import React, { useState, useEffect } from "react"; import { useParams } from "react-router-dom"; import SearchMap from '../../components/SearchMap'; import { apiFetch } from "../../api/client"; import { LOCATION_TYPES, WHO_FOUND_TYPES, FOUND_DIRECTIONS } from "../../constants"; import "./AnalysisResult.css"; const GENDER_LABEL = { m: 'М', f: 'Ж' }; const DIRECTION_RU = { N: 'С', NE: 'СВ', E: 'В', SE: 'ЮВ', S: 'Ю', SW: 'ЮЗ', W: 'З', NW: 'СЗ' }; // Маппинг уровня срочности бэкенда → CSS-класс (фронтовые классы в Cyrillic) const urgencyClass = (urgency) => { const u = (urgency || '').toLowerCase(); if (u.includes('крит')) return 'критический'; if (u.includes('высок')) return 'высокий'; if (u.includes('сред')) return 'средний'; return 'стандартный'; }; // Адаптер зон бэкенда (PrimaryZone: priority/name/direction/distance/reason) // → формат SearchMap (нужен score для радиуса и reasoning для попапа). // score выводим из приоритета — даёт визуальный градиент (топ-зоны крупнее), // как в прежней синтетической версии. const adaptZones = (primaryZones) => (primaryZones || []).map(z => ({ priority: z.priority, name: z.name, direction: z.direction, distance: `${z.distance} км`, score: Math.max(10, 100 - ((z.priority || 1) - 1) * 10), reasoning: z.reason || '', })); const AnalysisResult = () => { const { caseId } = useParams(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [caseData, setCaseData] = useState(null); const [analysisData, setAnalysisData] = useState(null); const [waterData, setWaterData] = useState(null); const [operation, setOperation] = useState(null); const [contourId, setContourId] = useState(''); const [contourStatus, setContourStatus] = useState(null); // {ok, message} const [showComplete, setShowComplete] = useState(false); const [completeForm, setCompleteForm] = useState({ found_alive: 'true', found_distance_km: '', found_direction: '', found_location_type: '', search_duration_hours: '', who_found: '', notes: '', }); const [completing, setCompleting] = useState(false); const [completeResult, setCompleteResult] = useState(null); useEffect(() => { const loadCaseAndAnalyze = async () => { try { setLoading(true); setError(null); // 1. Карточка случая — для шапки и обстоятельств const caseResponse = await apiFetch(`/api/v1/cases/${caseId}`); if (!caseResponse.ok) throw new Error("Не удалось загрузить карточку"); const caseResult = await caseResponse.json(); setCaseData(caseResult); // E4: операция этого кейса (для КОНТУР-отправки) try { const opsResp = await apiFetch(`/api/v1/operations?case_id=${caseId}`); if (opsResp.ok) { const opsData = await opsResp.json(); if (opsData.items?.length) { setOperation(opsData.items[0]); setContourId(opsData.items[0].contour_operation_id || ''); } } } catch { /* операции может не быть — панель скрыта */ } // 2. Анализ — единый источник правды на бэкенде const analyzeResponse = await apiFetch(`/api/v1/analyze`, { method: 'POST', body: JSON.stringify({ case_id: caseId }), }); if (!analyzeResponse.ok) throw new Error("Не удалось запустить анализ"); const analysis = await analyzeResponse.json(); setAnalysisData(analysis); // 3. Водоёмы вокруг ТНП (слой на карте; 404/409 — нет координат, не ошибка) try { const waterResponse = await apiFetch(`/api/v1/water/${caseId}`); if (waterResponse.ok) setWaterData(await waterResponse.json()); } catch { /* водоёмы — опциональный слой, не блокируют анализ */ } } catch (err) { console.error("Error loading case:", err); setError(err.message); } finally { setLoading(false); } }; if (caseId) { loadCaseAndAnalyze(); } }, [caseId]); if (loading) { return (

