Завершение поиска: исход → архив + матмодель + операция 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:
@@ -246,7 +246,9 @@ def list_closed_cases(
|
|||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
page_size: int = Query(default=50, ge=1, le=200),
|
page_size: int = Query(default=50, ge=1, le=200),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
cases = [c for c in db.list_cases() if (c.to_summary().get('analysis_log') or {}).get('closed_case')]
|
# status='closed' — надёжный признак архивной записи
|
||||||
|
# (analysis_log в to_summary не входит; фильтр в питоне — совместимо с TestDB)
|
||||||
|
cases = [c for c in db.list_cases() if c.to_summary().get('status') == 'closed']
|
||||||
items = []
|
items = []
|
||||||
for c in cases:
|
for c in cases:
|
||||||
detail = c.to_detail()
|
detail = c.to_detail()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from backend.audit import audit_log, visible_unit_ids
|
from backend.audit import audit_log, visible_unit_ids
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import SearchOperation, User
|
from backend.models import Case, SearchOperation, User
|
||||||
from backend.routers.auth import get_current_user, require_permission
|
from backend.routers.auth import get_current_user, require_permission
|
||||||
|
|
||||||
router = APIRouter(prefix='/api/v1/operations', tags=['operations'])
|
router = APIRouter(prefix='/api/v1/operations', tags=['operations'])
|
||||||
@@ -104,6 +104,112 @@ def _operation_dto(op: SearchOperation, users_by_id: dict | None = None) -> dict
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OperationComplete(BaseModel):
|
||||||
|
found_alive: bool
|
||||||
|
found_distance_km: Optional[float] = None
|
||||||
|
found_direction: Optional[str] = None
|
||||||
|
found_location_type: Optional[str] = None
|
||||||
|
found_lat: Optional[float] = None
|
||||||
|
found_lon: Optional[float] = None
|
||||||
|
search_duration_hours: Optional[float] = None
|
||||||
|
who_found: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/{operation_id}/complete')
|
||||||
|
def complete_operation(
|
||||||
|
operation_id: str,
|
||||||
|
payload: OperationComplete,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Завершение поиска: исход → кейс (архив + прогон модели) → операция completed."""
|
||||||
|
op = _get_operation_checked(db, current_user, operation_id)
|
||||||
|
|
||||||
|
if op.status == 'completed':
|
||||||
|
raise HTTPException(status_code=400, detail='Операция уже завершена')
|
||||||
|
if op.status == 'archived':
|
||||||
|
raise HTTPException(status_code=400, detail='Операция в архиве — завершение недоступно')
|
||||||
|
|
||||||
|
case = db.query(Case).filter(Case.id == op.case_id).first()
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(status_code=404, detail='Карточка операции не найдена')
|
||||||
|
|
||||||
|
# Данные кейса → вход матмодели (детерминированный прогон, как /analyze)
|
||||||
|
data = {
|
||||||
|
'age_years': case.age_years,
|
||||||
|
'gender': case.gender or 'м',
|
||||||
|
'diagnosis_type': case.diagnosis_type or [],
|
||||||
|
'has_transport': case.has_transport or 'none',
|
||||||
|
'cant_swim': bool(case.cant_swim),
|
||||||
|
'fitness_level': case.fitness_level,
|
||||||
|
'elapsed_hours': case.elapsed_hours or 0.0,
|
||||||
|
'loss_time': case.loss_time,
|
||||||
|
'terrain': case.terrain or [],
|
||||||
|
'weather': None, 'season': case.season, 'temperature_c': case.temperature_c,
|
||||||
|
'tnp_lat': case.tnp_lat, 'tnp_lon': case.tnp_lon, 'tnp_address': case.tnp_address,
|
||||||
|
'psychotype': case.psychotype, 'psychotype_answers': case.psychotype_answers,
|
||||||
|
'teams_count': case.teams_count, 'team_size': case.team_size,
|
||||||
|
'has_dog': bool(case.has_dog),
|
||||||
|
'found_alive': payload.found_alive,
|
||||||
|
'found_distance_km': payload.found_distance_km,
|
||||||
|
'found_direction': payload.found_direction,
|
||||||
|
'found_location_type': payload.found_location_type,
|
||||||
|
'found_lat': payload.found_lat, 'found_lon': payload.found_lon,
|
||||||
|
'search_duration_hours': payload.search_duration_hours,
|
||||||
|
'who_found': payload.who_found,
|
||||||
|
'notes': payload.notes,
|
||||||
|
'source': 'операция ВЕКТОР',
|
||||||
|
}
|
||||||
|
|
||||||
|
from backend.routers.closed_cases import _run_model as _run_model_func, _accuracy_note as _accuracy_note_func
|
||||||
|
from backend.schemas import ClosedCaseCreate
|
||||||
|
cc = ClosedCaseCreate(**{k: v for k, v in data.items() if k in ClosedCaseCreate.model_fields})
|
||||||
|
prediction = _run_model_func(cc.model_dump())
|
||||||
|
accuracy = _accuracy_note_func(prediction, cc)
|
||||||
|
|
||||||
|
# 1. Кейс → closed + фактические данные + маркер архивной записи
|
||||||
|
case.status = 'closed'
|
||||||
|
case.found_alive = payload.found_alive
|
||||||
|
case.found_distance_km = payload.found_distance_km
|
||||||
|
case.found_direction = payload.found_direction
|
||||||
|
case.found_location_type = payload.found_location_type
|
||||||
|
case.found_lat = payload.found_lat
|
||||||
|
case.found_lon = payload.found_lon
|
||||||
|
case.search_duration_hours = payload.search_duration_hours
|
||||||
|
case.who_found = payload.who_found
|
||||||
|
log = dict(case.analysis_log or {})
|
||||||
|
log.update({
|
||||||
|
'closed_case': True,
|
||||||
|
'model_prediction': prediction,
|
||||||
|
'accuracy_note': accuracy,
|
||||||
|
'source': 'операция ВЕКТОР',
|
||||||
|
'notes': payload.notes,
|
||||||
|
})
|
||||||
|
case.analysis_log = log
|
||||||
|
|
||||||
|
# 2. Операция → completed (через статусную машину, closed_at автоматически)
|
||||||
|
op.status = 'completed'
|
||||||
|
op.closed_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
audit_log(db, current_user, 'operation_complete',
|
||||||
|
object_type='search_operation', object_id=str(op.id),
|
||||||
|
details={'case_id': str(case.id), 'found_alive': payload.found_alive})
|
||||||
|
audit_log(db, current_user, 'case_closed', object_type='case', object_id=str(case.id),
|
||||||
|
details={'operation_id': str(op.id), 'found_alive': payload.found_alive})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'operation_id': str(op.id),
|
||||||
|
'status': op.status,
|
||||||
|
'closed_at': op.closed_at.isoformat() if op.closed_at else None,
|
||||||
|
'case_id': str(case.id),
|
||||||
|
'model_prediction': prediction,
|
||||||
|
'accuracy_note': accuracy,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get('/summary')
|
@router.get('/summary')
|
||||||
def operations_summary(
|
def operations_summary(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Тесты завершения поиска (кнопка «Завершить» → архив + модель + completed)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
if str(REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
import sqlalchemy # noqa: E402
|
||||||
|
from sqlalchemy import create_engine # noqa: E402
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID # noqa: E402
|
||||||
|
from sqlalchemy.ext.compiler import compiles # noqa: E402
|
||||||
|
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||||
|
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@compiles(PG_UUID, 'sqlite')
|
||||||
|
def _uuid_sqlite(type_, compiler, **kw):
|
||||||
|
return 'CHAR(36)'
|
||||||
|
|
||||||
|
|
||||||
|
@compiles(JSONB, 'sqlite')
|
||||||
|
def _jsonb_sqlite(type_, compiler, **kw):
|
||||||
|
return 'JSON'
|
||||||
|
|
||||||
|
|
||||||
|
@compiles(sqlalchemy.ARRAY, 'sqlite')
|
||||||
|
def _array_sqlite(type_, compiler, **kw):
|
||||||
|
return 'TEXT'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def complete_env(monkeypatch):
|
||||||
|
from backend import models as m
|
||||||
|
from backend.database import Base
|
||||||
|
from backend.models import (MchsUnit, Permission, Role, RolePermission,
|
||||||
|
SecuritySetting, SearchOperation, UserRole)
|
||||||
|
|
||||||
|
engine = create_engine('sqlite:///:memory:', connect_args={'check_same_thread': False},
|
||||||
|
poolclass=StaticPool)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
db = sessionmaker(bind=engine)()
|
||||||
|
|
||||||
|
rcu = MchsUnit(name='РЦУ РЧС', kind='rcu')
|
||||||
|
db.add(rcu); db.flush()
|
||||||
|
|
||||||
|
def _hash(pw): return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
root = m.User(username='root', email='r@t.by', hashed_password=_hash('rootpass123'),
|
||||||
|
role='admin', is_active=True, unit_id=rcu.id)
|
||||||
|
db.add(root); db.commit()
|
||||||
|
|
||||||
|
case = m.Case(age_years=10, gender='м', status='analyzed',
|
||||||
|
elapsed_hours=3.0, tnp_lat=53.9, tnp_lon=27.56)
|
||||||
|
db.add(case); db.flush(); db.commit()
|
||||||
|
|
||||||
|
op = SearchOperation(title='Операция завершения', case_id=case.id, status='active',
|
||||||
|
unit_id=rcu.id, created_by=root.id)
|
||||||
|
db.add(op); db.commit()
|
||||||
|
|
||||||
|
roles = {}
|
||||||
|
for name in ('admin', 'coordinator', 'operator', 'observer'):
|
||||||
|
r = Role(name=name, is_system=True); db.add(r); roles[name] = r
|
||||||
|
perms = {}
|
||||||
|
for code in ('view', 'create', 'update', 'delete', 'export', 'manage_users',
|
||||||
|
'manage_roles', 'view_audit', 'manage_security'):
|
||||||
|
p = Permission(code=code); db.add(p); perms[code] = p
|
||||||
|
db.commit()
|
||||||
|
for rn, codes in {'admin': tuple(perms), 'observer': ('view',)}.items():
|
||||||
|
for c in codes:
|
||||||
|
db.add(RolePermission(role_id=roles[rn].id, permission_id=perms[c].id))
|
||||||
|
db.add(UserRole(user_id=root.id, role_id=roles['admin'].id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
from backend.rbac import SECURITY_DEFAULTS
|
||||||
|
for k, v in SECURITY_DEFAULTS.items():
|
||||||
|
db.add(SecuritySetting(key=k, value=v))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
from backend.main import app
|
||||||
|
from backend.routers import auth as auth_router
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
def override():
|
||||||
|
yield db
|
||||||
|
|
||||||
|
app.dependency_overrides[auth_router.get_db] = override
|
||||||
|
# closed-cases читает через legacy-глобальный db + модульный SessionLocal —
|
||||||
|
# направляю оба в тестовую БД
|
||||||
|
import backend.database as database_mod
|
||||||
|
import backend.routers.closed_cases as closed_mod
|
||||||
|
from backend.database import SQLCaseRepository
|
||||||
|
test_session = sessionmaker(bind=engine)
|
||||||
|
monkeypatch.setattr(database_mod, 'SessionLocal', test_session)
|
||||||
|
monkeypatch.setattr(closed_mod, 'db', SQLCaseRepository())
|
||||||
|
client = TestClient(app)
|
||||||
|
tok = client.post('/api/v1/auth/login', data={'username': 'root', 'password': 'rootpass123'}).json()['access_token']
|
||||||
|
|
||||||
|
yield {'db': db, 'client': client, 'token': tok, 'op': op, 'case': case}
|
||||||
|
|
||||||
|
app.dependency_overrides.pop(auth_router.get_db, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompleteOperation:
|
||||||
|
def test_complete_flow(self, complete_env):
|
||||||
|
env = complete_env
|
||||||
|
r = env['client'].post(
|
||||||
|
f"/api/v1/operations/{env['op'].id}/complete",
|
||||||
|
headers={'Authorization': f"Bearer {env['token']}"},
|
||||||
|
json={'found_alive': True, 'found_distance_km': 4.2, 'found_direction': 'NE',
|
||||||
|
'search_duration_hours': 5.5, 'who_found': 'лесники'})
|
||||||
|
assert r.status_code == 200, r.json()
|
||||||
|
body = r.json()
|
||||||
|
assert body['status'] == 'completed'
|
||||||
|
assert body['closed_at'] is not None
|
||||||
|
# Кейс закрыт + фактические данные
|
||||||
|
from backend.models import Case
|
||||||
|
case = env['db'].query(Case).filter(Case.id == env['case'].id).first()
|
||||||
|
assert case.status == 'closed'
|
||||||
|
assert case.found_alive is True
|
||||||
|
assert case.found_distance_km == 4.2
|
||||||
|
assert case.who_found == 'лесники'
|
||||||
|
assert case.analysis_log.get('closed_case') is True
|
||||||
|
assert case.analysis_log.get('model_prediction') # прогон модели записан
|
||||||
|
# Операция завершена
|
||||||
|
from backend.models import SearchOperation
|
||||||
|
op = env['db'].query(SearchOperation).filter(SearchOperation.id == env['op'].id).first()
|
||||||
|
assert op.status == 'completed'
|
||||||
|
assert op.closed_at is not None
|
||||||
|
|
||||||
|
def test_double_complete_400(self, complete_env):
|
||||||
|
env = complete_env
|
||||||
|
h = {'Authorization': f"Bearer {env['token']}"}
|
||||||
|
payload = {'found_alive': False}
|
||||||
|
r1 = env['client'].post(f"/api/v1/operations/{env['op'].id}/complete", headers=h, json=payload)
|
||||||
|
assert r1.status_code == 200
|
||||||
|
r2 = env['client'].post(f"/api/v1/operations/{env['op'].id}/complete", headers=h, json=payload)
|
||||||
|
assert r2.status_code == 400
|
||||||
|
assert 'уже завершена' in r2.json()['detail']
|
||||||
|
|
||||||
|
def test_complete_audited(self, complete_env):
|
||||||
|
env = complete_env
|
||||||
|
env['client'].post(
|
||||||
|
f"/api/v1/operations/{env['op'].id}/complete",
|
||||||
|
headers={'Authorization': f"Bearer {env['token']}"},
|
||||||
|
json={'found_alive': True})
|
||||||
|
from backend.models import AuditEvent
|
||||||
|
types = {e.event_type for e in env['db'].query(AuditEvent).all()}
|
||||||
|
assert 'operation_complete' in types
|
||||||
|
assert 'case_closed' in types
|
||||||
|
|
||||||
|
def test_completed_case_in_closed_list(self, complete_env):
|
||||||
|
# Закрытый через операцию кейс попадает в архив завершённых (/admin)
|
||||||
|
env = complete_env
|
||||||
|
env['client'].post(
|
||||||
|
f"/api/v1/operations/{env['op'].id}/complete",
|
||||||
|
headers={'Authorization': f"Bearer {env['token']}"},
|
||||||
|
json={'found_alive': True, 'found_distance_km': 2.0})
|
||||||
|
r = env['client'].get('/api/v1/closed-cases', headers={'Authorization': f"Bearer {env['token']}"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()['items']
|
||||||
|
assert any(it.get('age_years') == 10 for it in items)
|
||||||
@@ -713,3 +713,43 @@
|
|||||||
.contour-send:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; }
|
.contour-send:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; }
|
||||||
.contour-ok { font-size: 12px; color: #3fb950; }
|
.contour-ok { font-size: 12px; color: #3fb950; }
|
||||||
.contour-err { font-size: 12px; color: #f85149; }
|
.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 [operation, setOperation] = useState(null);
|
||||||
const [contourId, setContourId] = useState('');
|
const [contourId, setContourId] = useState('');
|
||||||
const [contourStatus, setContourStatus] = useState(null); // {ok, message}
|
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(() => {
|
useEffect(() => {
|
||||||
const loadCaseAndAnalyze = async () => {
|
const loadCaseAndAnalyze = async () => {
|
||||||
@@ -286,6 +293,18 @@ const AnalysisResult = () => {
|
|||||||
)}
|
)}
|
||||||
</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">
|
<div className="map-info">
|
||||||
<span>ТНП: {tnpLat ? Number(tnpLat).toFixed(5) : '—'}, {tnpLon ? Number(tnpLon).toFixed(5) : '—'}</span>
|
<span>ТНП: {tnpLat ? Number(tnpLat).toFixed(5) : '—'}, {tnpLon ? Number(tnpLon).toFixed(5) : '—'}</span>
|
||||||
{caseData.tnp_address && <span className="sep">·</span>}
|
{caseData.tnp_address && <span className="sep">·</span>}
|
||||||
@@ -422,6 +441,79 @@ const AnalysisResult = () => {
|
|||||||
</div>
|
</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>Направление (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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user