diff --git a/backend/database.py b/backend/database.py
index 0b5b330..d379ff0 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -273,13 +273,31 @@ class SQLCaseRepository:
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)
distances = [c.found_distance_km for c in cases if c.found_distance_km is not None]
+ # Активность 7 дней: кейсы, созданные за последнюю неделю (было: = total)
+ from datetime import timedelta as _td
+ cutoff = datetime.now(timezone.utc) - _td(days=7)
+ recent = sum(1 for c in cases
+ if c.created_at is not None
+ and (c.created_at if c.created_at.tzinfo else c.created_at.replace(tzinfo=timezone.utc)) >= cutoff)
+ # Счётчики операций (B21) — для дашборда админа
+ active_ops = completed_ops = 0
+ try:
+ from backend.models import SearchOperation as _Op
+ with SessionLocal() as session:
+ from sqlalchemy import select as _select, func as _func
+ active_ops = session.query(_func.count(_Op.id)).filter(_Op.status.in_(['planned', 'active', 'paused'])).scalar() or 0
+ completed_ops = session.query(_func.count(_Op.id)).filter(_Op.status.in_(['completed', 'archived'])).scalar() or 0
+ except Exception:
+ pass
return {
'total_cases': total,
'found_alive_count': found_alive,
'found_deceased_count': found_deceased,
'unknown_outcome_count': total - found_alive - found_deceased,
'median_found_distance_km': median(distances) if distances else None,
- 'recent_activity_count': total,
+ 'recent_activity_count': recent,
+ 'active_operations': active_ops,
+ 'completed_operations': completed_ops,
}
def heatmap(self) -> list[dict[str, Any]]:
diff --git a/backend/routers/admin.py b/backend/routers/admin.py
index 90abe1f..299e992 100644
--- a/backend/routers/admin.py
+++ b/backend/routers/admin.py
@@ -5,12 +5,17 @@ import re
import xml.etree.ElementTree as ET
import zipfile
+from datetime import datetime, timezone
+import logging
+
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from backend.database import db
from backend.routers.auth import require_roles
from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, DashboardResponse, ParseDocResponse
+log = logging.getLogger('vector.admin')
+
router = APIRouter(prefix='/api/v1/admin', tags=['admin'], dependencies=[Depends(require_roles(['admin']))])
@@ -120,12 +125,32 @@ def admin_cases(
@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)
+ # default_factory-списки ([]) при exclude_none всё равно попадают в data и
+ # ЗАТИРАЮТ реальные terrain/diagnosis_type — выкидываем пустые списки:
+ data = {k: v for k, v in data.items() if not (isinstance(v, list) and not v)}
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')
+
+ # B21-синхронизация: кейс закрыт вручную → его операция завершается автоматически
+ if data.get('status') == 'closed':
+ import uuid as _uuid
+ from backend.models import SearchOperation as _Op
+ from backend.database import SessionLocal as _SL
+ case_uuid = _uuid.UUID(str(case.id)) if isinstance(case.id, str) else case.id
+ with _SL() as session:
+ ops = session.query(_Op).filter(_Op.case_id == case_uuid,
+ _Op.status.in_(['planned', 'active', 'paused'])).all()
+ for op in ops:
+ op.status = 'completed'
+ op.closed_at = datetime.now(timezone.utc)
+ session.commit()
+ closed_ops = len(ops)
+ if closed_ops:
+ log.info('admin closed case %s → %d операция(ий) завершены автоматически', case_id, closed_ops)
return case.to_detail()
diff --git a/backend/routers/cases.py b/backend/routers/cases.py
index 61b82bb..805255f 100644
--- a/backend/routers/cases.py
+++ b/backend/routers/cases.py
@@ -45,6 +45,8 @@ def get_case(case_id: str) -> dict:
@router.patch('/{case_id}', response_model=CaseResponse)
def patch_case(case_id: str, payload: CaseUpdate) -> dict:
data = payload.model_dump(exclude_none=True)
+ # default_factory-списки затирали terrain/diagnosis_type пустыми массивами
+ data = {k: v for k, v in data.items() if not (isinstance(v, list) and not v)}
note = data.pop('note', None)
if note is not None and 'notes' not in data:
data['notes'] = note
diff --git a/backend/routers/operations.py b/backend/routers/operations.py
index 5486a77..2be4eeb 100644
--- a/backend/routers/operations.py
+++ b/backend/routers/operations.py
@@ -85,7 +85,13 @@ def list_operations(
if creator_ids:
for u in db.query(UserModel).filter(UserModel.id.in_(creator_ids)).all():
users_by_id[str(u.id)] = u.full_name or u.username
- return {'items': [_operation_dto(o, users_by_id) for o in ops], 'total': len(ops)}
+ units_by_id = {}
+ unit_ids = {o.unit_id for o in ops if o.unit_id}
+ if unit_ids:
+ from backend.models import MchsUnit
+ for un in db.query(MchsUnit).filter(MchsUnit.id.in_(unit_ids)).all():
+ units_by_id[str(un.id)] = un.name
+ return {'items': [_operation_dto(o, users_by_id, units_by_id) for o in ops], 'total': len(ops)}
def _operation_dto(op: SearchOperation, users_by_id: dict | None = None) -> dict[str, Any]:
diff --git a/backend/schemas.py b/backend/schemas.py
index d53b5e2..f2ae606 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -111,6 +111,8 @@ class DashboardResponse(BaseModel):
unknown_outcome_count: int
median_found_distance_km: float | None
recent_activity_count: int
+ active_operations: int = 0
+ completed_operations: int = 0
class RecommendationRequest(BaseModel):
diff --git a/backend/tests/test_admin_reorg.py b/backend/tests/test_admin_reorg.py
new file mode 100644
index 0000000..6cab2ff
--- /dev/null
+++ b/backend/tests/test_admin_reorg.py
@@ -0,0 +1,134 @@
+"""Ревизия /admin: автозакрытие операции при закрытии кейса, честная активность 7д."""
+from __future__ import annotations
+
+import sys
+from datetime import datetime, timedelta, timezone
+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 admin_env(monkeypatch):
+ from backend import models as m
+ from backend.database import Base
+ from backend.models import (MchsUnit, Permission, Role, RolePermission,
+ SecuritySetting, 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')
+ db.add(case); db.flush(); db.commit()
+ case_id = case.id
+
+ op = m.SearchOperation(title='Оп', case_id=case_id, status='active', unit_id=rcu.id)
+ db.add(op); db.commit()
+ op_id = op.id
+
+ 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.database import SQLCaseRepository
+ 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
+ # legacy-пути (db.update_case, stats-операции) — направляю в тестовую БД
+ import backend.database as database_mod
+ import backend.routers.admin as admin_mod
+ test_session = sessionmaker(bind=engine)
+ monkeypatch.setattr(database_mod, 'SessionLocal', test_session)
+ monkeypatch.setattr(admin_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, 'case_id': case_id, 'op_id': op_id}
+
+ app.dependency_overrides.pop(auth_router.get_db, None)
+
+
+class TestAdminCaseCloseSync:
+ def test_closing_case_completes_operation(self, admin_env):
+ env = admin_env
+ h = {'Authorization': f"Bearer {env['token']}"}
+ r = env['client'].patch(f"/api/v1/admin/cases/{env['case_id']}",
+ headers=h, json={'status': 'closed', 'found_alive': True})
+ assert r.status_code == 200, r.json()
+ 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_stats_activity_is_real(self, admin_env):
+ env = admin_env
+ # Кейс старше 7 дней — не должен считаться активным
+ from backend.models import Case
+ case = env['db'].query(Case).filter(Case.id == env['case_id']).first()
+ case.created_at = datetime.now(timezone.utc) - timedelta(days=10)
+ env['db'].commit()
+ h = {'Authorization': f"Bearer {env['token']}"}
+ r = env['client'].get('/api/v1/admin/dashboard', headers=h)
+ assert r.status_code == 200
+ d = r.json()
+ assert d['recent_activity_count'] == 0
+ assert d['active_operations'] == 1
+ assert 'completed_operations' in d
diff --git a/frontend/src/pages/AdminDashboard.css b/frontend/src/pages/AdminDashboard.css
index 15fd2eb..9bd0268 100644
--- a/frontend/src/pages/AdminDashboard.css
+++ b/frontend/src/pages/AdminDashboard.css
@@ -437,3 +437,7 @@
flex-direction: column;
}
}
+
+.admin-badge-green { background: rgba(63,185,80,0.15); color: #3fb950; }
+.admin-badge-amber { background: rgba(255,209,102,0.15); color: #ffd166; }
+.admin-badge-blue { background: rgba(88,166,255,0.15); color: #58a6ff; }
diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx
index d070eae..2177d59 100644
--- a/frontend/src/pages/AdminDashboard.jsx
+++ b/frontend/src/pages/AdminDashboard.jsx
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
-import { apiFetch } from '../api/client';
+import { apiFetch, apiJson } from '../api/client';
+import { WHO_FOUND_TYPES } from '../constants';
import { useAuth } from '../context/AuthContext';
import ClosedCases from './ClosedCases';
import './AdminDashboard.css';
@@ -30,6 +31,107 @@ function Badge({ children, tone = 'neutral' }) {
return {children};
}
+
+const cell = { padding: '8px 10px', verticalAlign: 'top' };
+
+const OP_STATUS_LABEL = {
+ planned: 'Запланирована',
+ active: 'Активна',
+ paused: 'Приостановлена',
+ completed: 'Завершена',
+ archived: 'В архиве',
+};
+
+const OP_STATUS_TONE = {
+ planned: 'neutral',
+ active: 'success',
+ paused: 'amber',
+ completed: 'blue',
+ archived: 'neutral',
+};
+
+function OperationsTab() {
+ const [ops, setOps] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [statusFilter, setStatusFilter] = useState('');
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError('');
+ try {
+ const q = statusFilter ? `?status=${statusFilter}` : '';
+ const data = await apiJson(`/api/v1/operations${q}`);
+ setOps(data.items || []);
+ } catch (e) {
+ setError(e.message || 'Ошибка загрузки');
+ } finally {
+ setLoading(false);
+ }
+ }, [statusFilter]);
+
+ useEffect(() => { load(); }, [load]);
+
+ return (
+ Загрузка… {error} Операций нет.
+
+
+ )}
+
+
+
+
+ {ops.map((op) => (
+ Операция
+ Статус
+ Подразделение
+ Обновлена
+
+
+
+ ))}
+
+
+
+
+
+ {op.unit_name || '—'}
+ {op.updated_at ? new Date(op.updated_at).toLocaleString('ru-RU') : '—'}
+
+
+
+