feat: завершённые поиски — ручной ввод + детерминированный прогон модели (без ИИ)
Бэкенд:
- schemas.py: ClosedCaseCreate/Response — форма ~25 полей (субъект, среда,
ресурсы, исход) для внесения завершённых поисков вручную
- routers/closed_cases.py: CRUD + POST {id}/reanalyze. _run_model() —
тот же скоринг, что /analyze (WeightedScorer + distance_service +
психотип), НО БЕЗ claude_service: urgency/behavior/actions по правилам
- _accuracy_note(): прогнозный радиус vs факт (в радиусе / мимо)
- main.py: роутер подключён
Фронтенд:
- pages/ClosedCases.jsx + .css: форма внесения (VCL-density, чипы диагнозов/
рельефа), блок 'Прогноз vs факт', таблица архива (в радиусе/мимо)
- AdminDashboard.jsx: вкладки Кейсы | Завершённые поиски
Тесты: test_closed_cases.py — 6 passed. Существующие 15 failed
(test_distance_service) не связаны с изменениями — падали до них.
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { apiFetch } from '../api/client';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import ClosedCases from './ClosedCases';
|
||||
import './AdminDashboard.css';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -31,6 +32,7 @@ function Badge({ children, tone = 'neutral' }) {
|
||||
|
||||
function AdminDashboard() {
|
||||
const { user, logout } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState('cases'); // 'cases' | 'closed'
|
||||
const [cases, setCases] = useState([]);
|
||||
const [dashboard, setDashboard] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -338,6 +340,35 @@ function AdminDashboard() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-tabs" style={{ display: 'flex', gap: 4, marginBottom: 12 }}>
|
||||
{[
|
||||
{ id: 'cases', label: 'Кейсы' },
|
||||
{ id: 'closed', label: 'Завершённые поиски' },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #c2cad4',
|
||||
borderBottom: activeTab === t.id ? 'none' : '1px solid #c2cad4',
|
||||
background: activeTab === t.id ? '#fff' : '#eef2f7',
|
||||
fontWeight: activeTab === t.id ? 600 : 400,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'closed' ? (
|
||||
<ClosedCases />
|
||||
) : (
|
||||
<>
|
||||
<section className="admin-metrics-grid">
|
||||
<article className="admin-metric-card">
|
||||
<span className="admin-metric-label">Всего кейсов</span>
|
||||
@@ -685,6 +716,8 @@ function AdminDashboard() {
|
||||
)}
|
||||
</aside>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/* Завершённые поиски — VCL-density, без скруглений/теней */
|
||||
|
||||
.cc-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cc-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #d5dbe3;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cc-panel-header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.cc-panel-header p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #667;
|
||||
}
|
||||
|
||||
.cc-form {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.cc-group-title {
|
||||
font-size: 13px;
|
||||
margin: 14px 0 8px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #e3e8ee;
|
||||
color: #234;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.cc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cc-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cc-field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.cc-field span {
|
||||
color: #456;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cc-field input,
|
||||
.cc-field select,
|
||||
.cc-field textarea {
|
||||
border: 1px solid #c2cad4;
|
||||
background: #fff;
|
||||
padding: 5px 7px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.cc-field input:focus,
|
||||
.cc-field select:focus,
|
||||
.cc-field textarea:focus {
|
||||
outline: 2px solid #2b6cb0;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.cc-chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cc-chip {
|
||||
border: 1px solid #b8c2ce;
|
||||
background: #f4f6f9;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.cc-chip-on {
|
||||
background: #2b6cb0;
|
||||
color: #fff;
|
||||
border-color: #2b6cb0;
|
||||
}
|
||||
|
||||
.cc-actions {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.cc-primary-btn {
|
||||
background: #2b6cb0;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.cc-primary-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cc-alert {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cc-alert-error {
|
||||
background: #fde8e8;
|
||||
border: 1px solid #e53e3e;
|
||||
color: #9b2c2c;
|
||||
}
|
||||
|
||||
/* Прогноз vs факт */
|
||||
.cc-pred-grid {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.cc-pred-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cc-pred-item span {
|
||||
font-size: 11px;
|
||||
color: #567;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.cc-pred-item strong {
|
||||
font-size: 20px;
|
||||
color: #1a365d;
|
||||
}
|
||||
|
||||
.cc-accuracy {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
background: #f0f5fb;
|
||||
border: 1px solid #b8d0ea;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Таблица архива */
|
||||
.cc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.cc-table th,
|
||||
.cc-table td {
|
||||
border: 1px solid #dde3ea;
|
||||
padding: 5px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cc-table th {
|
||||
background: #eef2f7;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.cc-table tbody tr:hover {
|
||||
background: #f7fafd;
|
||||
}
|
||||
|
||||
.cc-hit {
|
||||
color: #276749;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cc-miss {
|
||||
color: #c53030;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cc-link-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #c53030;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cc-empty {
|
||||
color: #678;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { apiFetch } from '../api/client';
|
||||
import './ClosedCases.css';
|
||||
|
||||
const DIAGNOSIS_OPTIONS = ['РАС', 'эпилепсия', 'СДВГ', 'ЗПР'];
|
||||
const TERRAIN_OPTIONS = ['лес', 'болото', 'поле', 'город', 'село', 'горы', 'водоём'];
|
||||
const TRANSPORT_OPTIONS = [
|
||||
{ value: 'none', label: 'нет' },
|
||||
{ value: 'bike', label: 'велосипед' },
|
||||
{ value: 'scooter', label: 'самокат' },
|
||||
{ value: 'other', label: 'другое' },
|
||||
];
|
||||
const SEASON_OPTIONS = ['зима', 'весна', 'лето', 'осень'];
|
||||
const WEATHER_OPTIONS = ['нет', 'дождь', 'ливень', 'туман', 'снег', 'жара'];
|
||||
const LOCATION_TYPES = ['дорога', 'тропа', 'лес', 'дом/стройка', 'вода/берег', 'открытое поле', 'не указано'];
|
||||
|
||||
const EMPTY_FORM = {
|
||||
age_years: '',
|
||||
gender: 'м',
|
||||
diagnosis_type: [],
|
||||
has_transport: 'none',
|
||||
cant_swim: false,
|
||||
fitness_level: '',
|
||||
elapsed_hours: '',
|
||||
loss_time: '',
|
||||
terrain: [],
|
||||
weather: '',
|
||||
season: '',
|
||||
temperature_c: '',
|
||||
tnp_lat: '',
|
||||
tnp_lon: '',
|
||||
tnp_address: '',
|
||||
teams_count: '',
|
||||
team_size: '',
|
||||
has_dog: false,
|
||||
found_alive: 'true',
|
||||
found_distance_km: '',
|
||||
found_direction: '',
|
||||
found_location_type: '',
|
||||
found_lat: '',
|
||||
found_lon: '',
|
||||
search_duration_hours: '',
|
||||
who_found: '',
|
||||
notes: '',
|
||||
source: '',
|
||||
};
|
||||
|
||||
function num(value) {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function CleanForm(form) {
|
||||
return {
|
||||
...form,
|
||||
age_years: num(form.age_years) ?? 0,
|
||||
elapsed_hours: num(form.elapsed_hours) ?? 0,
|
||||
loss_time: form.loss_time ? new Date(form.loss_time).toISOString() : null,
|
||||
temperature_c: num(form.temperature_c),
|
||||
tnp_lat: num(form.tnp_lat),
|
||||
tnp_lon: num(form.tnp_lon),
|
||||
teams_count: num(form.teams_count),
|
||||
team_size: num(form.team_size),
|
||||
found_distance_km: num(form.found_distance_km),
|
||||
found_lat: num(form.found_lat),
|
||||
found_lon: num(form.found_lon),
|
||||
search_duration_hours: num(form.search_duration_hours),
|
||||
};
|
||||
}
|
||||
|
||||
function ChipGroup({ options, selected, onToggle }) {
|
||||
return (
|
||||
<div className="cc-chip-group">
|
||||
{options.map((opt) => {
|
||||
const isOn = selected.includes(opt);
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
className={`cc-chip ${isOn ? 'cc-chip-on' : ''}`}
|
||||
onClick={() => onToggle(opt)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children, wide = false }) {
|
||||
return (
|
||||
<label className={`cc-field ${wide ? 'cc-field-wide' : ''}`}>
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClosedCases() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [lastPrediction, setLastPrediction] = useState(null);
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await apiFetch('/api/v1/closed-cases?page=1&page_size=200');
|
||||
setItems(data.items || []);
|
||||
} catch (e) {
|
||||
setError(e.message || 'Ошибка загрузки');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const update = (key, value) => setForm((f) => ({ ...f, [key]: value }));
|
||||
const toggleChip = (key, opt) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
[key]: f[key].includes(opt) ? f[key].filter((x) => x !== opt) : [...f[key], opt],
|
||||
}));
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
if (!form.age_years || !form.elapsed_hours) {
|
||||
setError('Обязательны: возраст и часы с момента потери');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await apiFetch('/api/v1/closed-cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(CleanForm(form)),
|
||||
});
|
||||
setLastPrediction(created);
|
||||
setForm(EMPTY_FORM);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e.message || 'Ошибка сохранения');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
if (!window.confirm('Удалить запись?')) return;
|
||||
try {
|
||||
await apiFetch(`/api/v1/closed-cases/${id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cc-wrap">
|
||||
{/* ---------- Форма внесения ---------- */}
|
||||
<section className="cc-panel">
|
||||
<div className="cc-panel-header">
|
||||
<h2>Завершённый поиск — ручной ввод</h2>
|
||||
<p>Данные субъекта, среды и реальный исход. Модель считается без ИИ, детерминированно.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="cc-form">
|
||||
<h3 className="cc-group-title">Субъект</h3>
|
||||
<div className="cc-grid">
|
||||
<Field label="Возраст, лет *">
|
||||
<input type="number" min="0" max="120" value={form.age_years}
|
||||
onChange={(e) => update('age_years', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Пол">
|
||||
<select value={form.gender} onChange={(e) => update('gender', e.target.value)}>
|
||||
<option value="м">Мужской</option>
|
||||
<option value="ж">Женский</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Транспорт">
|
||||
<select value={form.has_transport} onChange={(e) => update('has_transport', e.target.value)}>
|
||||
{TRANSPORT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Не умеет плавать">
|
||||
<select value={form.cant_swim ? '1' : '0'} onChange={(e) => update('cant_swim', e.target.value === '1')}>
|
||||
<option value="0">Нет</option>
|
||||
<option value="1">Да</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Диагнозы / особенности" wide>
|
||||
<ChipGroup options={DIAGNOSIS_OPTIONS} selected={form.diagnosis_type} onToggle={(o) => toggleChip('diagnosis_type', o)} />
|
||||
</Field>
|
||||
|
||||
<h3 className="cc-group-title">Обстоятельства и среда</h3>
|
||||
<div className="cc-grid">
|
||||
<Field label="Часов с момента потери *">
|
||||
<input type="number" min="0" step="0.5" value={form.elapsed_hours}
|
||||
onChange={(e) => update('elapsed_hours', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Время потери">
|
||||
<input type="datetime-local" value={form.loss_time}
|
||||
onChange={(e) => update('loss_time', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Погода">
|
||||
<select value={form.weather} onChange={(e) => update('weather', e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{WEATHER_OPTIONS.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Сезон">
|
||||
<select value={form.season} onChange={(e) => update('season', e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{SEASON_OPTIONS.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Температура, °C">
|
||||
<input type="number" step="1" value={form.temperature_c}
|
||||
onChange={(e) => update('temperature_c', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="ТНП (адрес/место)">
|
||||
<input value={form.tnp_address} onChange={(e) => update('tnp_address', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="ТНП широта">
|
||||
<input type="number" step="0.000001" value={form.tnp_lat}
|
||||
onChange={(e) => update('tnp_lat', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="ТНП долгота">
|
||||
<input type="number" step="0.000001" value={form.tnp_lon}
|
||||
onChange={(e) => update('tnp_lon', e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Рельеф / местность" wide>
|
||||
<ChipGroup options={TERRAIN_OPTIONS} selected={form.terrain} onToggle={(o) => toggleChip('terrain', o)} />
|
||||
</Field>
|
||||
|
||||
<h3 className="cc-group-title">Ресурсы</h3>
|
||||
<div className="cc-grid">
|
||||
<Field label="Групп">
|
||||
<input type="number" min="0" value={form.teams_count}
|
||||
onChange={(e) => update('teams_count', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Человек в группе">
|
||||
<input type="number" min="0" value={form.team_size}
|
||||
onChange={(e) => update('team_size', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Кинолог">
|
||||
<select value={form.has_dog ? '1' : '0'} onChange={(e) => update('has_dog', e.target.value === '1')}>
|
||||
<option value="0">Нет</option>
|
||||
<option value="1">Да</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<h3 className="cc-group-title">Исход (факт)</h3>
|
||||
<div className="cc-grid">
|
||||
<Field label="Найден живым">
|
||||
<select value={form.found_alive} onChange={(e) => update('found_alive', e.target.value)}>
|
||||
<option value="true">Да</option>
|
||||
<option value="false">Нет (погиб)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Дистанция находки, км">
|
||||
<input type="number" step="0.1" min="0" value={form.found_distance_km}
|
||||
onChange={(e) => update('found_distance_km', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Направление от ТНП">
|
||||
<input value={form.found_direction} placeholder="С, СВ, В…"
|
||||
onChange={(e) => update('found_direction', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Тип места находки">
|
||||
<select value={form.found_location_type} onChange={(e) => update('found_location_type', e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{LOCATION_TYPES.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Кто нашёл">
|
||||
<input value={form.who_found} placeholder="группа, кинолог, местные…"
|
||||
onChange={(e) => update('who_found', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Длительность поиска, ч">
|
||||
<input type="number" step="0.5" value={form.search_duration_hours}
|
||||
onChange={(e) => update('search_duration_hours', e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="cc-grid">
|
||||
<Field label="Источник (спецдонесение №, год)" wide>
|
||||
<input value={form.source} placeholder="напр. Спецдонесение 2015-07, бумажный журнал"
|
||||
onChange={(e) => update('source', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Заметки" wide>
|
||||
<textarea rows="2" value={form.notes} onChange={(e) => update('notes', e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{error ? <div className="cc-alert cc-alert-error">{error}</div> : null}
|
||||
|
||||
<div className="cc-actions">
|
||||
<button type="submit" className="cc-primary-btn" disabled={saving}>
|
||||
{saving ? 'Сохраняю…' : 'Внести и прогнать модель'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ---------- Результат последнего прогона ---------- */}
|
||||
{lastPrediction && (
|
||||
<section className="cc-panel cc-prediction">
|
||||
<h2>Прогноз модели vs факт</h2>
|
||||
<div className="cc-pred-grid">
|
||||
<div className="cc-pred-item">
|
||||
<span>Прогнозный радиус</span>
|
||||
<strong>{lastPrediction.model_prediction?.max_distance_km ?? '—'} км</strong>
|
||||
</div>
|
||||
<div className="cc-pred-item">
|
||||
<span>Факт (дистанция находки)</span>
|
||||
<strong>{lastPrediction.found_distance_km ?? '—'} км</strong>
|
||||
</div>
|
||||
<div className="cc-pred-item">
|
||||
<span>Срочность (по правилам)</span>
|
||||
<strong>{lastPrediction.model_prediction?.urgency ?? '—'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{lastPrediction.accuracy_note && (
|
||||
<div className="cc-accuracy">{lastPrediction.accuracy_note}</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ---------- Список внесённых ---------- */}
|
||||
<section className="cc-panel">
|
||||
<div className="cc-panel-header">
|
||||
<h2>Архив завершённых поисков ({items.length})</h2>
|
||||
</div>
|
||||
{loading ? <p>Загрузка…</p> : items.length === 0 ? (
|
||||
<p className="cc-empty">Пока ничего не внесено.</p>
|
||||
) : (
|
||||
<table className="cc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Возраст</th><th>Пол</th><th>Часов</th><th>Рельеф</th>
|
||||
<th>Найден</th><th>Факт, км</th><th>Прогноз, км</th><th>Точность</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const pred = item.model_prediction?.max_distance_km;
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>{item.age_years}</td>
|
||||
<td>{item.gender}</td>
|
||||
<td>{item.elapsed_hours}</td>
|
||||
<td>{(item.terrain || []).join(', ') || '—'}</td>
|
||||
<td>{item.found_alive ? 'живым' : 'погиб'}</td>
|
||||
<td>{item.found_distance_km ?? '—'}</td>
|
||||
<td>{pred != null ? Number(pred).toFixed(1) : '—'}</td>
|
||||
<td className={item.accuracy_note && item.accuracy_note.includes('ЗА') ? 'cc-miss' : 'cc-hit'}>
|
||||
{item.accuracy_note ? (item.accuracy_note.includes('ЗА') ? 'мимо' : 'в радиусе') : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" className="cc-link-btn" onClick={() => remove(item.id)}>✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user