Загрузка данных и расчёт зон поиска...

); } if (error) { return (

Ошибка загрузки

{error}

); } if (!caseData) { return (

Карточка не найдена

ID: {caseId}

); } const zones = adaptZones(analysisData?.primary_zones); const coeffs = analysisData?.coefficients || {}; const psyMods = analysisData?.psychotype_modifiers; const psyRec = analysisData?.psychotype_recommendations; const genderLabel = GENDER_LABEL[caseData.gender] || caseData.gender || ''; const urgencyLevel = urgencyClass(analysisData?.urgency); const profiles = analysisData?.active_profiles || []; const warnings = analysisData?.critical_warnings || []; const unmodeled = analysisData?.unmodeled_profiles || []; const tnpLat = caseData.tnp_lat || caseData.gps_lat; const tnpLon = caseData.tnp_lon || caseData.gps_lon; const waterCount = (waterData?.features || []).filter(f => f.layer !== 'railway').length; const railCount = (waterData?.features || []).filter(f => f.layer === 'railway').length; // Коэффициенты формулы Экстремум — компактные чипы (название: значение) const coeffChips = [ { label: 'Время', value: `${caseData.elapsed_hours || 1} ч`, hint: 'от потери' }, { label: 'НормС', value: coeffs.base_speed, hint: `баз. скорость, ${caseData.age_years} лет` }, { label: 'СП', value: coeffs.terrain, hint: caseData.terrain?.join(', ') || 'местность' }, { label: 'СКД', value: coeffs.diagnosis, hint: caseData.diagnosis_type?.join(', ') || 'без диагноза' }, { label: 'СУ', value: coeffs.urgency, hint: caseData.has_transport && caseData.has_transport !== 'none' ? caseData.has_transport : 'без транспорта' }, { label: 'СУТ', value: coeffs.fatigue?.toFixed(2), hint: 'усталость' }, { label: 'ВВС', value: coeffs.time_of_day, hint: analysisData?.time_of_day || 'время суток' }, { label: 'ВП', value: coeffs.weather, hint: caseData.weather || caseData.precipitation || 'без осадков' }, ]; // Средний ряд: карта + зоны рядом const hasMap = tnpLat && tnpLon; return (
{/* ===== Шапка: кейс + срочность + радиус ===== */}

ВЕКТОР Результаты анализа

{caseData.child_name || 'Ребёнок'}, {caseData.age_years} лет{genderLabel ? ` (${genderLabel})` : ''} • Время потери: {caseData.loss_time || 'не указано'} • Прошло: {caseData.elapsed_hours || 1} ч {analysisData && ( <> • Локальный расчёт )}
{/* Панель решения: срочность | радиус | действия */}
Срочность
{analysisData?.urgency || '—'}
Радиус поиска
{analysisData?.max_distance_km} км {analysisData?.distance_multiplier !== 1 && ( ×{analysisData?.distance_multiplier} )}
Критические действия
{warnings.length > 0 ? (
    {warnings.map((w, i) =>
  • {w.warning}
  • )}
) : (
Нет критических предупреждений
)}
{analysisData?.summary &&

{analysisData.summary}

}
{/* ===== Ряд 1: Карта + зоны ===== */}

Карта зон поиска

{(waterData?.count > 0) && (
💧 Водоёмы: {waterCount} {railCount > 0 && 🚂 Ж/д: {railCount}}
)}
{waterData?.warning && (
🚨 {waterData.warning}
)} {waterData?.railway_warning && railCount > 0 && (
🚂 {waterData.railway_warning}
)} {hasMap ? ( ) : (
ТНП не задана — карта недоступна
)} {operation && (
🔗 КОНТУР (полевая координация)
setContourId(e.target.value)} />
{contourStatus && (
{contourStatus.message}
)}
)} {operation && operation.status !== 'completed' && (
Внесение исхода → кейс в архив + прогон матмодели
)} {operation && operation.status === 'completed' && (
✓ Поиск завершён {operation.closed_at ? new Date(operation.closed_at).toLocaleString('ru-RU') : ''} — запись в архиве завершённых
)}
ТНП: {tnpLat ? Number(tnpLat).toFixed(5) : '—'}, {tnpLon ? Number(tnpLon).toFixed(5) : '—'} {caseData.tnp_address && ·} {caseData.tnp_address && {caseData.tnp_address}}

