1513bad538
- search_engine: max_distance_km передаётся в analyzer (case_data). - rules_analysis: search_radius = max_distance × _psychotype_radius_multiplier (среднее по полосам 0-500/0.5-1.5/1.5-2.5/2.5+). Убран двойной учёт age/season: score_zone.distance_multiplier их уже содержит, а max_distance их тоже учёл — раньше radius = multiplier×2.0 (хардкод) давал 1.6 км при радиусе 2.4 км. - UI: подсказка у «×0.8» — коэффициент скоринга, не радиуса. - Тест: согласованность summary↔формула.
549 lines
27 KiB
React
549 lines
27 KiB
React
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 (
|
||
<div className="analysis-result">
|
||
<div className="loading-state">
|
||
<div className="spinner"></div>
|
||
<p>Загрузка данных и расчёт зон поиска...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="analysis-result">
|
||
<div className="error-state">
|
||
<h2>Ошибка загрузки</h2>
|
||
<p>{error}</p>
|
||
<button onClick={() => window.location.reload()}>Попробовать снова</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!caseData) {
|
||
return (
|
||
<div className="analysis-result">
|
||
<div className="error-state">
|
||
<h2>Карточка не найдена</h2>
|
||
<p>ID: {caseId}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="analysis-result">
|
||
{/* ===== Шапка: кейс + срочность + радиус ===== */}
|
||
<div className="result-header">
|
||
<div className="header-top">
|
||
<h1><span className="logo">ВЕКТОР</span> Результаты анализа</h1>
|
||
<div className="case-info">
|
||
<span>{caseData.child_name || 'Ребёнок'}, {caseData.age_years} лет{genderLabel ? ` (${genderLabel})` : ''}</span>
|
||
<span className="separator">•</span>
|
||
<span>Время потери: {caseData.loss_time || 'не указано'}</span>
|
||
<span className="separator">•</span>
|
||
<span>Прошло: {caseData.elapsed_hours || 1} ч</span>
|
||
{analysisData && (
|
||
<>
|
||
<span className="separator">•</span>
|
||
<span style={{ color: 'var(--success)' }}>
|
||
Локальный расчёт
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Панель решения: срочность | радиус | действия */}
|
||
<div className="decision-strip">
|
||
<div className={`decision-cell urgency-cell urgency-${urgencyLevel}`}>
|
||
<div className="decision-label">Срочность</div>
|
||
<div className="decision-value">{analysisData?.urgency || '—'}</div>
|
||
</div>
|
||
<div className="decision-cell">
|
||
<div className="decision-label">Радиус поиска</div>
|
||
<div className="decision-value radius">
|
||
{analysisData?.max_distance_km} км
|
||
{analysisData?.distance_multiplier !== 1 && (
|
||
<span
|
||
className="radius-mult"
|
||
title="Суммарный коэффициент скоринга (возраст/сезон/профили) — влияет на сравнение секторов, не на радиус."
|
||
>×{analysisData?.distance_multiplier}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="decision-cell warnings-cell">
|
||
<div className="decision-label">Критические действия</div>
|
||
{warnings.length > 0 ? (
|
||
<ul className="decision-warnings">
|
||
{warnings.map((w, i) => <li key={i}>{w.warning}</li>)}
|
||
</ul>
|
||
) : (
|
||
<div className="decision-warnings muted">Нет критических предупреждений</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{analysisData?.summary && <p className="header-summary">{analysisData.summary}</p>}
|
||
</div>
|
||
|
||
<div className="result-content">
|
||
|
||
{/* ===== Ряд 1: Карта + зоны ===== */}
|
||
<div className={`main-row ${hasMapClass(hasMap)}`}>
|
||
<div className="map-section">
|
||
<div className="section-head">
|
||
<h2>Карта зон поиска</h2>
|
||
{(waterData?.count > 0) && (
|
||
<div className="geo-chips">
|
||
<span className="geo-chip water">💧 Водоёмы: {waterCount}</span>
|
||
{railCount > 0 && <span className="geo-chip rail">🚂 Ж/д: {railCount}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{waterData?.warning && (
|
||
<div className="geo-warning">
|
||
🚨 {waterData.warning}
|
||
</div>
|
||
)}
|
||
{waterData?.railway_warning && railCount > 0 && (
|
||
<div className="geo-warning rail">
|
||
🚂 {waterData.railway_warning}
|
||
</div>
|
||
)}
|
||
{hasMap ? (
|
||
<SearchMap zones={zones}
|
||
maxDistance={parseFloat(analysisData?.max_distance_km)}
|
||
tnpLat={parseFloat(tnpLat)}
|
||
tnpLon={parseFloat(tnpLon)}
|
||
waterFeatures={waterData?.features || []}
|
||
/>
|
||
) : (
|
||
<div className="map-empty">ТНП не задана — карта недоступна</div>
|
||
)}
|
||
{operation && (
|
||
<div className="contour-panel">
|
||
<div className="contour-title">🔗 КОНТУР (полевая координация)</div>
|
||
<div className="contour-row">
|
||
<input
|
||
className="contour-input"
|
||
placeholder="UUID операции в КОНТУРе (создаётся в штабной панели)"
|
||
value={contourId}
|
||
onChange={(e) => setContourId(e.target.value)}
|
||
/>
|
||
<button
|
||
className="contour-save"
|
||
onClick={async () => {
|
||
const r = await apiFetch(`/api/v1/operations/${operation.id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ contour_operation_id: contourId }),
|
||
});
|
||
setContourStatus(r.ok
|
||
? { ok: true, message: 'ID операции КОНТУРа сохранён' }
|
||
: { ok: false, message: 'Не удалось сохранить ID' });
|
||
}}
|
||
>Сохранить ID</button>
|
||
</div>
|
||
<div className="contour-row">
|
||
<button
|
||
className="contour-send"
|
||
disabled={!contourId || !analysisData}
|
||
onClick={async () => {
|
||
const r = await apiFetch(`/api/v1/operations/${operation.id}/send-to-contour`, { method: 'POST' });
|
||
const body = await r.json().catch(() => ({}));
|
||
setContourStatus(r.ok
|
||
? { ok: true, message: `Отправлено зон: ${body.sent_zones}. Статус в КОНТУРе: ${body.contour_response?.status}` }
|
||
: { ok: false, message: body.detail || 'Ошибка отправки' });
|
||
}}
|
||
>Отправить зоны в КОНТУР</button>
|
||
</div>
|
||
{contourStatus && (
|
||
<div className={contourStatus.ok ? 'contour-ok' : 'contour-err'}>
|
||
{contourStatus.message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{operation && operation.status !== 'completed' && (
|
||
<div className="complete-row">
|
||
<button
|
||
className="complete-btn"
|
||
onClick={() => { setShowComplete(true); setCompleteResult(null); }}
|
||
>✓ Завершить поиск</button>
|
||
<span className="complete-hint">Внесение исхода → кейс в архив + прогон матмодели</span>
|
||
</div>
|
||
)}
|
||
{operation && operation.status === 'completed' && (
|
||
<div className="complete-done">✓ Поиск завершён {operation.closed_at ? new Date(operation.closed_at).toLocaleString('ru-RU') : ''} — запись в архиве завершённых</div>
|
||
)}
|
||
<div className="map-info">
|
||
<span>ТНП: {tnpLat ? Number(tnpLat).toFixed(5) : '—'}, {tnpLon ? Number(tnpLon).toFixed(5) : '—'}</span>
|
||
{caseData.tnp_address && <span className="sep">·</span>}
|
||
{caseData.tnp_address && <span>{caseData.tnp_address}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="zones-panel">
|
||
<div className="section-head">
|
||
<h2>Зоны поиска</h2>
|
||
<span className="zones-count">{zones.length} зон</span>
|
||
</div>
|
||
<div className="zones-list">
|
||
{zones.slice(0, 8).map((zone, idx) => (
|
||
<div key={idx} className={`zone-card priority-${Math.min(idx + 1, 3)}`}>
|
||
<div className="zone-header">
|
||
<div className="zone-title">
|
||
<span className="zone-priority">{idx + 1}</span>
|
||
<span className="zone-name">{zone.name}</span>
|
||
</div>
|
||
<div className="zone-meta">
|
||
<span className="zone-dir">{DIRECTION_RU[zone.direction] || zone.direction}</span>
|
||
<span className="zone-dist">{zone.distance}</span>
|
||
<div className="zone-score">{zone.score}%</div>
|
||
</div>
|
||
</div>
|
||
{zone.reasoning && <div className="zone-reasoning">{zone.reasoning}</div>}
|
||
</div>
|
||
))}
|
||
{zones.length === 0 && <div className="zones-empty">Зоны не рассчитаны</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ===== Ряд 2: Поведение + действия ===== */}
|
||
<div className="behavior-row">
|
||
<div className="info-card behavior-card">
|
||
<div className="section-head">
|
||
<h3>Поведение и профиль</h3>
|
||
{(profiles.length > 0 || unmodeled.length > 0) && (
|
||
<div className="profile-tags">
|
||
{profiles.map((p, i) => <span key={i} className="profile-tag">{p.name}</span>)}
|
||
{unmodeled.map((u, i) => <span key={`u${i}`} className="profile-tag unmodeled" title={u.note}>{u.profile}*</span>)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{analysisData?.behavioral_prediction && (
|
||
<p className="prediction">{analysisData.behavioral_prediction}</p>
|
||
)}
|
||
{psyRec && (
|
||
<div className="tactics-grid">
|
||
<div className="tactic"><div className="tactic-label">Приоритетные зоны</div><div>{psyRec.priority_zones}</div></div>
|
||
<div className="tactic"><div className="tactic-label">Паттерн поиска</div><div>{psyRec.search_pattern}</div></div>
|
||
<div className="tactic"><div className="tactic-label">Ключевые объекты</div><div>{psyRec.key_locations}</div></div>
|
||
<div className="tactic"><div className="tactic-label">Связь</div><div>{psyRec.communication}</div></div>
|
||
</div>
|
||
)}
|
||
{unmodeled.length > 0 && (
|
||
<div className="unmodeled-note">
|
||
{unmodeled.map((u, i) => (
|
||
<p key={i}>• {u.profile}: {u.note}</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="info-card actions-card">
|
||
<h3>Немедленные действия</h3>
|
||
<ol className="actions-list">
|
||
{(analysisData?.immediate_actions || []).map((a, i) => (
|
||
<li key={i} className={a.startsWith('КРИТИЧНО') ? 'critical' : ''}>{a}</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ===== Ряд 3: Обоснование радиуса (формула Экстремум) ===== */}
|
||
{analysisData && (
|
||
<div className="info-card radius-card">
|
||
<div className="section-head">
|
||
<h3>Обоснование радиуса</h3>
|
||
<span className="radius-formula">Distance = Time × НормС × СП × СКД × СУ × СУТ × ВВС × ВП</span>
|
||
</div>
|
||
<div className="coeff-grid">
|
||
{coeffChips.map((c, i) => (
|
||
<div key={i} className="coeff-chip">
|
||
<div className="coeff-name">{c.label} <span className="coeff-value">{c.value}</span></div>
|
||
<div className="coeff-hint">{c.hint}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{psyMods && (
|
||
<div className="psy-mods">
|
||
<span className="psy-mods-title">Психотип {psychoRu(analysisData?.psychotype)} — поправки полос:</span>
|
||
{['0–500 м', '500–1500 м', '1500–2500 м', '2500 м+'].map((band, i) => (
|
||
<span key={i} className="psy-mod-chip">{band}: ×{[psyMods.zone_0_500, psyMods.zone_500_1500, psyMods.zone_1500_2500, psyMods.zone_2500plus][i]}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ===== Ряд 4: Справочные данные (3 колонки) ===== */}
|
||
<div className="reference-grid">
|
||
<div className="info-card">
|
||
<h3>Обстоятельства потери</h3>
|
||
<p><strong>Причина:</strong> {caseData.reason || caseData.loss_reason || 'не указана'}</p>
|
||
<p><strong>Последнее известное место:</strong> {caseData.last_known_place || 'не указано'}</p>
|
||
{caseData.direction && <p><strong>Направление движения:</strong> {caseData.direction}</p>}
|
||
{caseData.behavior_description && <p><strong>Описание:</strong> {caseData.behavior_description}</p>}
|
||
</div>
|
||
|
||
<div className="info-card">
|
||
<h3>Здоровье и особенности</h3>
|
||
{caseData.has_diagnosis ? (
|
||
<p><strong>Диагнозы:</strong> {caseData.diagnosis_type?.join(', ')}</p>
|
||
) : (
|
||
<p><strong>Диагнозы:</strong> нет</p>
|
||
)}
|
||
{caseData.fitness_level && <p><strong>Физподготовка:</strong> {caseData.fitness_level}</p>}
|
||
{caseData.cant_swim && <p>⚠️ <strong>Не умеет плавать</strong> — приоритет ближайшим водоёмам</p>}
|
||
{caseData.has_transport && caseData.has_transport !== 'none' && (
|
||
<p><strong>Транспорт:</strong> {caseData.has_transport}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="info-card">
|
||
<h3>Условия среды</h3>
|
||
<p><strong>Сезон:</strong> {caseData.season || 'не указан'}</p>
|
||
{caseData.temperature_c != null && <p><strong>Температура:</strong> {caseData.temperature_c}°C</p>}
|
||
<p><strong>Осадки:</strong> {caseData.weather || caseData.precipitation || 'нет'}</p>
|
||
{caseData.visibility && <p><strong>Видимость:</strong> {caseData.visibility}</p>}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
{showComplete && (
|
||
<div className="modal-overlay" onClick={() => setShowComplete(false)}>
|
||
<div className="modal-box" onClick={(e) => e.stopPropagation()}>
|
||
<h3>Завершение поиска</h3>
|
||
<p className="modal-sub">Исход вносится в карточку, кейс уходит в архив завершённых, операция помечается завершённой.</p>
|
||
<div className="modal-grid">
|
||
<label>Найден?
|
||
<select value={completeForm.found_alive} onChange={(e) => setCompleteForm({ ...completeForm, found_alive: e.target.value })}>
|
||
<option value="true">Живым</option>
|
||
<option value="false">Погибшим</option>
|
||
</select>
|
||
</label>
|
||
<label>Дистанция, км
|
||
<input type="number" step="0.1" value={completeForm.found_distance_km} onChange={(e) => setCompleteForm({ ...completeForm, found_distance_km: e.target.value })} />
|
||
</label>
|
||
<label>Направление от ТНП
|
||
<select value={completeForm.found_direction} onChange={(e) => setCompleteForm({ ...completeForm, found_direction: e.target.value })}>
|
||
<option value="">—</option>
|
||
{FOUND_DIRECTIONS.map((o) => <option key={o} value={o}>{o}</option>)}
|
||
</select>
|
||
</label>
|
||
<label>Кто нашёл
|
||
<select value={completeForm.who_found} onChange={(e) => setCompleteForm({ ...completeForm, who_found: e.target.value })}>
|
||
<option value="">—</option>
|
||
{WHO_FOUND_TYPES.map((o) => <option key={o} value={o}>{o}</option>)}
|
||
</select>
|
||
</label>
|
||
<label>Длительность поиска, ч
|
||
<input type="number" step="0.5" value={completeForm.search_duration_hours} onChange={(e) => setCompleteForm({ ...completeForm, search_duration_hours: e.target.value })} />
|
||
</label>
|
||
<label>Тип места находки
|
||
<select value={completeForm.found_location_type} onChange={(e) => setCompleteForm({ ...completeForm, found_location_type: e.target.value })}>
|
||
<option value="">—</option>
|
||
{LOCATION_TYPES.map((o) => <option key={o} value={o}>{o}</option>)}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
{completeResult && (
|
||
<div className={completeResult.ok ? 'complete-ok' : 'complete-err'}>{completeResult.message}</div>
|
||
)}
|
||
<div className="modal-actions">
|
||
<button className="modal-cancel" onClick={() => setShowComplete(false)}>Отмена</button>
|
||
<button
|
||
className="modal-confirm"
|
||
disabled={completing}
|
||
onClick={async () => {
|
||
setCompleting(true);
|
||
setCompleteResult(null);
|
||
try {
|
||
const f = completeForm;
|
||
const body = {
|
||
found_alive: f.found_alive === 'true',
|
||
found_distance_km: f.found_distance_km ? Number(f.found_distance_km) : null,
|
||
found_direction: f.found_direction || null,
|
||
found_location_type: f.found_location_type || null,
|
||
search_duration_hours: f.search_duration_hours ? Number(f.search_duration_hours) : null,
|
||
who_found: f.who_found || null,
|
||
};
|
||
const r = await apiFetch(`/api/v1/operations/${operation.id}/complete`, {
|
||
method: 'POST', body: JSON.stringify(body),
|
||
});
|
||
const d = await r.json().catch(() => ({}));
|
||
if (r.ok) {
|
||
const acc = d.accuracy_note ? ` Точность прогноза: ${d.accuracy_note}` : '';
|
||
setCompleteResult({ ok: true, message: `Поиск завершён, кейс в архиве.${acc}` });
|
||
setOperation({ ...operation, status: 'completed', closed_at: d.closed_at });
|
||
setTimeout(() => setShowComplete(false), 1800);
|
||
} else {
|
||
setCompleteResult({ ok: false, message: d.detail || 'Ошибка завершения' });
|
||
}
|
||
} catch (e) {
|
||
setCompleteResult({ ok: false, message: e.message });
|
||
} finally {
|
||
setCompleting(false);
|
||
}
|
||
}}
|
||
>{completing ? 'Завершение...' : 'Завершить поиск'}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// РУ-названия психотипов (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; |