Реорганизация /admin: операции, честные метрики, синхронизация закрытия

- Вкладка «Операции»: список search_operations (статус/подразделение/обновлена,
  фильтр, переход в анализ кейса), unit_name в DTO.
- Метрики: + активные/завершённые операции (stats из search_operations);
  «Активность 7 дней» теперь честная (created_at >= now-7d, была = total).
- Синхронизация: закрытие кейса через /admin PATCH → активная операция
  автоматически completed + closed_at (аудит в логе).
- ФИКС legacy-бага PATCH /cases: default_factory-списки затирали
  terrain/diagnosis_type пустыми массивами — выкидываются пустые списки.
- Редактор исхода: «Кто нашёл» → select WHO_FOUND_TYPES.
- Кнопка «Пользователи · Аудит» → /users.
- Тесты: автозакрытие операции, метрики (232 passed).
This commit is contained in:
2026-09-09 22:41:14 +03:00
parent 4fe60b0772
commit a1d33a2b28
8 changed files with 320 additions and 6 deletions
+4
View File
@@ -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; }
+127 -4
View File
@@ -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 <span className={`admin-badge admin-badge-${tone}`}>{children}</span>;
}
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 (
<section className="admin-ops">
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 10 }}>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{ padding: '6px 10px', fontSize: 13, fontFamily: 'inherit' }}
>
<option value="">Все статусы</option>
{Object.entries(OP_STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<button type="button" onClick={load} style={{ padding: '6px 12px', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>Обновить</button>
<span style={{ fontSize: 12, color: '#8b949e' }}>всего: {ops.length}</span>
</div>
{loading && <p>Загрузка…</p>}
{error && <p style={{ color: '#f85149' }}>{error}</p>}
{!loading && !error && ops.length === 0 && <p>Операций нет.</p>}
{!loading && ops.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', color: '#8b949e', fontSize: 12 }}>
<th style={cell}>Операция</th>
<th style={cell}>Статус</th>
<th style={cell}>Подразделение</th>
<th style={cell}>Обновлена</th>
<th style={cell}></th>
</tr>
</thead>
<tbody>
{ops.map((op) => (
<tr key={op.id} style={{ borderBottom: '1px solid #21262d' }}>
<td style={cell}>
<div style={{ fontWeight: 600 }}>{op.title}</div>
<div style={{ fontSize: 12, color: '#8b949e' }}>кейс {String(op.case_id).slice(0, 8)}</div>
</td>
<td style={cell}>
<Badge tone={OP_STATUS_TONE[op.status] || 'neutral'}>{OP_STATUS_LABEL[op.status] || op.status}</Badge>
</td>
<td style={cell}>{op.unit_name || '—'}</td>
<td style={cell}>{op.updated_at ? new Date(op.updated_at).toLocaleString('ru-RU') : '—'}</td>
<td style={cell}>
<button
type="button"
onClick={() => { window.location.href = `/analysis/${op.case_id}`; }}
style={{ padding: '4px 10px', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit' }}
>
Открыть анализ
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
function AdminDashboard() {
const { user, logout } = useAuth();
const [activeTab, setActiveTab] = useState('cases'); // 'cases' | 'closed'
@@ -337,12 +439,20 @@ function AdminDashboard() {
<button type="button" className="admin-secondary-button" onClick={logout}>
Выйти{user ? ` · ${user.username}` : ''}
</button>
<button
type="button"
onClick={() => { window.location.href = '/users'; }}
style={{ padding: '8px 14px', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}
>
Пользователи · Аудит
</button>
</div>
</header>
<div className="admin-tabs" style={{ display: 'flex', gap: 4, marginBottom: 12 }}>
{[
{ id: 'cases', label: 'Кейсы' },
{ id: 'operations', label: 'Операции' },
{ id: 'closed', label: 'Завершённые поиски' },
].map((t) => (
<button
@@ -365,7 +475,9 @@ function AdminDashboard() {
))}
</div>
{activeTab === 'closed' ? (
{activeTab === 'operations' ? (
<OperationsTab />
) : activeTab === 'closed' ? (
<ClosedCases />
) : (
<>
@@ -396,6 +508,14 @@ function AdminDashboard() {
<span className="admin-metric-label">Активность 7 дней</span>
<strong>{dashboardLoading ? '…' : dashboard?.recent_activity_count ?? 0}</strong>
</article>
<article className="admin-metric-card">
<span className="admin-metric-label">Активные операции</span>
<strong>{dashboardLoading ? '…' : dashboard?.active_operations ?? 0}</strong>
</article>
<article className="admin-metric-card">
<span className="admin-metric-label">Завершённые операции</span>
<strong>{dashboardLoading ? '…' : dashboard?.completed_operations ?? 0}</strong>
</article>
</section>
<section className="admin-panel admin-filters-panel">
@@ -618,10 +738,13 @@ function AdminDashboard() {
</label>
<label className="admin-field">
<span>Кто нашёл</span>
<input
<select
value={editForm.who_found ?? ''}
onChange={(event) => updateEditForm('who_found', event.target.value)}
/>
>
<option value="">Не указано</option>
{WHO_FOUND_TYPES.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</label>
<label className="admin-field">
<span>Найден живым</span>