Зоны поиска

{zones.length} зон
{zones.slice(0, 8).map((zone, idx) => (
{idx + 1} {zone.name}
{DIRECTION_RU[zone.direction] || zone.direction} {zone.distance}
{zone.score}%
{zone.reasoning &&
{zone.reasoning}
}
))} {zones.length === 0 &&
Зоны не рассчитаны
}
{/* ===== Ряд 2: Поведение + действия ===== */}

Поведение и профиль

{(profiles.length > 0 || unmodeled.length > 0) && (
{profiles.map((p, i) => {p.name})} {unmodeled.map((u, i) => {u.profile}*)}
)}
{analysisData?.behavioral_prediction && (

{analysisData.behavioral_prediction}

)} {psyRec && (
Приоритетные зоны
{psyRec.priority_zones}
Паттерн поиска
{psyRec.search_pattern}
Ключевые объекты
{psyRec.key_locations}
Связь
{psyRec.communication}
)} {unmodeled.length > 0 && (
{unmodeled.map((u, i) => (

• {u.profile}: {u.note}

))}
)}

Немедленные действия

    {(analysisData?.immediate_actions || []).map((a, i) => (
  1. {a}
  2. ))}
{/* ===== Ряд 3: Обоснование радиуса (формула Экстремум) ===== */} {analysisData && (

Обоснование радиуса

Distance = Time × НормС × СП × СКД × СУ × СУТ × ВВС × ВП
{coeffChips.map((c, i) => (
{c.label} {c.value}
{c.hint}
))}
{psyMods && (
Психотип {psychoRu(analysisData?.psychotype)} — поправки полос: {['0–500 м', '500–1500 м', '1500–2500 м', '2500 м+'].map((band, i) => ( {band}: ×{[psyMods.zone_0_500, psyMods.zone_500_1500, psyMods.zone_1500_2500, psyMods.zone_2500plus][i]} ))}
)}
)} {/* ===== Ряд 4: Справочные данные (3 колонки) ===== */}

Обстоятельства потери

Причина: {caseData.reason || caseData.loss_reason || 'не указана'}

Последнее известное место: {caseData.last_known_place || 'не указано'}

{caseData.direction &&

Направление движения: {caseData.direction}

} {caseData.behavior_description &&

Описание: {caseData.behavior_description}

}

Здоровье и особенности

{caseData.has_diagnosis ? (

Диагнозы: {caseData.diagnosis_type?.join(', ')}

) : (

Диагнозы: нет

)} {caseData.fitness_level &&

Физподготовка: {caseData.fitness_level}

} {caseData.cant_swim &&

⚠️ Не умеет плавать — приоритет ближайшим водоёмам

} {caseData.has_transport && caseData.has_transport !== 'none' && (

Транспорт: {caseData.has_transport}

)}

Условия среды

Сезон: {caseData.season || 'не указан'}

{caseData.temperature_c != null &&

Температура: {caseData.temperature_c}°C

}

Осадки: {caseData.weather || caseData.precipitation || 'нет'}

{caseData.visibility &&

Видимость: {caseData.visibility}

}
{showComplete && (
setShowComplete(false)}>
e.stopPropagation()}>

Завершение поиска

Исход вносится в карточку, кейс уходит в архив завершённых, операция помечается завершённой.

{completeResult && (
{completeResult.message}
)}
)}
); }; // РУ-названия психотипов (psychotype_service отдаёт slug'и) const PSY_RU = { dominant: 'Доминантный', harmonic: 'Гармоничный', anxious: 'Тревожный', introvert_passive: 'Интроверт пассивный', introvert_active: 'Интроверт активный', }; const psychoRu = (p) => PSY_RU[p] || p || ''; // Класс вёрстки главного ряда: карта+зоны / только зоны function hasMapClass(hasMap) { return hasMap ? 'with-map' : 'no-map'; } export default AnalysisResult;