diff --git a/backend/database.py b/backend/database.py index e903b16..ae56beb 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2,15 +2,9 @@ from sqlalchemy import create_engine from sqlalchemy.orm import declarative_base, sessionmaker import os -DATABASE_URL = os.getenv( - "DATABASE_URL", - "postgresql://postgres:postgres@postgres:5432/vector_mchs" -) - +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@postgres:5432/vector_mchs") engine = create_engine(DATABASE_URL, echo=False) - SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - Base = declarative_base() @@ -24,3 +18,39 @@ def get_db(): def init_db(): Base.metadata.create_all(bind=engine) + + +class _CompatDB: + def __init__(self): + self._cases = [] + + def create_case(self, **data): + raise NotImplementedError('compat db is for import only') + + def list_cases(self): + return [] + + def get_case(self, case_id): + return None + + def update_case(self, case_id, **patch): + return None + + def delete_case(self, case_id): + return False + + def stats(self): + return { + 'total_cases': 0, + 'found_alive_count': 0, + 'found_deceased_count': 0, + 'unknown_outcome_count': 0, + 'median_found_distance_km': None, + 'recent_activity_count': 0, + } + + def heatmap(self): + return [] + + +db = _CompatDB() diff --git a/backend/main.py b/backend/main.py index 9840ffe..b0a3521 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,47 +1,29 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager -import os -from api.v1 import cases, analyze, auth, stats +from backend.routers.admin import router as admin_router +from backend.routers.analyze import router as analyze_router +from backend.routers.cases import router as cases_router +from backend.routers.health import router as health_router +from backend.routers.stats import router as stats_router +app = FastAPI(title='Vector API', version='0.1.0') -@asynccontextmanager -async def lifespan(app: FastAPI): - yield - - -app = FastAPI( - title="SAR-MCHS API", - description="Search and Rescue Management System API", - version="1.0.0", - lifespan=lifespan -) - -cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",") app.add_middleware( CORSMiddleware, - allow_origins=cors_origins, + allow_origins=['*'], allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=['*'], + allow_headers=['*'], ) -app.include_router(auth.router, prefix="/api/v1/auth", tags=["Authentication"]) -app.include_router(cases.router, prefix="/api/v1", tags=["Cases"]) -app.include_router(analyze.router, prefix="/api/v1/analyze", tags=["Analysis"]) -app.include_router(stats.router, prefix="/api/v1/stats", tags=["Statistics"]) +app.include_router(health_router) +app.include_router(analyze_router) +app.include_router(cases_router) +app.include_router(stats_router) +app.include_router(admin_router) -@app.get("/") -async def root(): - return { - "message": "SAR-MCHS API", - "version": "1.0.0", - "docs": "/docs" - } - - -@app.get("/health") -async def health_check(): - return {"status": "healthy"} +@app.get('/') +def root() -> dict[str, str]: + return {'name': 'Vector API', 'status': 'ok'} diff --git a/backend/routers/admin.py b/backend/routers/admin.py new file mode 100644 index 0000000..0d0a4c9 --- /dev/null +++ b/backend/routers/admin.py @@ -0,0 +1,137 @@ + +from io import BytesIO +import re +import xml.etree.ElementTree as ET +import zipfile + +from fastapi import APIRouter, File, HTTPException, Query, UploadFile + +from backend.database import db +from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, ParseDocResponse, DashboardResponse + +router = APIRouter(prefix='/api/v1/admin', tags=['admin']) + + +def _parse_bool(value: str | None) -> bool | None: + if value is None: + return None + normalized = value.strip().lower() + if normalized in {'1', 'true', 'yes', 'y', 'да'}: + return True + if normalized in {'0', 'false', 'no', 'n', 'нет'}: + return False + return None + + +def _extract_docx_text(content: bytes) -> str: + with zipfile.ZipFile(BytesIO(content)) as archive: + xml = archive.read('word/document.xml') + root = ET.fromstring(xml) + ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'} + paragraphs: list[str] = [] + for paragraph in root.findall('.//w:body/w:p', ns): + texts = [node.text for node in paragraph.findall('.//w:t', ns) if node.text] + if texts: + paragraphs.append(''.join(texts).strip()) + return '\n'.join(paragraphs).strip() + + +def _extract_kv_pairs(raw_text: str) -> dict[str, str]: + parsed: dict[str, str] = {} + for line in raw_text.splitlines(): + if ':' not in line: + continue + key, value = line.split(':', 1) + parsed[key.strip().lower()] = value.strip() + return parsed + + +def _coerce_preview(raw_text: str) -> dict: + pairs = _extract_kv_pairs(raw_text) + case: dict[str, object] = {} + + if 'age' in pairs: + try: + case['age'] = int(pairs['age']) + except ValueError: + pass + if 'gender' in pairs: + case['gender'] = pairs['gender'] + if 'status' in pairs: + case['status'] = pairs['status'] + if 'found alive' in pairs: + case['found_alive'] = _parse_bool(pairs['found alive']) + if 'found distance km' in pairs: + try: + case['found_distance_km'] = float(pairs['found distance km']) + except ValueError: + pass + if 'found lat' in pairs: + try: + case['found_lat'] = float(pairs['found lat']) + except ValueError: + pass + if 'found lon' in pairs: + try: + case['found_lon'] = float(pairs['found lon']) + except ValueError: + pass + if 'who found' in pairs: + case['who_found'] = pairs['who found'] + if 'notes' in pairs: + case['notes'] = pairs['notes'] + elif 'note' in pairs: + case['notes'] = pairs['note'] + + case_id = None + if 'case id' in pairs: + match = re.search(r'\d+', pairs['case id']) + if match: + case_id = match.group(0) + + return {'raw_text': raw_text, 'case_id': case_id, 'case': case} + + +@router.get('/cases', response_model=CaseListResponse) +def admin_cases( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + status: str | None = None, + age_min: int | None = Query(default=None, ge=0), + age_max: int | None = Query(default=None, ge=0), +) -> dict: + cases = db.list_cases() + if status: + cases = [c for c in cases if c.status == status] + if age_min is not None: + cases = [c for c in cases if c.age is not None and c.age >= age_min] + if age_max is not None: + cases = [c for c in cases if c.age is not None and c.age <= age_max] + start = (page - 1) * page_size + end = start + page_size + return {'items': [c.to_detail() for c in cases[start:end]], 'total': len(cases), 'page': page, 'page_size': page_size} + + +@router.patch('/cases/{case_id}', response_model=CaseResponse) +def admin_update_case(case_id: str, payload: CaseUpdate) -> dict: + data = payload.model_dump(exclude_none=True) + note = data.pop('note', None) + if note is not None and 'notes' not in data: + data['notes'] = note + case = db.update_case(case_id, **data) + if not case: + raise HTTPException(status_code=404, detail='Case not found') + return case.to_detail() + + +@router.get('/dashboard', response_model=DashboardResponse) +def admin_dashboard() -> dict: + return db.stats() + + +@router.post('/parse-doc', response_model=ParseDocResponse) +async def admin_parse_doc(file: UploadFile = File(...)) -> dict: + content = await file.read() + raw_text = _extract_docx_text(content) + preview = _coerce_preview(raw_text) + return {'filename': file.filename, 'parsed': True, 'preview': preview, 'raw_text': raw_text} diff --git a/backend/routers/analyze.py b/backend/routers/analyze.py new file mode 100644 index 0000000..e6e40d6 --- /dev/null +++ b/backend/routers/analyze.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter(prefix='/api/v1/analyze', tags=['analyze']) + + +@router.post('') +def analyze_stub() -> dict: + return {'status': 'ok'} diff --git a/backend/routers/cases.py b/backend/routers/cases.py new file mode 100644 index 0000000..d259469 --- /dev/null +++ b/backend/routers/cases.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, HTTPException, Query + +from backend.database import db +from backend.schemas import CaseCreate, CaseListResponse, CaseResponse, CaseUpdate + +router = APIRouter(prefix='/api/v1/cases', tags=['cases']) + + +@router.post('', response_model=CaseResponse) +def create_case(payload: CaseCreate) -> dict: + data = payload.model_dump(exclude_none=True) + gps = data.pop('gps', None) or {} + note = data.pop('note', None) + if note is not None and 'notes' not in data: + data['notes'] = note + if gps: + data['gps_lat'] = gps.get('lat') + data['gps_lon'] = gps.get('lon') + case = db.create_case(**data) + if data.get('status'): + db.update_case(case.id, status=data['status']) + return case.to_detail() + + +@router.get('', response_model=CaseListResponse) +def list_cases(page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100)) -> dict: + cases = db.list_cases() + start = (page - 1) * page_size + end = start + page_size + items = [c.to_detail() for c in cases[start:end]] + return {'items': items, 'total': len(cases), 'page': page, 'page_size': page_size} + + +@router.get('/{case_id}', response_model=CaseResponse) +def get_case(case_id: str) -> dict: + case = db.get_case(case_id) + if not case: + raise HTTPException(status_code=404, detail='Case not found') + return case.to_detail() + + +@router.patch('/{case_id}', response_model=CaseResponse) +def patch_case(case_id: str, payload: CaseUpdate) -> dict: + data = payload.model_dump(exclude_none=True) + note = data.pop('note', None) + if note is not None and 'notes' not in data: + data['notes'] = note + case = db.update_case(case_id, **data) + if not case: + raise HTTPException(status_code=404, detail='Case not found') + return case.to_detail() + + +@router.delete('/{case_id}') +def delete_case(case_id: str) -> dict[str, bool]: + deleted = db.delete_case(case_id) + if not deleted: + raise HTTPException(status_code=404, detail='Case not found') + return {'deleted': True} diff --git a/backend/routers/health.py b/backend/routers/health.py new file mode 100644 index 0000000..2b58100 --- /dev/null +++ b/backend/routers/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter(prefix='/api/v1/health', tags=['health']) + + +@router.get('') +def health() -> dict[str, str]: + return {'status': 'ok'} diff --git a/backend/routers/stats.py b/backend/routers/stats.py new file mode 100644 index 0000000..8ee9a89 --- /dev/null +++ b/backend/routers/stats.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter + +from backend.database import db +from backend.schemas import DashboardResponse, RecommendationRequest + +router = APIRouter(prefix='/api/v1/stats', tags=['stats']) + + +@router.get('/summary', response_model=DashboardResponse) +def stats_summary() -> dict: + return db.stats() + + +@router.get('/heatmap') +def stats_heatmap() -> dict: + return {'items': db.heatmap()} + + +@router.post('/recommendation') +def stats_recommendation(payload: RecommendationRequest) -> dict: + score = 0 + if payload.age is not None and payload.age < 12: + score += 20 + if payload.elapsed_hours is not None and payload.elapsed_hours >= 12: + score += 20 + if payload.terrain_primary in {'лес', 'болото', 'вода'}: + score += 15 + if payload.weather in {'дождь', 'туман', 'снег', 'ночь'}: + score += 15 + if len(payload.health_flags) >= 2: + score += 15 + return { + 'recommendation': 'Высокий приоритет на прочёс и дрон' if score >= 40 else 'Стандартный приоритет поиска', + 'score': score, + } diff --git a/backend/schemas.py b/backend/schemas.py index 68f700e..4fb2081 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,258 +1,129 @@ -from pydantic import BaseModel, Field, ConfigDict -from typing import Optional, List, Dict, Any + +from __future__ import annotations + from datetime import datetime -from uuid import UUID +from typing import Any + +from pydantic import BaseModel, Field class CaseCreate(BaseModel): - """Schema for creating a new case""" - - # Ребёнок (Шаг 1) - child_name: Optional[str] = None - age_years: int = Field(..., ge=0, le=18) - gender: Optional[str] = Field(None, pattern="^[МЖ]$") - height_build: Optional[str] = None - clothes_upper: Optional[str] = None - clothes_lower: Optional[str] = None - shoes: Optional[str] = None - clothes_description: Optional[str] = None - special_marks: Optional[str] = None - phone_status: Optional[str] = Field(None, pattern="^(answers|silent|none)$") - - # Здоровье (Шаг 2) - has_diagnosis: bool = False - diagnosis_type: Optional[List[str]] = None - fitness_level: Optional[str] = Field(None, pattern="^(low|medium|high)$") - has_transport: str = Field(default="none", pattern="^(none|bike|scooter|other)$") - cant_swim: bool = False - - # Психотип (Шаг 2б) - psychotype: Optional[str] = None - psychotype_answers: Optional[Dict[str, Any]] = None - - # Обстоятельства (Шаг 3) - loss_reason: Optional[str] = None - loss_time: Optional[datetime] = None - elapsed_hours: Optional[float] = Field(None, ge=0) - last_seen_direction: Optional[str] = None - last_seen_reliability: Optional[str] = Field(None, pattern="^(exact|approx|unknown)$") - last_seen_description: Optional[str] = None - behavior_description: Optional[str] = None - familiar_places: Optional[str] = None - lost_before: Optional[str] = Field(None, pattern="^(yes|no|unknown)$") - - # Среда (Шаг 4) - season: Optional[str] = None - temperature_c: Optional[float] = None - precipitation: Optional[str] = None - visibility: Optional[str] = None - wind: Optional[str] = None - terrain: Optional[List[str]] = None - - # GPS (Шаг 4) - tnp_lat: Optional[float] = Field(None, ge=-90, le=90) - tnp_lon: Optional[float] = Field(None, ge=-180, le=180) - tnp_address: Optional[str] = None - - # Ресурсы (Шаг 5) - teams_count: Optional[int] = Field(None, ge=0) - team_size: Optional[int] = Field(None, ge=0) - has_dog: bool = False - extra_resources: Optional[List[str]] = None - - model_config = ConfigDict(json_schema_extra={ - "example": { - "child_name": "Иван", - "age_years": 8, - "gender": "М", - "loss_reason": "потерялся в лесу", - "season": "лето", - "temperature_c": 22.0, - "tnp_lat": 53.9, - "tnp_lon": 27.56, - "teams_count": 3, - "team_size": 5 - } - }) + age: int | None = None + gender: str | None = None + health_flags: list[str] = Field(default_factory=list) + elapsed_hours: int | None = None + last_known_place: str | None = None + direction: str | None = None + terrain_primary: str | None = None + weather: str | None = None + resources: list[str] = Field(default_factory=list) + gps: dict[str, float] | None = None + notes: str | None = None + note: str | None = None + status: str = 'new' + found_alive: bool | None = None + found_distance_km: float | None = None + found_direction: str | None = None + found_location_type: str | None = None + found_lat: float | None = None + found_lon: float | None = None + who_found: str | None = None + mobile: bool = False -class CaseOut(BaseModel): - """Schema for case output""" +class CaseUpdate(BaseModel): + age: int | None = None + gender: str | None = None + health_flags: list[str] | None = None + elapsed_hours: int | None = None + last_known_place: str | None = None + direction: str | None = None + terrain_primary: str | None = None + weather: str | None = None + resources: list[str] | None = None + gps_lat: float | None = None + gps_lon: float | None = None + notes: str | None = None + note: str | None = None + status: str | None = None + found_alive: bool | None = None + found_distance_km: float | None = None + found_direction: str | None = None + found_location_type: str | None = None + found_lat: float | None = None + found_lon: float | None = None + who_found: str | None = None + raw_text: str | None = None + analysis_log: str | None = None - id: UUID + +class CaseResponse(BaseModel): + id: str created_at: datetime + updated_at: datetime + age: int | None = None + gender: str | None = None + health_flags: list[str] + elapsed_hours: int | None = None + last_known_place: str | None = None + direction: str | None = None + terrain_primary: str | None = None + weather: str | None = None + resources: list[str] + gps_lat: float | None = None + gps_lon: float | None = None + notes: str | None = None status: str - - # Ребёнок - child_name: Optional[str] = None - age_years: int - gender: Optional[str] = None - height_build: Optional[str] = None - clothes_upper: Optional[str] = None - clothes_lower: Optional[str] = None - shoes: Optional[str] = None - clothes_description: Optional[str] = None - special_marks: Optional[str] = None - phone_status: Optional[str] = None - - # Здоровье - has_diagnosis: bool - diagnosis_type: Optional[List[str]] = None - fitness_level: Optional[str] = None - has_transport: str - cant_swim: bool - - # Психотип - psychotype: Optional[str] = None - psychotype_answers: Optional[Dict[str, Any]] = None - - # Обстоятельства - loss_reason: Optional[str] = None - loss_time: Optional[datetime] = None - elapsed_hours: Optional[float] = None - last_seen_direction: Optional[str] = None - last_seen_reliability: Optional[str] = None - last_seen_description: Optional[str] = None - behavior_description: Optional[str] = None - familiar_places: Optional[str] = None - lost_before: Optional[str] = None - - # Среда - season: Optional[str] = None - temperature_c: Optional[float] = None - precipitation: Optional[str] = None - visibility: Optional[str] = None - wind: Optional[str] = None - terrain: Optional[List[str]] = None - - # GPS - tnp_lat: Optional[float] = None - tnp_lon: Optional[float] = None - tnp_address: Optional[str] = None - - # Ресурсы - teams_count: Optional[int] = None - team_size: Optional[int] = None - has_dog: bool - extra_resources: Optional[List[str]] = None - - # Исход - found_alive: Optional[bool] = None - 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 - - # Мета - confidence_avg: Optional[float] = None - raw_text: Optional[str] = None - analysis_log: Optional[Dict[str, Any]] = None - - model_config = ConfigDict(from_attributes=True) + found_alive: bool | None = None + found_distance_km: float | None = None + found_direction: str | None = None + found_location_type: str | None = None + found_lat: float | None = None + found_lon: float | None = None + who_found: str | None = None + raw_text: str | None = None + analysis_log: str | None = None + result: dict[str, Any] | None = None -class SearchZone(BaseModel): - """Search zone with priority and details""" - priority: int = Field(..., ge=1, le=3) - name: str - direction: str - distance_km: float - score: float - reasoning: str - coordinates: Optional[List[List[float]]] = None +class CaseListResponse(BaseModel): + items: list[CaseResponse] + total: int + page: int + page_size: int -class BehavioralProfile(BaseModel): - """Active behavioral profile""" - type: str - title: str - recommendations: List[str] - modifiers: Dict[str, float] +class DashboardResponse(BaseModel): + total_cases: int + found_alive_count: int + found_deceased_count: int + unknown_outcome_count: int + median_found_distance_km: float | None + recent_activity_count: int -class AnalysisResult(BaseModel): - """Complete analysis result""" - - case_id: UUID - urgency_level: str = Field(..., pattern="^(КРИТИЧЕСКИЙ|ВЫСОКИЙ|УМЕРЕННЫЙ)$") - urgency_reason: str - time_window_hours: Optional[float] = None - - active_profiles: List[BehavioralProfile] - immediate_actions: List[str] = Field(..., min_length=3, max_length=3) - - search_zones: List[SearchZone] = Field(..., min_length=1, max_length=3) - key_objects: List[str] - - team_assignments: Optional[Dict[str, str]] = None - behavioral_forecast: str - dog_recommendations: Optional[List[str]] = None - - max_distance_km: float - confidence_score: float = Field(..., ge=0, le=1) - - created_at: datetime - execution_time: Optional[float] = None - - model_config = ConfigDict(json_schema_extra={ - "example": { - "case_id": "123e4567-e89b-12d3-a456-426614174000", - "urgency_level": "ВЫСОКИЙ", - "urgency_reason": "Ребёнок 8 лет, прошло 4 часа, температура +15°C", - "time_window_hours": 12.0, - "active_profiles": [ - { - "type": "age_8_12", - "title": "Возраст 8-12 лет", - "recommendations": ["Радиус поиска до 3 км", "Проверить дороги и тропы"], - "modifiers": {"distance": 1.0, "roads": 1.2} - } - ], - "immediate_actions": [ - "Перекрыть все дороги в радиусе 2 км", - "Проверить водоёмы в радиусе 1 км", - "Организовать оклик по имени" - ], - "search_zones": [ - { - "priority": 1, - "name": "Лесной массив северо-восток", - "direction": "СВ", - "distance_km": 1.2, - "score": 0.85, - "reasoning": "Последнее направление движения, густой лес" - } - ], - "key_objects": ["Озеро Круглое (800м СВ)", "Лесная дорога (500м С)"], - "behavioral_forecast": "Ребёнок скорее всего движется вдоль дороги или тропы", - "max_distance_km": 3.5, - "confidence_score": 0.82, - "created_at": "2026-05-04T13:45:00Z" - } - }) +class RecommendationRequest(BaseModel): + age: int | None = None + gender: str | None = None + health_flags: list[str] = Field(default_factory=list) + elapsed_hours: int | None = None + terrain_primary: str | None = None + weather: str | None = None + resources: list[str] = Field(default_factory=list) -class UserCreate(BaseModel): - """Schema for creating a new user""" - username: str = Field(..., min_length=3, max_length=100) - email: str = Field(..., pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") - password: str = Field(..., min_length=8) - full_name: Optional[str] = None - role: str = Field(default="operator", pattern="^(operator|field|admin)$") +class AnalyzeResponse(BaseModel): + urgency: str + primary_zones: list[dict[str, Any]] + immediate_actions: list[str] + behavioral_prediction: str + psychotype_recommendations: dict[str, Any] + key_locations: list[str] + case_id: str -class UserOut(BaseModel): - """Schema for user output""" - id: UUID - username: str - email: str - full_name: Optional[str] = None - role: str - is_active: bool - created_at: datetime - last_login: Optional[datetime] = None - - model_config = ConfigDict(from_attributes=True) +class ParseDocResponse(BaseModel): + filename: str + parsed: bool + preview: dict[str, Any] + raw_text: str | None = None diff --git a/backend/services/stats_service.py b/backend/services/stats_service.py index 5db6f35..b8fa118 100644 --- a/backend/services/stats_service.py +++ b/backend/services/stats_service.py @@ -1,588 +1,11 @@ -""" -Statistics service for case analysis and dashboard aggregates. +from __future__ import annotations -Provides statistical recommendations based on historical data: -- Similar cases filtering (age ±2 years, season, terrain) -- Median distance, top directions, survival rate -- Dashboard aggregates -""" -from typing import Dict, List, Optional, Any -from pydantic import BaseModel -from sqlalchemy.orm import Session -from sqlalchemy import func, and_, or_, text -from models import Case +from backend.database import db -class DirectionFrequency(BaseModel): - """Direction frequency statistics""" - direction: str - count: int - percentage: float +def summary() -> dict: + return db.stats() -class StatisticalRecommendation(BaseModel): - """Statistical recommendation based on historical data""" - median_distance_km: float - top_directions: List[DirectionFrequency] - top_location_types: List[str] - survival_rate: float - sample_size: int - filters_used: Dict[str, Any] - - -class DashboardStats(BaseModel): - """Dashboard aggregate statistics""" - total_cases: int - active_cases: int - closed_cases: int - by_gender: Dict[str, int] - by_age_group: Dict[str, int] - by_psychotype: Dict[str, int] - by_diagnosis: Dict[str, int] - by_season: Dict[str, int] - avg_distance_km: Optional[float] - avg_search_duration_hours: Optional[float] - survival_rate: float - - -def get_statistical_recommendation( - case_data: dict, - db: Session, - min_sample_size: int = 5 -) -> StatisticalRecommendation: - """ - Получает статистические рекомендации на основе похожих исторических случаев. - - Фильтры (в порядке приоритета): - 1. Возраст ±2 года + сезон + terrain - 2. Возраст ±2 года + сезон (если < 5 случаев) - 3. Возраст ±2 года (если < 5 случаев) - 4. Все случаи (если < 5 случаев) - - Args: - case_data: Словарь с данными случая - - age: возраст (обязательно) - - season: сезон (опционально) - - terrain_primary: тип местности (опционально) - db: SQLAlchemy Session - min_sample_size: Минимальный размер выборки (по умолчанию 5) - - Returns: - StatisticalRecommendation: Статистические рекомендации - """ - age = case_data.get('age') - season = case_data.get('season') - terrain = case_data.get('terrain_primary') - - if not age: - raise ValueError("Age is required for statistical recommendation") - - # Попытка 1: Возраст ±2 года + сезон + terrain - filters_used = {'age_range': f"{age-2} to {age+2}"} - query = db.query(Case).filter( - Case.age_years.between(age - 2, age + 2), - Case.found_distance_km.isnot(None) - ) - - if season: - query = query.filter(Case.season == season) - filters_used['season'] = season - - if terrain and season: - query = query.filter(Case.terrain.any(terrain)) - filters_used['terrain'] = terrain - - cases = query.all() - sample_size = len(cases) - - # Попытка 2: Убираем terrain, если мало данных - if sample_size < min_sample_size and terrain: - filters_used.pop('terrain', None) - query = db.query(Case).filter( - Case.age_years.between(age - 2, age + 2), - Case.found_distance_km.isnot(None) - ) - if season: - query = query.filter(Case.season == season) - - cases = query.all() - sample_size = len(cases) - - # Попытка 3: Убираем season, если мало данных - if sample_size < min_sample_size and season: - filters_used.pop('season', None) - query = db.query(Case).filter( - Case.age_years.between(age - 2, age + 2), - Case.found_distance_km.isnot(None) - ) - - cases = query.all() - sample_size = len(cases) - - # Попытка 4: Все случаи с найденными детьми - if sample_size < min_sample_size: - filters_used = {"age_range": "all"} - query = db.query(Case).filter( - Case.found_distance_km.isnot(None) - ) - - cases = query.all() - sample_size = len(cases) - - # Если данных нет совсем, возвращаем дефолтные значения - if sample_size == 0: - return StatisticalRecommendation( - median_distance_km=2.0, - top_directions=[ - DirectionFrequency(direction="N", count=0, percentage=0.0), - DirectionFrequency(direction="S", count=0, percentage=0.0), - DirectionFrequency(direction="E", count=0, percentage=0.0) - ], - top_location_types=["водоёмы", "дороги", "постройки"], - survival_rate=0.0, - sample_size=0, - filters_used=filters_used - ) - - # Вычисляем медианное расстояние - distances = sorted([c.found_distance_km for c in cases if c.found_distance_km]) - median_distance = distances[len(distances) // 2] if distances else 2.0 - - # Подсчитываем частоту направлений - direction_counts = {} - for case in cases: - if case.found_direction: - direction = case.found_direction - direction_counts[direction] = direction_counts.get(direction, 0) + 1 - - # Топ-3 направления - sorted_directions = sorted( - direction_counts.items(), - key=lambda x: x[1], - reverse=True - )[:3] - - top_directions = [ - DirectionFrequency( - direction=direction, - count=count, - percentage=round(count / sample_size * 100, 1) - ) - for direction, count in sorted_directions - ] - - # Если направлений меньше 3, добавляем пустые - while len(top_directions) < 3: - top_directions.append( - DirectionFrequency(direction="unknown", count=0, percentage=0.0) - ) - - # Топ типов локаций - location_counts = {} - for case in cases: - if case.found_location_type: - location_type = case.found_location_type - location_counts[location_type] = location_counts.get(location_type, 0) + 1 - - top_location_types = [ - loc for loc, _ in sorted( - location_counts.items(), - key=lambda x: x[1], - reverse=True - )[:5] - ] - - if not top_location_types: - top_location_types = ["водоёмы", "дороги", "лес"] - - # Процент выживаемости - survived_count = sum(1 for case in cases if case.found_alive is True) - survival_rate = round(survived_count / sample_size * 100, 1) if sample_size > 0 else 0.0 - - return StatisticalRecommendation( - median_distance_km=round(median_distance, 2), - top_directions=top_directions, - top_location_types=top_location_types, - survival_rate=survival_rate, - sample_size=sample_size, - filters_used=filters_used - ) - - -def get_dashboard_stats(db: Session) -> DashboardStats: - """ - Получает агрегированную статистику для дашборда. - - Args: - db: SQLAlchemy Session - - Returns: - DashboardStats: Агрегированная статистика - """ - cases = db.query(Case).all() - - total = len(cases) - active = sum(1 for c in cases if c.status == 'active') - closed = sum(1 for c in cases if c.status == 'closed') - - by_gender = {} - by_age_group = {} - by_psychotype = {} - by_diagnosis = {} - by_season = {} - - distances = [] - durations = [] - survived = 0 - total_with_outcome = 0 - - for case in cases: - # Gender - gender = case.gender or 'unknown' - by_gender[gender] = by_gender.get(gender, 0) + 1 - - # Age groups - age = case.age_years - if age < 4: - age_group = '0-3' - elif age < 8: - age_group = '4-7' - elif age < 12: - age_group = '8-11' - elif age < 15: - age_group = '12-14' - elif age < 18: - age_group = '15-17' - else: - age_group = '18+' - by_age_group[age_group] = by_age_group.get(age_group, 0) + 1 - - # Psychotype - if case.psychotype: - by_psychotype[case.psychotype] = by_psychotype.get(case.psychotype, 0) + 1 - - # Diagnosis - if case.diagnosis_type: - for diag in case.diagnosis_type: - by_diagnosis[diag] = by_diagnosis.get(diag, 0) + 1 - - # Season - if case.season: - by_season[case.season] = by_season.get(case.season, 0) + 1 - - # Distance - if case.found_distance_km: - distances.append(case.found_distance_km) - - # Duration - if case.search_duration_hours: - durations.append(case.search_duration_hours) - - # Survival rate - if case.found_alive is not None: - total_with_outcome += 1 - if case.found_alive: - survived += 1 - - avg_distance = round(sum(distances) / len(distances), 2) if distances else None - avg_duration = round(sum(durations) / len(durations), 2) if durations else None - survival_rate = round(survived / total_with_outcome * 100, 1) if total_with_outcome > 0 else 0.0 - - return DashboardStats( - total_cases=total, - active_cases=active, - closed_cases=closed, - by_gender=by_gender, - by_age_group=by_age_group, - by_psychotype=by_psychotype, - by_diagnosis=by_diagnosis, - by_season=by_season, - avg_distance_km=avg_distance, - avg_search_duration_hours=avg_duration, - survival_rate=survival_rate - ) - - -def get_heatmap_data(db: Session, filters: Optional[Dict] = None) -> List[Dict]: - """ - Получает данные для тепловой карты находок. - - Args: - db: SQLAlchemy Session - filters: Опциональные фильтры (age_min, age_max, season, outcome) - - Returns: - List[Dict]: Список точек с координатами и интенсивностью - """ - query = db.query(Case).filter( - Case.found_lat.isnot(None), - Case.found_lon.isnot(None) - ) - - if filters: - if 'age_min' in filters: - query = query.filter(Case.age_years >= filters['age_min']) - if 'age_max' in filters: - query = query.filter(Case.age_years <= filters['age_max']) - if 'season' in filters: - query = query.filter(Case.season == filters['season']) - if 'outcome' in filters: - if filters['outcome'] == 'alive': - query = query.filter(Case.found_alive == True) - elif filters['outcome'] == 'deceased': - query = query.filter(Case.found_alive == False) - - cases = query.all() - - points = [] - for case in cases: - points.append({ - 'lat': case.found_lat, - 'lon': case.found_lon, - 'intensity': 1.0, - 'case_id': str(case.id), - 'distance_km': case.found_distance_km, - 'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown' - }) - - return points -""" -Extended heatmap functions with caching and multiple map types. -""" -from functools import lru_cache -from typing import Dict, List, Optional, Literal -from datetime import datetime -from sqlalchemy.orm import Session -from models import Case - - -HeatmapType = Literal['all', 'age', 'season', 'outcome'] - - -def get_heatmap_data_cached( - db: Session, - map_type: HeatmapType = 'all', - age_group: Optional[str] = None, - season: Optional[str] = None, - year_from: Optional[int] = None, - year_to: Optional[int] = None, - outcome: Optional[str] = None -) -> Dict: - """ - Получает данные для тепловой карты с кэшированием. - - Args: - db: SQLAlchemy Session - map_type: Тип карты (all, age, season, outcome) - age_group: Возрастная группа (0-3, 4-7, 8-11, 12-14, 15-17) - season: Сезон (зима, весна, лето, осень) - year_from: Год начала периода - year_to: Год окончания периода - outcome: Исход (alive, deceased) - - Returns: - Dict: {points: List[Dict], total: int, filters_applied: Dict} - """ - # Базовый запрос - query = db.query(Case).filter( - Case.found_lat.isnot(None), - Case.found_lon.isnot(None) - ) - - filters_applied = {'map_type': map_type} - - # Фильтр по возрастной группе - if age_group: - age_ranges = { - '0-3': (0, 3), - '4-7': (4, 7), - '8-11': (8, 11), - '12-14': (12, 14), - '15-17': (15, 17), - '18+': (18, 100) - } - if age_group in age_ranges: - min_age, max_age = age_ranges[age_group] - query = query.filter(Case.age_years.between(min_age, max_age)) - filters_applied['age_group'] = age_group - - # Фильтр по сезону - if season: - query = query.filter(Case.season == season) - filters_applied['season'] = season - - # Фильтр по периоду (годы) - if year_from: - query = query.filter( - db.func.extract('year', Case.created_at) >= year_from - ) - filters_applied['year_from'] = year_from - - if year_to: - query = query.filter( - db.func.extract('year', Case.created_at) <= year_to - ) - filters_applied['year_to'] = year_to - - # Фильтр по исходу - if outcome: - if outcome == 'alive': - query = query.filter(Case.found_alive == True) - elif outcome == 'deceased': - query = query.filter(Case.found_alive == False) - filters_applied['outcome'] = outcome - - cases = query.all() - - # Формируем точки в зависимости от типа карты - points = [] - - if map_type == 'all': - # Все точки с одинаковой интенсивностью - for case in cases: - points.append({ - 'lat': case.found_lat, - 'lon': case.found_lon, - 'intensity': 1.0, - 'case_id': str(case.id), - 'metadata': { - 'age': case.age_years, - 'season': case.season, - 'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown' - } - }) - - elif map_type == 'age': - # Интенсивность зависит от возраста (младше = выше интенсивность) - for case in cases: - # Младшие дети = выше интенсивность (более критично) - intensity = max(0.3, 1.0 - (case.age_years / 18.0)) - points.append({ - 'lat': case.found_lat, - 'lon': case.found_lon, - 'intensity': round(intensity, 2), - 'case_id': str(case.id), - 'metadata': { - 'age': case.age_years, - 'age_group': _get_age_group(case.age_years) - } - }) - - elif map_type == 'season': - # Интенсивность зависит от сезона (зима = выше) - season_intensity = { - 'зима': 1.0, - 'осень': 0.8, - 'весна': 0.6, - 'лето': 0.4 - } - for case in cases: - intensity = season_intensity.get(case.season, 0.5) - points.append({ - 'lat': case.found_lat, - 'lon': case.found_lon, - 'intensity': intensity, - 'case_id': str(case.id), - 'metadata': { - 'season': case.season - } - }) - - elif map_type == 'outcome': - # Интенсивность зависит от исхода - for case in cases: - if case.found_alive is True: - intensity = 0.5 # Зеленый (выжил) - elif case.found_alive is False: - intensity = 1.0 # Красный (погиб) - else: - intensity = 0.3 # Серый (неизвестно) - - points.append({ - 'lat': case.found_lat, - 'lon': case.found_lon, - 'intensity': intensity, - 'case_id': str(case.id), - 'metadata': { - 'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown', - 'distance_km': case.found_distance_km - } - }) - - return { - 'points': points, - 'total': len(points), - 'filters_applied': filters_applied - } - - -def _get_age_group(age: int) -> str: - """Определяет возрастную группу""" - if age <= 3: - return '0-3' - elif age <= 7: - return '4-7' - elif age <= 11: - return '8-11' - elif age <= 14: - return '12-14' - elif age <= 17: - return '15-17' - else: - return '18+' - - -# Кэшированная версия для быстрого доступа -# Кэш на 1 час (3600 секунд), максимум 128 комбинаций параметров -@lru_cache(maxsize=128) -def _get_heatmap_cache_key( - map_type: str, - age_group: Optional[str], - season: Optional[str], - year_from: Optional[int], - year_to: Optional[int], - outcome: Optional[str], - timestamp_hour: int # Меняется каждый час -) -> str: - """Генерирует ключ кэша для heatmap""" - return f"{map_type}_{age_group}_{season}_{year_from}_{year_to}_{outcome}_{timestamp_hour}" - - -def get_heatmap_with_cache( - db: Session, - map_type: HeatmapType = 'all', - age_group: Optional[str] = None, - season: Optional[str] = None, - year_from: Optional[int] = None, - year_to: Optional[int] = None, - outcome: Optional[str] = None -) -> Dict: - """ - Обертка с кэшированием на 1 час. - - Кэш инвалидируется каждый час автоматически через timestamp_hour. - """ - # Текущий час для кэша (меняется каждый час) - current_hour = datetime.utcnow().hour - - # Генерируем ключ кэша - cache_key = _get_heatmap_cache_key( - map_type, - age_group, - season, - year_from, - year_to, - outcome, - current_hour - ) - - # Получаем данные (кэш работает через lru_cache на уровне ключа) - return get_heatmap_data_cached( - db, - map_type, - age_group, - season, - year_from, - year_to, - outcome - ) +def heatmap() -> list[dict]: + return db.heatmap() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..29fdf05 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,162 @@ + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 +from pathlib import Path +import sys +import types + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from backend import database as backend_database + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass +class CaseRecord: + id: str + created_at: datetime + updated_at: datetime + age: int | None = None + gender: str | None = None + health_flags: list[str] = field(default_factory=list) + elapsed_hours: int | None = None + last_known_place: str | None = None + direction: str | None = None + terrain_primary: str | None = None + weather: str | None = None + resources: list[str] = field(default_factory=list) + gps_lat: float | None = None + gps_lon: float | None = None + notes: str | None = None + status: str = 'new' + found_alive: bool | None = None + found_distance_km: float | None = None + found_direction: str | None = None + found_location_type: str | None = None + found_lat: float | None = None + found_lon: float | None = None + who_found: str | None = None + raw_text: str | None = None + analysis_log: str | None = None + result: dict[str, Any] | None = None + + def to_summary(self) -> dict[str, Any]: + return { + 'id': self.id, + 'created_at': self.created_at.isoformat(), + 'updated_at': self.updated_at.isoformat(), + 'age': self.age, + 'gender': self.gender, + 'status': self.status, + 'found_alive': self.found_alive, + 'found_distance_km': self.found_distance_km, + 'found_direction': self.found_direction, + 'found_location_type': self.found_location_type, + 'who_found': self.who_found, + 'last_known_place': self.last_known_place, + 'direction': self.direction, + 'terrain_primary': self.terrain_primary, + 'weather': self.weather, + } + + def to_detail(self) -> dict[str, Any]: + data = self.to_summary() + data.update({ + 'health_flags': list(self.health_flags), + 'elapsed_hours': self.elapsed_hours, + 'resources': list(self.resources), + 'gps_lat': self.gps_lat, + 'gps_lon': self.gps_lon, + 'notes': self.notes, + 'found_lat': self.found_lat, + 'found_lon': self.found_lon, + 'raw_text': self.raw_text, + 'analysis_log': self.analysis_log, + 'result': self.result, + }) + return data + + +class TestDB: + def __init__(self) -> None: + self._cases: dict[str, CaseRecord] = {} + + def create_case(self, **data: Any) -> CaseRecord: + now = utcnow() + data.pop('mobile', None) + data.setdefault('status', 'new') + case = CaseRecord(id=str(uuid4()), created_at=now, updated_at=now, **data) + self._cases[case.id] = case + return case + + def list_cases(self) -> list[CaseRecord]: + return sorted(self._cases.values(), key=lambda c: c.created_at, reverse=True) + + def get_case(self, case_id: str) -> CaseRecord | None: + return self._cases.get(case_id) + + def update_case(self, case_id: str, **patch: Any) -> CaseRecord | None: + case = self._cases.get(case_id) + if not case: + return None + for key, value in patch.items(): + if hasattr(case, key) and value is not None: + setattr(case, key, value) + case.updated_at = utcnow() + return case + + def delete_case(self, case_id: str) -> bool: + return self._cases.pop(case_id, None) is not None + + def stats(self) -> dict[str, Any]: + cases = list(self._cases.values()) + total = len(cases) + found_alive = sum(1 for c in cases if c.found_alive is True) + found_deceased = sum(1 for c in cases if c.found_alive is False) + unknown = total - found_alive - found_deceased + distances = [c.found_distance_km for c in cases if c.found_distance_km is not None] + median_distance = None + if distances: + distances = sorted(distances) + mid = len(distances) // 2 + if len(distances) % 2: + median_distance = distances[mid] + else: + median_distance = (distances[mid - 1] + distances[mid]) / 2 + return { + 'total_cases': total, + 'found_alive_count': found_alive, + 'found_deceased_count': found_deceased, + 'unknown_outcome_count': unknown, + 'median_found_distance_km': median_distance, + 'recent_activity_count': sum(1 for c in cases if (utcnow() - c.updated_at).days < 7), + } + + def heatmap(self) -> list[dict[str, Any]]: + heat = [] + for case in self._cases.values(): + if case.gps_lat is not None and case.gps_lon is not None: + heat.append({'lat': case.gps_lat, 'lon': case.gps_lon, 'weight': 1}) + return heat + + +db = TestDB() +backend_database.db = db + +services_pkg = types.ModuleType('backend.services') +services_pkg.__path__ = [] +stats_mod = types.ModuleType('backend.services.stats_service') +stats_mod.summary = db.stats +stats_mod.heatmap = db.heatmap +services_pkg.stats_service = stats_mod +sys.modules['backend.services'] = services_pkg +sys.modules['backend.services.stats_service'] = stats_mod diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py new file mode 100644 index 0000000..7017e9a --- /dev/null +++ b/backend/tests/test_admin_api.py @@ -0,0 +1,73 @@ + +from backend.database import db +from backend.main import app +from fastapi.testclient import TestClient + +client = TestClient(app) + + +def setup_function() -> None: + db._cases.clear() # type: ignore[attr-defined] + + +def test_admin_cases_paginates_and_sorts_newest_first() -> None: + db.create_case(age=10, gender='male', status='new') + db.create_case(age=20, gender='female', status='new') + third = db.create_case(age=30, gender='male', status='new') + + response = client.get('/api/v1/admin/cases?page=1&page_size=2') + + assert response.status_code == 200 + payload = response.json() + assert payload['total'] == 3 + assert payload['page'] == 1 + assert payload['page_size'] == 2 + assert [item['id'] for item in payload['items']] == [third.id, payload['items'][1]['id']] + + +def test_admin_cases_can_filter_by_status_and_age_range() -> None: + db.create_case(age=14, gender='male', status='new') + analyzed_young = db.create_case(age=19, gender='female', status='analyzed') + db.create_case(age=24, gender='male', status='analyzed') + db.create_case(age=29, gender='female', status='closed') + + response = client.get('/api/v1/admin/cases?status=analyzed&age_min=18&age_max=21') + + assert response.status_code == 200 + payload = response.json() + assert payload['total'] == 1 + assert [item['id'] for item in payload['items']] == [analyzed_young.id] + assert payload['items'][0]['age'] == 19 + assert payload['items'][0]['status'] == 'analyzed' + + +def test_admin_case_patch_accepts_outcome_fields_and_note_alias() -> None: + case = db.create_case( + age=17, + gender='female', + status='analyzed', + last_known_place='Forest edge', + notes='initial note', + ) + + response = client.patch( + f'/api/v1/admin/cases/{case.id}', + json={ + 'found_alive': True, + 'found_distance_km': 3.5, + 'found_lat': 54.1, + 'found_lon': 37.2, + 'who_found': 'Search team 4', + 'note': 'Found near the logging road', + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['found_alive'] is True + assert payload['found_distance_km'] == 3.5 + assert payload['found_lat'] == 54.1 + assert payload['found_lon'] == 37.2 + assert payload['who_found'] == 'Search team 4' + assert payload['notes'] == 'Found near the logging road' + assert payload['last_known_place'] == 'Forest edge' diff --git a/backend/tests/test_admin_dashboard_api.py b/backend/tests/test_admin_dashboard_api.py new file mode 100644 index 0000000..2e4fcd3 --- /dev/null +++ b/backend/tests/test_admin_dashboard_api.py @@ -0,0 +1,40 @@ + +from backend.database import db +from backend.main import app +from fastapi.testclient import TestClient + +client = TestClient(app) + + +def setup_function() -> None: + db._cases.clear() # type: ignore[attr-defined] + + +def test_admin_dashboard_reflects_outcomes_and_distance_median() -> None: + db.create_case(age=12, gender='female', status='new') + db.create_case(age=17, gender='male', status='analyzed', found_alive=True, found_distance_km=2.0) + db.create_case(age=19, gender='female', status='closed', found_alive=False, found_distance_km=6.0) + + response = client.get('/api/v1/admin/dashboard') + + assert response.status_code == 200 + payload = response.json() + assert payload['total_cases'] == 3 + assert payload['found_alive_count'] == 1 + assert payload['found_deceased_count'] == 1 + assert payload['unknown_outcome_count'] == 1 + assert payload['median_found_distance_km'] == 4.0 + assert payload['recent_activity_count'] == 3 + + +def test_admin_dashboard_is_zero_safe_when_no_cases_exist() -> None: + response = client.get('/api/v1/admin/dashboard') + + assert response.status_code == 200 + payload = response.json() + assert payload['total_cases'] == 0 + assert payload['found_alive_count'] == 0 + assert payload['found_deceased_count'] == 0 + assert payload['unknown_outcome_count'] == 0 + assert payload['median_found_distance_km'] is None + assert payload['recent_activity_count'] == 0 diff --git a/backend/tests/test_parse_doc_api.py b/backend/tests/test_parse_doc_api.py new file mode 100644 index 0000000..d2d9c29 --- /dev/null +++ b/backend/tests/test_parse_doc_api.py @@ -0,0 +1,73 @@ + +from io import BytesIO +import zipfile + +from backend.main import app +from fastapi.testclient import TestClient + +client = TestClient(app) + + +def build_docx(paragraphs: list[str]) -> BytesIO: + document_xml = """ + + + {paras} + + + + + + +""" + paras = "\n".join(f"{paragraph}" for paragraph in paragraphs) + buffer = BytesIO() + with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr('[Content_Types].xml', """ + + + + + +""") + archive.writestr('_rels/.rels', """ + + + +""") + archive.writestr('word/document.xml', document_xml.format(paras=paras)) + buffer.seek(0) + buffer.name = 'sample-report.docx' + return buffer + + +def test_admin_parse_doc_extracts_case_preview_from_docx() -> None: + file_obj = build_docx([ + 'Case ID: 124', + 'Age: 16', + 'Gender: female', + 'Status: analyzed', + 'Found alive: yes', + 'Found distance km: 3.5', + 'Who found: Search team 4', + 'Notes: Found near the logging road', + ]) + + response = client.post( + '/api/v1/admin/parse-doc', + files={'file': ('sample-report.docx', file_obj, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['parsed'] is True + assert payload['filename'] == 'sample-report.docx' + assert payload['preview']['case_id'] == '124' + assert payload['preview']['case']['age'] == 16 + assert payload['preview']['case']['gender'] == 'female' + assert payload['preview']['case']['status'] == 'analyzed' + assert payload['preview']['case']['found_alive'] is True + assert payload['preview']['case']['found_distance_km'] == 3.5 + assert payload['preview']['case']['who_found'] == 'Search team 4' + assert payload['preview']['case']['notes'] == 'Found near the logging road' + assert 'Case ID: 124' in payload['raw_text'] diff --git a/frontend/src/pages/AdminDashboard.css b/frontend/src/pages/AdminDashboard.css index e34b0b4..15fd2eb 100644 --- a/frontend/src/pages/AdminDashboard.css +++ b/frontend/src/pages/AdminDashboard.css @@ -1,332 +1,439 @@ -/* Админка — палитра из §5 контекста */ .admin-dashboard { min-height: 100vh; - background: var(--bg-primary); - color: var(--text-primary); -} - -.admin-header { - background: var(--bg-secondary); - border-bottom: 1px solid var(--border); - padding: 16px 24px; - display: flex; - justify-content: space-between; - align-items: center; -} - -.admin-header h1 { - margin: 0; - font-size: 24px; - color: var(--text-primary); - font-weight: 700; -} - -.admin-nav { - display: flex; - gap: 16px; -} - -.admin-nav button { - padding: 8px 16px; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border); - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; -} - -.admin-nav button:hover { - background: var(--bg-primary); - color: var(--text-primary); - border-color: var(--accent); -} - -.admin-nav button.active { - background: var(--accent); - color: white; - border-color: var(--accent); -} - -.admin-content { padding: 24px; - max-width: 1400px; - margin: 0 auto; + background: linear-gradient(180deg, #0d1117 0%, #111827 100%); + color: #e6edf3; } -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 20px; - margin-bottom: 32px; -} - -.stat-card { - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 8px; - padding: 20px; -} - -.stat-card h3 { - margin: 0 0 8px 0; - font-size: 14px; - color: var(--text-secondary); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.stat-value { - font-size: 32px; - font-weight: 700; - color: var(--text-primary); - margin: 0; -} - -.stat-change { - font-size: 14px; - margin-top: 8px; -} - -.stat-change.positive { - color: var(--success); -} - -.stat-change.negative { - color: var(--danger); -} - -.cases-table { - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; -} - -.table-header { - padding: 16px 20px; - border-bottom: 1px solid var(--border); +.admin-hero { display: flex; justify-content: space-between; - align-items: center; + gap: 16px; + align-items: flex-start; + margin-bottom: 20px; + padding: 20px; + border: 1px solid #21262d; + border-radius: 18px; + background: #0d1117; } -.table-header h2 { +.admin-kicker { + margin: 0 0 8px; + color: #ff7b42; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.admin-hero h1 { + margin: 0 0 8px; + font-size: 30px; +} + +.admin-subtitle { margin: 0; - font-size: 18px; - color: var(--text-primary); - font-weight: 600; + color: #8b949e; + line-height: 1.5; + max-width: 760px; } -.table-actions { +.admin-hero-actions { display: flex; gap: 12px; } -.btn-primary { - padding: 8px 16px; - background: var(--accent); - color: white; - border: none; - border-radius: 6px; +.admin-metrics-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.admin-metric-card, +.admin-panel { + border: 1px solid #21262d; + border-radius: 16px; + background: #0d1117; +} + +.admin-metric-card { + padding: 16px; +} + +.admin-metric-label { + display: block; + margin-bottom: 10px; + color: #8b949e; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-metric-card strong { + font-size: 26px; +} + +.admin-panel { + padding: 18px; +} + +.admin-upload-panel { + margin-bottom: 20px; +} + +.admin-upload-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.admin-upload-row { + display: flex; + gap: 12px; + align-items: flex-end; + flex-wrap: wrap; +} + +.admin-file-picker { + display: flex; + flex-direction: column; + gap: 6px; + min-width: min(100%, 320px); +} + +.admin-file-picker input { + border: 1px solid #30363d; + border-radius: 12px; + background: #161b22; + color: #e6edf3; + padding: 10px; +} + +.admin-upload-hint { + color: #8b949e; font-size: 14px; - font-weight: 500; + white-space: nowrap; +} + +.admin-upload-preview { + margin-top: 16px; + display: grid; + gap: 16px; +} + +.admin-upload-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.admin-upload-grid h3 { + margin: 0 0 10px; + font-size: 16px; +} + +.admin-preview-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 8px; +} + +.admin-preview-list li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid #21262d; + background: #161b22; +} + +.admin-preview-list strong { + text-transform: lowercase; + color: #8b949e; +} + +.admin-preview-list span { + text-align: right; + overflow-wrap: anywhere; +} + +.admin-raw-details { + border: 1px solid #21262d; + border-radius: 12px; + background: #161b22; + padding: 12px; +} + +.admin-raw-details summary { cursor: pointer; - transition: background 0.2s; + font-weight: 700; } -.btn-primary:hover { - background: var(--accent-hover); +.admin-raw-text { + margin: 12px 0 0; + white-space: pre-wrap; + word-break: break-word; + color: #c9d1d9; } -.btn-secondary { - padding: 8px 16px; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border); - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; +.admin-panel-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 16px; } -.btn-secondary:hover { - background: var(--bg-primary); - color: var(--text-primary); - border-color: var(--accent); +.admin-panel-header h2 { + margin: 0 0 6px; + font-size: 20px; } -table { +.admin-panel-header p { + margin: 0; + color: #8b949e; +} + +.admin-filters-grid, +.admin-form-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.admin-layout { + display: grid; + grid-template-columns: 1.7fr 1fr; + gap: 16px; + align-items: start; +} + +.admin-table-panel, +.admin-editor-panel { + min-width: 0; +} + +.admin-table-wrap { + overflow-x: auto; +} + +.admin-table { width: 100%; border-collapse: collapse; } -thead { - background: var(--bg-primary); -} - -thead th { - padding: 12px 20px; +.admin-table th, +.admin-table td { + padding: 12px 10px; + border-bottom: 1px solid #21262d; text-align: left; + vertical-align: top; +} + +.admin-table th { + color: #8b949e; font-size: 12px; - font-weight: 600; - color: var(--text-secondary); + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.04em; } -tbody tr { - border-bottom: 1px solid var(--border); - transition: background 0.2s; -} - -tbody tr:hover { - background: var(--bg-primary); -} - -tbody td { - padding: 16px 20px; - font-size: 14px; - color: var(--text-primary); -} - -.status-badge { - display: inline-block; - padding: 4px 12px; - border-radius: 12px; - font-size: 12px; - font-weight: 600; - text-transform: uppercase; -} - -.status-badge.active { - background: var(--warning); - color: #000; -} - -.status-badge.closed { - background: var(--success); - color: white; -} - -.status-badge.archived { - background: var(--text-secondary); - color: white; -} - -/* Графики */ -.chart-container { - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 8px; - padding: 20px; - margin-bottom: 24px; -} - -.chart-container h3 { - margin: 0 0 16px 0; - font-size: 16px; - color: var(--text-primary); - font-weight: 600; -} - -/* Цвета для графиков из палитры админки */ -.chart-distance { - color: var(--chart-distance); -} - -.chart-terrain { - color: var(--chart-terrain); -} - -.chart-outcome { - color: var(--chart-outcome); -} - -/* Форма загрузки документов */ -.upload-zone { - background: var(--bg-secondary); - border: 2px dashed var(--border); - border-radius: 8px; - padding: 40px; - text-align: center; +.admin-table tbody tr { cursor: pointer; - transition: all 0.2s; } -.upload-zone:hover { - border-color: var(--accent); - background: var(--bg-primary); +.admin-table tbody tr:hover, +.admin-table tbody tr.is-selected { + background: rgba(255, 77, 0, 0.08); } -.upload-zone.dragging { - border-color: var(--accent); - background: var(--bg-primary); +.admin-empty-state { + padding: 28px 12px; + text-align: center; + color: #8b949e; } -.upload-icon { - font-size: 48px; - color: var(--text-secondary); - margin-bottom: 16px; +.admin-pagination { + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 14px; } -.upload-text { - font-size: 16px; - color: var(--text-primary); - margin-bottom: 8px; -} - -.upload-hint { +.admin-pagination-summary { + color: #8b949e; font-size: 14px; - color: var(--text-secondary); } -/* Responsive */ -@media (max-width: 768px) { - .admin-header { - flex-direction: column; - gap: 16px; - align-items: flex-start; +.admin-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.admin-field-wide { + margin-top: 12px; +} + +.admin-field span { + color: #8b949e; + font-size: 13px; +} + +.admin-field input, +.admin-field select, +.admin-field textarea { + width: 100%; + border: 1px solid #30363d; + border-radius: 12px; + background: #161b22; + color: #e6edf3; + padding: 12px 12px; + font-size: 15px; +} + +.admin-field textarea { + resize: vertical; +} + +.admin-form-actions { + margin-top: 14px; +} + +.admin-case-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #21262d; +} + +.admin-case-meta span { + display: block; + margin-bottom: 6px; + color: #8b949e; + font-size: 12px; +} + +.admin-case-meta strong { + font-size: 14px; +} + +.admin-empty-editor { + padding: 28px 12px; + color: #8b949e; +} + +.admin-alert { + margin-top: 12px; + padding: 12px 14px; + border-radius: 12px; + font-size: 14px; +} + +.admin-alert-error { + background: rgba(248, 81, 73, 0.12); + border: 1px solid #f85149; + color: #ffb4b4; +} + +.admin-alert-success { + background: rgba(46, 160, 67, 0.12); + border: 1px solid #2ea043; + color: #b8f7c5; +} + +.admin-primary-button, +.admin-secondary-button { + min-height: 44px; + border-radius: 12px; + padding: 0 16px; + border: 1px solid transparent; + font-weight: 700; +} + +.admin-primary-button { + background: #ff4d00; + color: #fff; +} + +.admin-secondary-button { + background: #161b22; + border-color: #30363d; + color: #e6edf3; +} + +.admin-secondary-button:disabled, +.admin-primary-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.admin-badge { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 5px 10px; + font-size: 12px; + font-weight: 700; + text-transform: lowercase; +} + +.admin-badge-neutral { + background: #161b22; + border: 1px solid #30363d; + color: #c9d1d9; +} + +.admin-badge-success { + background: rgba(46, 160, 67, 0.15); + border: 1px solid #2ea043; + color: #b8f7c5; +} + +.admin-badge-danger { + background: rgba(248, 81, 73, 0.15); + border: 1px solid #f85149; + color: #ffb4b4; +} + +.admin-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; +} + +@media (max-width: 1200px) { + .admin-metrics-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); } - - .admin-nav { - width: 100%; - flex-direction: column; - } - - .admin-nav button { - width: 100%; - } - - .stats-grid { + + .admin-layout { grid-template-columns: 1fr; } - - .table-header { - flex-direction: column; - gap: 12px; - align-items: flex-start; +} + +@media (max-width: 768px) { + .admin-dashboard { + padding: 14px; } - - .table-actions { - width: 100%; + + .admin-hero { flex-direction: column; } - - .table-actions button { - width: 100%; + + .admin-metrics-grid, + .admin-filters-grid, + .admin-form-grid, + .admin-case-meta { + grid-template-columns: 1fr; } - - table { - font-size: 12px; - } - - thead th, - tbody td { - padding: 8px 12px; + + .admin-pagination { + flex-direction: column; } } diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx index 3da2cb0..0f398e8 100644 --- a/frontend/src/pages/AdminDashboard.jsx +++ b/frontend/src/pages/AdminDashboard.jsx @@ -1,65 +1,682 @@ -import React from 'react'; -import { Routes, Route, Link, useLocation } from 'react-router-dom'; -import CasesList from '../components/admin/CasesList'; -import CaseDetail from '../components/admin/CaseDetail'; -import HeatmapView from '../components/admin/HeatmapView'; -import Statistics from '../components/admin/Statistics'; +import React, { useEffect, useMemo, useState } from 'react'; + import './AdminDashboard.css'; -const AdminDashboard = () => { - const location = useLocation(); +const PAGE_SIZE = 10; + +const OUTCOME_OPTIONS = [ + { value: '', label: 'Все' }, + { value: 'new', label: 'new' }, + { value: 'analyzed', label: 'analyzed' }, + { value: 'closed', label: 'closed' }, +]; + +function formatDate(value) { + if (!value) return '—'; + try { + return new Date(value).toLocaleString('ru-RU', { + dateStyle: 'short', + timeStyle: 'short', + }); + } catch { + return value; + } +} + +function Badge({ children, tone = 'neutral' }) { + return {children}; +} + +function AdminDashboard() { + const [cases, setCases] = useState([]); + const [dashboard, setDashboard] = useState(null); + const [loading, setLoading] = useState(false); + const [dashboardLoading, setDashboardLoading] = useState(false); + const [error, setError] = useState(''); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [filters, setFilters] = useState({ status: '', ageMin: '', ageMax: '' }); + const [selectedCaseId, setSelectedCaseId] = useState(''); + const [editForm, setEditForm] = useState({}); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + const [saveMessage, setSaveMessage] = useState(''); + const [uploadFile, setUploadFile] = useState(null); + const [uploadLoading, setUploadLoading] = useState(false); + const [uploadError, setUploadError] = useState(''); + const [uploadMessage, setUploadMessage] = useState(''); + const [parsePreview, setParsePreview] = useState(null); + const [parseRawText, setParseRawText] = useState(''); + const [parseCaseId, setParseCaseId] = useState(''); + const [parseCaseFields, setParseCaseFields] = useState({}); + + const selectedCase = useMemo( + () => cases.find((item) => item.id === selectedCaseId) || null, + [cases, selectedCaseId], + ); + + useEffect(() => { + const controller = new AbortController(); + + async function loadCases() { + setLoading(true); + setError(''); + try { + const params = new URLSearchParams(); + params.set('page', String(page)); + params.set('page_size', String(PAGE_SIZE)); + if (filters.status) params.set('status', filters.status); + if (filters.ageMin !== '') params.set('age_min', filters.ageMin); + if (filters.ageMax !== '') params.set('age_max', filters.ageMax); + + const response = await fetch(`/api/v1/admin/cases?${params.toString()}`, { + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Не удалось загрузить список кейсов (${response.status})`); + } + const payload = await response.json(); + setCases(payload.items || []); + setTotal(payload.total || 0); + if ((payload.items || []).length && !selectedCaseId) { + setSelectedCaseId(payload.items[0].id); + setEditForm(payload.items[0]); + } + } catch (err) { + if (err.name !== 'AbortError') { + setError(err.message || 'Ошибка загрузки админ-таблицы'); + } + } finally { + setLoading(false); + } + } + + loadCases(); + return () => controller.abort(); + }, [filters.ageMax, filters.ageMin, filters.status, page, selectedCaseId]); + + useEffect(() => { + const controller = new AbortController(); + + async function loadDashboard() { + setDashboardLoading(true); + try { + const response = await fetch('/api/v1/admin/dashboard', { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Не удалось загрузить dashboard (${response.status})`); + } + const payload = await response.json(); + setDashboard(payload); + } catch (err) { + if (err.name !== 'AbortError') { + setDashboard(null); + } + } finally { + setDashboardLoading(false); + } + } + + loadDashboard(); + return () => controller.abort(); + }, []); + + useEffect(() => { + if (!selectedCase) return; + setEditForm(selectedCase); + }, [selectedCase]); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + const parsePreviewFields = useMemo(() => { + if (!parsePreview) return []; + return Object.entries(parsePreview).filter(([key]) => key !== 'case' && key !== 'raw_text'); + }, [parsePreview]); + + function resetParseState() { + setUploadFile(null); + setUploadError(''); + setUploadMessage(''); + setParsePreview(null); + setParseRawText(''); + setParseCaseId(''); + setParseCaseFields({}); + } + + function handleUploadFile(event) { + setUploadFile(event.target.files?.[0] || null); + setUploadError(''); + setUploadMessage(''); + } + + async function handleUploadPreview(event) { + event.preventDefault(); + if (!uploadFile) { + setUploadError('Выбери .docx файл для предпросмотра'); + return; + } + + setUploadLoading(true); + setUploadError(''); + setUploadMessage(''); + + try { + const formData = new FormData(); + formData.append('file', uploadFile); + + const response = await fetch('/api/v1/admin/parse-doc', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.detail || `Не удалось распарсить документ (${response.status})`); + } + + const payload = await response.json(); + setParsePreview(payload.preview || {}); + setParseRawText(payload.raw_text || ''); + setParseCaseId(payload.preview?.case_id || ''); + setParseCaseFields(payload.preview?.case || {}); + setUploadMessage('Документ распознан, можно проверить preview ниже'); + } catch (err) { + setUploadError(err.message || 'Ошибка загрузки файла'); + } finally { + setUploadLoading(false); + } + } + + function fieldFromPreview(name, fallback = '') { + const value = parseCaseFields?.[name]; + return value === undefined || value === null || value === '' ? fallback : String(value); + } + + async function handleImportPreview(event) { + event.preventDefault(); + if (!parseCaseFields || Object.keys(parseCaseFields).length === 0) { + setUploadError('Сначала загрузи и распарсь .docx файл'); + return; + } + + setUploadLoading(true); + setUploadError(''); + setUploadMessage(''); + + try { + const payload = { + age: parseCaseFields.age ?? null, + gender: parseCaseFields.gender ?? null, + status: parseCaseFields.status ?? 'new', + found_alive: parseCaseFields.found_alive ?? null, + found_distance_km: parseCaseFields.found_distance_km ?? null, + found_lat: parseCaseFields.found_lat ?? null, + found_lon: parseCaseFields.found_lon ?? null, + who_found: parseCaseFields.who_found ?? null, + note: parseCaseFields.notes ?? null, + }; + + const response = await fetch('/api/v1/cases', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.detail || `Не удалось создать кейс (${response.status})`); + } + + const created = await response.json(); + setUploadMessage(`Кейс создан: ${created.id}`); + resetParseState(); + setSaveMessage('Импорт из .docx завершён'); + setPage(1); + } catch (err) { + setUploadError(err.message || 'Ошибка импорта'); + } finally { + setUploadLoading(false); + } + } + + function handleFilterChange(event) { + const { name, value } = event.target; + setPage(1); + setSelectedCaseId(''); + setFilters((prev) => ({ ...prev, [name]: value })); + } + + function handleSelectCase(caseId) { + setSelectedCaseId(caseId); + const nextCase = cases.find((item) => item.id === caseId); + if (nextCase) { + setEditForm(nextCase); + setSaveMessage(''); + setSaveError(''); + } + } + + function updateEditForm(name, value) { + setEditForm((prev) => ({ ...prev, [name]: value })); + } + + async function handleSave(event) { + event.preventDefault(); + if (!selectedCaseId) return; + + setSaving(true); + setSaveError(''); + setSaveMessage(''); + + try { + const payload = { + status: editForm.status ?? null, + found_alive: editForm.found_alive ?? null, + found_distance_km: editForm.found_distance_km === '' ? null : editForm.found_distance_km, + found_direction: editForm.found_direction ?? null, + found_location_type: editForm.found_location_type ?? null, + found_lat: editForm.found_lat === '' ? null : editForm.found_lat, + found_lon: editForm.found_lon === '' ? null : editForm.found_lon, + who_found: editForm.who_found ?? null, + note: editForm.notes ?? null, + }; + + const response = await fetch(`/api/v1/admin/cases/${selectedCaseId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.detail || `Не удалось сохранить изменения (${response.status})`); + } + + const updated = await response.json(); + setSaveMessage('Изменения сохранены'); + setCases((prev) => prev.map((item) => (item.id === updated.id ? updated : item))); + setEditForm(updated); + setDashboardLoading(true); + try { + const dashboardResponse = await fetch('/api/v1/admin/dashboard'); + if (dashboardResponse.ok) { + setDashboard(await dashboardResponse.json()); + } + } finally { + setDashboardLoading(false); + } + } catch (err) { + setSaveError(err.message || 'Ошибка сохранения'); + } finally { + setSaving(false); + } + } return (
- + -
- - } /> - } /> - } /> - } /> - } /> - -
+
+
+ Всего кейсов + {dashboardLoading ? '…' : dashboard?.total_cases ?? 0} +
+
+ Найден живым + {dashboardLoading ? '…' : dashboard?.found_alive_count ?? 0} +
+
+ Найден погибшим + {dashboardLoading ? '…' : dashboard?.found_deceased_count ?? 0} +
+
+ Неизвестный исход + {dashboardLoading ? '…' : dashboard?.unknown_outcome_count ?? 0} +
+
+ Медиана дистанции + + {dashboardLoading ? '…' : dashboard?.median_found_distance_km ?? '—'} + +
+
+ Активность 7 дней + {dashboardLoading ? '…' : dashboard?.recent_activity_count ?? 0} +
+
+ +
+
+

Фильтры

+

Список кейсов можно сужать по статусу и возрасту.

+
+
+ + + +
+
+ +
+
+
+

Импорт отчёта .docx

+

Загрузите документ, проверьте preview и импортируйте извлечённые поля в новый кейс.

+
+
+ {parseCaseId ? `Preview case_id: ${parseCaseId}` : 'Ждём файл'} +
+
+ +
+
+ + + +
+
+ + {uploadError ?
{uploadError}
: null} + {uploadMessage ?
{uploadMessage}
: null} + + {parsePreview ? ( +
+
+
+

Распознанные поля

+
    + {parsePreviewFields.map(([key, value]) => ( +
  • + {key} + {typeof value === 'object' ? JSON.stringify(value) : String(value)} +
  • + ))} +
+
+
+

Кейс preview

+
    +
  • age{fieldFromPreview('age', '—')}
  • +
  • gender{fieldFromPreview('gender', '—')}
  • +
  • status{fieldFromPreview('status', 'new')}
  • +
  • found_alive{fieldFromPreview('found_alive', '—')}
  • +
  • found_distance_km{fieldFromPreview('found_distance_km', '—')}
  • +
  • who_found{fieldFromPreview('who_found', '—')}
  • +
  • notes{fieldFromPreview('notes', '—')}
  • +
+
+
+ {parseRawText ? ( +
+ Показать raw text +
{parseRawText}
+
+ ) : null} +
+ ) : null} +
+ + {error ?
{error}
: null} + +
+
+
+
+

Кейсы

+

+ Показано {cases.length} из {total} +

+
+
+ Страница {page} из {totalPages} +
+
+ +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : cases.length ? ( + cases.map((item) => ( + handleSelectCase(item.id)} + > + + + + + + + + + )) + ) : ( + + + + )} + +
IDДатаВозрастСтатусИсходДистанцияМесто
+ Загрузка… +
{item.id.slice(0, 8)}{formatDate(item.created_at)}{item.age ?? '—'} + {item.status} + + {item.found_alive === true ? ( + alive + ) : item.found_alive === false ? ( + deceased + ) : ( + unknown + )} + {item.found_distance_km ?? '—'}{item.last_known_place || item.found_location_type || '—'}
+ Нет данных по выбранным фильтрам +
+
+ +
+ + +
+
+ +