Fix admin import chain and tests

This commit is contained in:
root
2026-06-06 20:59:23 +00:00
parent fcc22063ed
commit 3548733019
15 changed files with 1818 additions and 1193 deletions
+383 -276
View File
@@ -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;
}
}
+670 -53
View File
@@ -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;