Files
vector/frontend/src/pages/AdminDashboard.jsx
T
root 3a0d2b0c2d B6: SPA navigation after case creation (useNavigate instead of window.location.href)
B7: stop AdminDashboard refetching the case list on selection change

selectedCaseId was both read and written inside the list-loading effect and
listed in its deps, causing a second fetch on first render and after every
filter reset. Auto-select now uses a functional state update, so the effect
no longer depends on selectedCaseId; editForm stays in sync via the existing
selectedCase effect.
2026-07-25 13:04:19 +00:00

689 lines
26 KiB
React

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 <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();
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 (
<div className="admin-dashboard">
<header className="admin-hero">
<div>
<p className="admin-kicker">Vector / admin</p>
<h1>Панель администрирования</h1>
<p className="admin-subtitle">
Список кейсов, быстрый просмотр исходов и редактирование административных полей.
</p>
</div>
<div className="admin-hero-actions">
<button type="button" className="admin-secondary-button" onClick={() => setPage(1)}>
Обновить
</button>
</div>
</header>
<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;