Fix admin import chain and tests
This commit is contained in:
@@ -1,332 +1,439 @@
|
||||
/* Админка — палитра из §5 контекста */
|
||||
.admin-dashboard {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-nav button {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.admin-nav button:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-nav button.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: linear-gradient(180deg, #0d1117 0%, #111827 100%);
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.stat-change.positive {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stat-change.negative {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.cases-table {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
.admin-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 18px;
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
.table-header h2 {
|
||||
.admin-kicker {
|
||||
margin: 0 0 8px;
|
||||
color: #ff7b42;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.admin-hero h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.admin-subtitle {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
color: #8b949e;
|
||||
line-height: 1.5;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
.admin-hero-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
.admin-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-metric-card,
|
||||
.admin-panel {
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 16px;
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
.admin-metric-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-metric-label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: #8b949e;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.admin-metric-card strong {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.admin-upload-panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-upload-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-upload-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-file-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: min(100%, 320px);
|
||||
}
|
||||
|
||||
.admin-file-picker input {
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
background: #161b22;
|
||||
color: #e6edf3;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.admin-upload-hint {
|
||||
color: #8b949e;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-upload-preview {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-upload-grid h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.admin-preview-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-preview-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #21262d;
|
||||
background: #161b22;
|
||||
}
|
||||
|
||||
.admin-preview-list strong {
|
||||
text-transform: lowercase;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.admin-preview-list span {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-raw-details {
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 12px;
|
||||
background: #161b22;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.admin-raw-details summary {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
.admin-raw-text {
|
||||
margin: 12px 0 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
.admin-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
.admin-panel-header h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
table {
|
||||
.admin-panel-header p {
|
||||
margin: 0;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.admin-filters-grid,
|
||||
.admin-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.7fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-table-panel,
|
||||
.admin-editor-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
thead th {
|
||||
padding: 12px 20px;
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
color: #8b949e;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 16px 20px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: var(--warning);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.status-badge.closed {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.archived {
|
||||
background: var(--text-secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Графики */
|
||||
.chart-container {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-container h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Цвета для графиков из палитры админки */
|
||||
.chart-distance {
|
||||
color: var(--chart-distance);
|
||||
}
|
||||
|
||||
.chart-terrain {
|
||||
color: var(--chart-terrain);
|
||||
}
|
||||
|
||||
.chart-outcome {
|
||||
color: var(--chart-outcome);
|
||||
}
|
||||
|
||||
/* Форма загрузки документов */
|
||||
.upload-zone {
|
||||
background: var(--bg-secondary);
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
.admin-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-primary);
|
||||
.admin-table tbody tr:hover,
|
||||
.admin-table tbody tr.is-selected {
|
||||
background: rgba(255, 77, 0, 0.08);
|
||||
}
|
||||
|
||||
.upload-zone.dragging {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-primary);
|
||||
.admin-empty-state {
|
||||
padding: 28px 12px;
|
||||
text-align: center;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 48px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
.admin-pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
.admin-pagination-summary {
|
||||
color: #8b949e;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.admin-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
.admin-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-field-wide {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.admin-field span {
|
||||
color: #8b949e;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-field input,
|
||||
.admin-field select,
|
||||
.admin-field textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
background: #161b22;
|
||||
color: #e6edf3;
|
||||
padding: 12px 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.admin-field textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.admin-form-actions {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.admin-case-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.admin-case-meta span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #8b949e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-case-meta strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-empty-editor {
|
||||
padding: 28px 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.admin-alert {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-alert-error {
|
||||
background: rgba(248, 81, 73, 0.12);
|
||||
border: 1px solid #f85149;
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
.admin-alert-success {
|
||||
background: rgba(46, 160, 67, 0.12);
|
||||
border: 1px solid #2ea043;
|
||||
color: #b8f7c5;
|
||||
}
|
||||
|
||||
.admin-primary-button,
|
||||
.admin-secondary-button {
|
||||
min-height: 44px;
|
||||
border-radius: 12px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-primary-button {
|
||||
background: #ff4d00;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-secondary-button {
|
||||
background: #161b22;
|
||||
border-color: #30363d;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.admin-secondary-button:disabled,
|
||||
.admin-primary-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.admin-badge-neutral {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.admin-badge-success {
|
||||
background: rgba(46, 160, 67, 0.15);
|
||||
border: 1px solid #2ea043;
|
||||
color: #b8f7c5;
|
||||
}
|
||||
|
||||
.admin-badge-danger {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
border: 1px solid #f85149;
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
.admin-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.admin-metrics-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-nav button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
|
||||
.admin-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-dashboard {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
width: 100%;
|
||||
|
||||
.admin-hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-actions button {
|
||||
width: 100%;
|
||||
|
||||
.admin-metrics-grid,
|
||||
.admin-filters-grid,
|
||||
.admin-form-grid,
|
||||
.admin-case-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
thead th,
|
||||
tbody td {
|
||||
padding: 8px 12px;
|
||||
|
||||
.admin-pagination {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,682 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import CasesList from '../components/admin/CasesList';
|
||||
import CaseDetail from '../components/admin/CaseDetail';
|
||||
import HeatmapView from '../components/admin/HeatmapView';
|
||||
import Statistics from '../components/admin/Statistics';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import './AdminDashboard.css';
|
||||
|
||||
const AdminDashboard = () => {
|
||||
const location = useLocation();
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const OUTCOME_OPTIONS = [
|
||||
{ value: '', label: 'Все' },
|
||||
{ value: 'new', label: 'new' },
|
||||
{ value: 'analyzed', label: 'analyzed' },
|
||||
{ value: 'closed', label: 'closed' },
|
||||
];
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—';
|
||||
try {
|
||||
return new Date(value).toLocaleString('ru-RU', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function Badge({ children, tone = 'neutral' }) {
|
||||
return <span className={`admin-badge admin-badge-${tone}`}>{children}</span>;
|
||||
}
|
||||
|
||||
function AdminDashboard() {
|
||||
const [cases, setCases] = useState([]);
|
||||
const [dashboard, setDashboard] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dashboardLoading, setDashboardLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [filters, setFilters] = useState({ status: '', ageMin: '', ageMax: '' });
|
||||
const [selectedCaseId, setSelectedCaseId] = useState('');
|
||||
const [editForm, setEditForm] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const [uploadFile, setUploadFile] = useState(null);
|
||||
const [uploadLoading, setUploadLoading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState('');
|
||||
const [uploadMessage, setUploadMessage] = useState('');
|
||||
const [parsePreview, setParsePreview] = useState(null);
|
||||
const [parseRawText, setParseRawText] = useState('');
|
||||
const [parseCaseId, setParseCaseId] = useState('');
|
||||
const [parseCaseFields, setParseCaseFields] = useState({});
|
||||
|
||||
const selectedCase = useMemo(
|
||||
() => cases.find((item) => item.id === selectedCaseId) || null,
|
||||
[cases, selectedCaseId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadCases() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', String(PAGE_SIZE));
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.ageMin !== '') params.set('age_min', filters.ageMin);
|
||||
if (filters.ageMax !== '') params.set('age_max', filters.ageMax);
|
||||
|
||||
const response = await fetch(`/api/v1/admin/cases?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Не удалось загрузить список кейсов (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
setCases(payload.items || []);
|
||||
setTotal(payload.total || 0);
|
||||
if ((payload.items || []).length && !selectedCaseId) {
|
||||
setSelectedCaseId(payload.items[0].id);
|
||||
setEditForm(payload.items[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
setError(err.message || 'Ошибка загрузки админ-таблицы');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadCases();
|
||||
return () => controller.abort();
|
||||
}, [filters.ageMax, filters.ageMin, filters.status, page, selectedCaseId]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadDashboard() {
|
||||
setDashboardLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/dashboard', { signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Не удалось загрузить dashboard (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
setDashboard(payload);
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
setDashboard(null);
|
||||
}
|
||||
} finally {
|
||||
setDashboardLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadDashboard();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCase) return;
|
||||
setEditForm(selectedCase);
|
||||
}, [selectedCase]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const parsePreviewFields = useMemo(() => {
|
||||
if (!parsePreview) return [];
|
||||
return Object.entries(parsePreview).filter(([key]) => key !== 'case' && key !== 'raw_text');
|
||||
}, [parsePreview]);
|
||||
|
||||
function resetParseState() {
|
||||
setUploadFile(null);
|
||||
setUploadError('');
|
||||
setUploadMessage('');
|
||||
setParsePreview(null);
|
||||
setParseRawText('');
|
||||
setParseCaseId('');
|
||||
setParseCaseFields({});
|
||||
}
|
||||
|
||||
function handleUploadFile(event) {
|
||||
setUploadFile(event.target.files?.[0] || null);
|
||||
setUploadError('');
|
||||
setUploadMessage('');
|
||||
}
|
||||
|
||||
async function handleUploadPreview(event) {
|
||||
event.preventDefault();
|
||||
if (!uploadFile) {
|
||||
setUploadError('Выбери .docx файл для предпросмотра');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadLoading(true);
|
||||
setUploadError('');
|
||||
setUploadMessage('');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadFile);
|
||||
|
||||
const response = await fetch('/api/v1/admin/parse-doc', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.detail || `Не удалось распарсить документ (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
setParsePreview(payload.preview || {});
|
||||
setParseRawText(payload.raw_text || '');
|
||||
setParseCaseId(payload.preview?.case_id || '');
|
||||
setParseCaseFields(payload.preview?.case || {});
|
||||
setUploadMessage('Документ распознан, можно проверить preview ниже');
|
||||
} catch (err) {
|
||||
setUploadError(err.message || 'Ошибка загрузки файла');
|
||||
} finally {
|
||||
setUploadLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function fieldFromPreview(name, fallback = '') {
|
||||
const value = parseCaseFields?.[name];
|
||||
return value === undefined || value === null || value === '' ? fallback : String(value);
|
||||
}
|
||||
|
||||
async function handleImportPreview(event) {
|
||||
event.preventDefault();
|
||||
if (!parseCaseFields || Object.keys(parseCaseFields).length === 0) {
|
||||
setUploadError('Сначала загрузи и распарсь .docx файл');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadLoading(true);
|
||||
setUploadError('');
|
||||
setUploadMessage('');
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
age: parseCaseFields.age ?? null,
|
||||
gender: parseCaseFields.gender ?? null,
|
||||
status: parseCaseFields.status ?? 'new',
|
||||
found_alive: parseCaseFields.found_alive ?? null,
|
||||
found_distance_km: parseCaseFields.found_distance_km ?? null,
|
||||
found_lat: parseCaseFields.found_lat ?? null,
|
||||
found_lon: parseCaseFields.found_lon ?? null,
|
||||
who_found: parseCaseFields.who_found ?? null,
|
||||
note: parseCaseFields.notes ?? null,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/v1/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.detail || `Не удалось создать кейс (${response.status})`);
|
||||
}
|
||||
|
||||
const created = await response.json();
|
||||
setUploadMessage(`Кейс создан: ${created.id}`);
|
||||
resetParseState();
|
||||
setSaveMessage('Импорт из .docx завершён');
|
||||
setPage(1);
|
||||
} catch (err) {
|
||||
setUploadError(err.message || 'Ошибка импорта');
|
||||
} finally {
|
||||
setUploadLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilterChange(event) {
|
||||
const { name, value } = event.target;
|
||||
setPage(1);
|
||||
setSelectedCaseId('');
|
||||
setFilters((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
|
||||
function handleSelectCase(caseId) {
|
||||
setSelectedCaseId(caseId);
|
||||
const nextCase = cases.find((item) => item.id === caseId);
|
||||
if (nextCase) {
|
||||
setEditForm(nextCase);
|
||||
setSaveMessage('');
|
||||
setSaveError('');
|
||||
}
|
||||
}
|
||||
|
||||
function updateEditForm(name, value) {
|
||||
setEditForm((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
|
||||
async function handleSave(event) {
|
||||
event.preventDefault();
|
||||
if (!selectedCaseId) return;
|
||||
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
setSaveMessage('');
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
status: editForm.status ?? null,
|
||||
found_alive: editForm.found_alive ?? null,
|
||||
found_distance_km: editForm.found_distance_km === '' ? null : editForm.found_distance_km,
|
||||
found_direction: editForm.found_direction ?? null,
|
||||
found_location_type: editForm.found_location_type ?? null,
|
||||
found_lat: editForm.found_lat === '' ? null : editForm.found_lat,
|
||||
found_lon: editForm.found_lon === '' ? null : editForm.found_lon,
|
||||
who_found: editForm.who_found ?? null,
|
||||
note: editForm.notes ?? null,
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/v1/admin/cases/${selectedCaseId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.detail || `Не удалось сохранить изменения (${response.status})`);
|
||||
}
|
||||
|
||||
const updated = await response.json();
|
||||
setSaveMessage('Изменения сохранены');
|
||||
setCases((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
|
||||
setEditForm(updated);
|
||||
setDashboardLoading(true);
|
||||
try {
|
||||
const dashboardResponse = await fetch('/api/v1/admin/dashboard');
|
||||
if (dashboardResponse.ok) {
|
||||
setDashboard(await dashboardResponse.json());
|
||||
}
|
||||
} finally {
|
||||
setDashboardLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setSaveError(err.message || 'Ошибка сохранения');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-dashboard">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h1>SAR МЧС</h1>
|
||||
<p className="subtitle">Система анализа поисков</p>
|
||||
<header className="admin-hero">
|
||||
<div>
|
||||
<p className="admin-kicker">Vector / admin</p>
|
||||
<h1>Панель администрирования</h1>
|
||||
<p className="admin-subtitle">
|
||||
Список кейсов, быстрый просмотр исходов и редактирование административных полей.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<Link
|
||||
to="/admin/cases"
|
||||
className={location.pathname.startsWith('/admin/cases') ? 'active' : ''}
|
||||
>
|
||||
<span className="icon">📋</span>
|
||||
Карточки
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/heatmap"
|
||||
className={location.pathname === '/admin/heatmap' ? 'active' : ''}
|
||||
>
|
||||
<span className="icon">🗺️</span>
|
||||
Тепловая карта
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/statistics"
|
||||
className={location.pathname === '/admin/statistics' ? 'active' : ''}
|
||||
>
|
||||
<span className="icon">📊</span>
|
||||
Статистика
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<a href="/mobile" target="_blank" rel="noopener noreferrer" className="mobile-link">
|
||||
<span className="icon">📱</span>
|
||||
Мобильная форма
|
||||
</a>
|
||||
<div className="admin-hero-actions">
|
||||
<button type="button" className="admin-secondary-button" onClick={() => setPage(1)}>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<CasesList />} />
|
||||
<Route path="/cases" element={<CasesList />} />
|
||||
<Route path="/cases/:id" element={<CaseDetail />} />
|
||||
<Route path="/heatmap" element={<HeatmapView />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<section className="admin-metrics-grid">
|
||||
<article className="admin-metric-card">
|
||||
<span className="admin-metric-label">Всего кейсов</span>
|
||||
<strong>{dashboardLoading ? '…' : dashboard?.total_cases ?? 0}</strong>
|
||||
</article>
|
||||
<article className="admin-metric-card">
|
||||
<span className="admin-metric-label">Найден живым</span>
|
||||
<strong>{dashboardLoading ? '…' : dashboard?.found_alive_count ?? 0}</strong>
|
||||
</article>
|
||||
<article className="admin-metric-card">
|
||||
<span className="admin-metric-label">Найден погибшим</span>
|
||||
<strong>{dashboardLoading ? '…' : dashboard?.found_deceased_count ?? 0}</strong>
|
||||
</article>
|
||||
<article className="admin-metric-card">
|
||||
<span className="admin-metric-label">Неизвестный исход</span>
|
||||
<strong>{dashboardLoading ? '…' : dashboard?.unknown_outcome_count ?? 0}</strong>
|
||||
</article>
|
||||
<article className="admin-metric-card">
|
||||
<span className="admin-metric-label">Медиана дистанции</span>
|
||||
<strong>
|
||||
{dashboardLoading ? '…' : dashboard?.median_found_distance_km ?? '—'}
|
||||
</strong>
|
||||
</article>
|
||||
<article className="admin-metric-card">
|
||||
<span className="admin-metric-label">Активность 7 дней</span>
|
||||
<strong>{dashboardLoading ? '…' : dashboard?.recent_activity_count ?? 0}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="admin-panel admin-filters-panel">
|
||||
<div className="admin-panel-header">
|
||||
<h2>Фильтры</h2>
|
||||
<p>Список кейсов можно сужать по статусу и возрасту.</p>
|
||||
</div>
|
||||
<div className="admin-filters-grid">
|
||||
<label className="admin-field">
|
||||
<span>Статус</span>
|
||||
<select name="status" value={filters.status} onChange={handleFilterChange}>
|
||||
{OUTCOME_OPTIONS.map((option) => (
|
||||
<option key={option.value || 'all'} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>Возраст от</span>
|
||||
<input
|
||||
name="ageMin"
|
||||
type="number"
|
||||
min="0"
|
||||
value={filters.ageMin}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="18"
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>Возраст до</span>
|
||||
<input
|
||||
name="ageMax"
|
||||
type="number"
|
||||
min="0"
|
||||
value={filters.ageMax}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="25"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-panel admin-upload-panel">
|
||||
<div className="admin-panel-header">
|
||||
<div>
|
||||
<h2>Импорт отчёта .docx</h2>
|
||||
<p>Загрузите документ, проверьте preview и импортируйте извлечённые поля в новый кейс.</p>
|
||||
</div>
|
||||
<div className="admin-upload-hint">
|
||||
{parseCaseId ? `Preview case_id: ${parseCaseId}` : 'Ждём файл'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="admin-upload-form" onSubmit={handleUploadPreview}>
|
||||
<div className="admin-upload-row">
|
||||
<label className="admin-file-picker">
|
||||
<span>Выбери .docx</span>
|
||||
<input type="file" accept=".docx" onChange={handleUploadFile} />
|
||||
</label>
|
||||
<button type="submit" className="admin-secondary-button" disabled={uploadLoading}>
|
||||
{uploadLoading ? 'Распознаю…' : 'Распознать'}
|
||||
</button>
|
||||
<button type="button" className="admin-primary-button" onClick={handleImportPreview} disabled={uploadLoading}>
|
||||
Импортировать в кейс
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{uploadError ? <div className="admin-alert admin-alert-error">{uploadError}</div> : null}
|
||||
{uploadMessage ? <div className="admin-alert admin-alert-success">{uploadMessage}</div> : null}
|
||||
|
||||
{parsePreview ? (
|
||||
<div className="admin-upload-preview">
|
||||
<div className="admin-upload-grid">
|
||||
<div>
|
||||
<h3>Распознанные поля</h3>
|
||||
<ul className="admin-preview-list">
|
||||
{parsePreviewFields.map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<strong>{key}</strong>
|
||||
<span>{typeof value === 'object' ? JSON.stringify(value) : String(value)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Кейс preview</h3>
|
||||
<ul className="admin-preview-list">
|
||||
<li><strong>age</strong><span>{fieldFromPreview('age', '—')}</span></li>
|
||||
<li><strong>gender</strong><span>{fieldFromPreview('gender', '—')}</span></li>
|
||||
<li><strong>status</strong><span>{fieldFromPreview('status', 'new')}</span></li>
|
||||
<li><strong>found_alive</strong><span>{fieldFromPreview('found_alive', '—')}</span></li>
|
||||
<li><strong>found_distance_km</strong><span>{fieldFromPreview('found_distance_km', '—')}</span></li>
|
||||
<li><strong>who_found</strong><span>{fieldFromPreview('who_found', '—')}</span></li>
|
||||
<li><strong>notes</strong><span>{fieldFromPreview('notes', '—')}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{parseRawText ? (
|
||||
<details className="admin-raw-details">
|
||||
<summary>Показать raw text</summary>
|
||||
<pre className="admin-raw-text">{parseRawText}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{error ? <div className="admin-alert admin-alert-error">{error}</div> : null}
|
||||
|
||||
<section className="admin-layout">
|
||||
<div className="admin-panel admin-table-panel">
|
||||
<div className="admin-panel-header">
|
||||
<div>
|
||||
<h2>Кейсы</h2>
|
||||
<p>
|
||||
Показано {cases.length} из {total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-pagination-summary">
|
||||
Страница {page} из {totalPages}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Дата</th>
|
||||
<th>Возраст</th>
|
||||
<th>Статус</th>
|
||||
<th>Исход</th>
|
||||
<th>Дистанция</th>
|
||||
<th>Место</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="admin-empty-state">
|
||||
Загрузка…
|
||||
</td>
|
||||
</tr>
|
||||
) : cases.length ? (
|
||||
cases.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={item.id === selectedCaseId ? 'is-selected' : ''}
|
||||
onClick={() => handleSelectCase(item.id)}
|
||||
>
|
||||
<td className="admin-mono">{item.id.slice(0, 8)}</td>
|
||||
<td>{formatDate(item.created_at)}</td>
|
||||
<td>{item.age ?? '—'}</td>
|
||||
<td>
|
||||
<Badge tone={item.status === 'closed' ? 'success' : 'neutral'}>{item.status}</Badge>
|
||||
</td>
|
||||
<td>
|
||||
{item.found_alive === true ? (
|
||||
<Badge tone="success">alive</Badge>
|
||||
) : item.found_alive === false ? (
|
||||
<Badge tone="danger">deceased</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">unknown</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td>{item.found_distance_km ?? '—'}</td>
|
||||
<td>{item.last_known_place || item.found_location_type || '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="7" className="admin-empty-state">
|
||||
Нет данных по выбранным фильтрам
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="admin-pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Вперёд
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="admin-panel admin-editor-panel">
|
||||
<div className="admin-panel-header">
|
||||
<div>
|
||||
<h2>Редактирование исхода</h2>
|
||||
<p>{selectedCase ? `Кейс ${selectedCase.id.slice(0, 8)}` : 'Выберите строку в таблице'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCase ? (
|
||||
<form className="admin-form" onSubmit={handleSave}>
|
||||
<div className="admin-form-grid">
|
||||
<label className="admin-field">
|
||||
<span>Статус</span>
|
||||
<input
|
||||
value={editForm.status ?? ''}
|
||||
onChange={(event) => updateEditForm('status', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>Кто нашёл</span>
|
||||
<input
|
||||
value={editForm.who_found ?? ''}
|
||||
onChange={(event) => updateEditForm('who_found', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>Найден живым</span>
|
||||
<select
|
||||
value={editForm.found_alive === null || editForm.found_alive === undefined ? '' : String(editForm.found_alive)}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
updateEditForm('found_alive', value === '' ? null : value === 'true');
|
||||
}}
|
||||
>
|
||||
<option value="">Не указано</option>
|
||||
<option value="true">Да</option>
|
||||
<option value="false">Нет</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>Дистанция, км</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editForm.found_distance_km ?? ''}
|
||||
onChange={(event) =>
|
||||
updateEditForm('found_distance_km', event.target.value === '' ? '' : Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>Широта</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
value={editForm.found_lat ?? ''}
|
||||
onChange={(event) =>
|
||||
updateEditForm('found_lat', event.target.value === '' ? '' : Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>Долгота</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
value={editForm.found_lon ?? ''}
|
||||
onChange={(event) =>
|
||||
updateEditForm('found_lon', event.target.value === '' ? '' : Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="admin-field admin-field-wide">
|
||||
<span>Тип точки</span>
|
||||
<input
|
||||
value={editForm.found_location_type ?? ''}
|
||||
onChange={(event) => updateEditForm('found_location_type', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field admin-field-wide">
|
||||
<span>Комментарий / note</span>
|
||||
<textarea
|
||||
rows="4"
|
||||
value={editForm.notes ?? ''}
|
||||
onChange={(event) => updateEditForm('notes', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{saveError ? <div className="admin-alert admin-alert-error">{saveError}</div> : null}
|
||||
{saveMessage ? <div className="admin-alert admin-alert-success">{saveMessage}</div> : null}
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<button type="submit" className="admin-primary-button" disabled={saving}>
|
||||
{saving ? 'Сохраняю…' : 'Сохранить исход'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-case-meta">
|
||||
<div>
|
||||
<span>Последнее обновление</span>
|
||||
<strong>{formatDate(editForm.updated_at)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Исходное место</span>
|
||||
<strong>{editForm.last_known_place || '—'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="admin-empty-editor">
|
||||
<p>Нажми на строку в таблице, чтобы открыть карточку редактирования.</p>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default AdminDashboard;
|
||||
|
||||
Reference in New Issue
Block a user