Завершение поиска: исход → архив + матмодель + операция completed
- POST /operations/{id}/complete: found_alive/дистанция/направление/кто нашёл/
длительность → кейс status=closed + фактические данные + прогон детерминированной
матмодели (model_prediction, accuracy_note в analysis_log) → операция
status=completed + closed_at. Аудит: operation_complete + case_closed.
- ФИКС list_closed_cases: фильтр по to_summary().analysis_log всегда давал None
(analysis_log только в to_detail) → список завершённых был пуст; теперь
фильтр по status='closed' (совместимо с TestDB).
- UI: кнопка «✓ Завершить поиск» на странице анализа (для активных операций),
модал с исходом, после завершения — плашка «Поиск завершён», в архиве
(/admin завершённые) появляется запись с прогнозом модели и точностью.
- Тесты (4): полный флоу, повторное завершение 400, аудит, появление в
/closed-cases. 230 passed.
This commit is contained in:
@@ -713,3 +713,43 @@
|
||||
.contour-send:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; }
|
||||
.contour-ok { font-size: 12px; color: #3fb950; }
|
||||
.contour-err { font-size: 12px; color: #f85149; }
|
||||
|
||||
|
||||
/* Завершение поиска */
|
||||
.complete-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
|
||||
.complete-btn {
|
||||
padding: 9px 16px; background: #9d3412; color: #fff; border: none;
|
||||
border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.complete-btn:hover { background: #c2410c; }
|
||||
.complete-hint { font-size: 12px; color: #8b949e; }
|
||||
.complete-done { margin-top: 12px; padding: 9px 12px; background: rgba(63,185,80,0.1); border: 1px solid rgba(63,185,80,0.4); border-radius: 6px; color: #3fb950; font-size: 13px; }
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-box {
|
||||
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
||||
padding: 20px; width: 620px; max-width: 94vw; max-height: 90vh; overflow-y: auto;
|
||||
}
|
||||
.modal-box h3 { margin: 0 0 6px; color: #e6edf3; font-size: 17px; }
|
||||
.modal-sub { margin: 0 0 14px; font-size: 12px; color: #8b949e; }
|
||||
.modal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
|
||||
.modal-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #8b949e; }
|
||||
.modal-grid input, .modal-grid select {
|
||||
padding: 8px 10px; background: #0d1117; border: 1px solid #30363d;
|
||||
border-radius: 6px; color: #e6edf3; font-size: 13px;
|
||||
}
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
||||
.modal-cancel {
|
||||
padding: 9px 14px; background: #21262d; color: #e6edf3; border: 1px solid #30363d;
|
||||
border-radius: 6px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.modal-confirm {
|
||||
padding: 9px 14px; background: #9d3412; color: #fff; border: none;
|
||||
border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.modal-confirm:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; }
|
||||
.complete-ok { padding: 8px 12px; background: rgba(63,185,80,0.1); border-radius: 6px; color: #3fb950; font-size: 13px; margin-bottom: 8px; }
|
||||
.complete-err { padding: 8px 12px; background: rgba(248,81,73,0.1); border-radius: 6px; color: #f85149; font-size: 13px; margin-bottom: 8px; }
|
||||
|
||||
@@ -39,6 +39,13 @@ const AnalysisResult = () => {
|
||||
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 () => {
|
||||
@@ -286,6 +293,18 @@ const AnalysisResult = () => {
|
||||
)}
|
||||
</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>}
|
||||
@@ -422,6 +441,79 @@ const AnalysisResult = () => {
|
||||
</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>Направление (N/NE/E/...)
|
||||
<input value={completeForm.found_direction} onChange={(e) => setCompleteForm({ ...completeForm, found_direction: e.target.value })} />
|
||||
</label>
|
||||
<label>Кто нашёл
|
||||
<input value={completeForm.who_found} onChange={(e) => setCompleteForm({ ...completeForm, who_found: e.target.value })} />
|
||||
</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>Тип места находки
|
||||
<input value={completeForm.found_location_type} onChange={(e) => setCompleteForm({ ...completeForm, found_location_type: e.target.value })} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user