import React, { useEffect, useMemo, useState } from 'react'; import './AdminDashboard.css'; 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 {children}; } 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(); const items = payload.items || []; setCases(items); setTotal(payload.total || 0); // Auto-select the first row only when nothing is selected yet. // Functional update so this effect does not have to read (and depend // on) selectedCaseId — depending on it caused a second fetch on first // render and after every filter reset. editForm is kept in sync by the // selectedCase effect below. if (items.length) { setSelectedCaseId((current) => current || items[0].id); } } catch (err) { if (err.name !== 'AbortError') { setError(err.message || 'Ошибка загрузки админ-таблицы'); } } finally { setLoading(false); } } loadCases(); return () => controller.abort(); }, [filters.ageMax, filters.ageMin, filters.status, page]); 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); setError(err.message || 'Ошибка загрузки дашборда'); } } 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 (
Vector / admin
Список кейсов, быстрый просмотр исходов и редактирование административных полей.
Список кейсов можно сужать по статусу и возрасту.
Загрузите документ, проверьте preview и импортируйте извлечённые поля в новый кейс.
{parseRawText}
Показано {cases.length} из {total}
| ID | Дата | Возраст | Статус | Исход | Дистанция | Место |
|---|---|---|---|---|---|---|
| Загрузка… | ||||||
| {item.id.slice(0, 8)} | {formatDate(item.created_at)} | {item.age ?? '—'} |
|
{item.found_alive === true ? (
|
{item.found_distance_km ?? '—'} | {item.last_known_place || item.found_location_type || '—'} |
| Нет данных по выбранным фильтрам | ||||||