3a0d2b0c2d
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.
689 lines
26 KiB
React
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;
|