diff --git a/frontend/src/App.js b/frontend/src/App.js index 29cd9aa..3feeeae 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -4,6 +4,7 @@ import AdminDashboard from "./pages/AdminDashboard"; import DesktopSARPage from "./pages/DesktopSARPage"; import AnalysisResult from "./pages/analysis/AnalysisResult"; import Login from "./pages/Login"; +import Dashboard from "./pages/Dashboard"; import ProtectedRoute from "./components/ProtectedRoute"; import { AuthProvider } from "./context/AuthContext"; import "./App.css"; @@ -34,7 +35,8 @@ function App() { } /> - } /> + } /> + } /> } /> } /> diff --git a/frontend/src/components/CaseForm/CaseForm.jsx b/frontend/src/components/CaseForm/CaseForm.jsx index d3fa222..8e42d9b 100644 --- a/frontend/src/components/CaseForm/CaseForm.jsx +++ b/frontend/src/components/CaseForm/CaseForm.jsx @@ -116,6 +116,19 @@ const CaseForm = ({ isMobile = false }) => { const result = await response.json(); + // E3: автоматически создаём поисковую операцию для карточки. + try { + await apiFetch('/api/v1/operations', { + method: 'POST', + body: JSON.stringify({ + title: `Поиск: ${formData.child_name || 'без имени'}, ${formData.age_years || '?'} лет`, + case_id: result.id, + }), + }); + } catch { + /* Операция не блокирует анализ (создастся позже вручную) */ + } + // Перенаправляем на страницу анализа с ID карточки. // SPA-переход: полная перезагрузка теряла состояние приложения. navigate(`/analysis/${result.id}`); diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css new file mode 100644 index 0000000..422ddfc --- /dev/null +++ b/frontend/src/pages/Dashboard.css @@ -0,0 +1,149 @@ +/* E3: дашборд «Активные поиски» — VCL-density, палитра ЦОУ */ + +.dashboard { + max-width: 1100px; + margin: 0 auto; + padding: 24px 20px; +} + +.dash-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.dash-header h1 { + margin: 0; + font-size: 22px; + color: #e6edf3; +} + +.dash-header .logo { + color: var(--accent, #ff4d00); + margin-right: 8px; +} + +.dash-new-btn { + padding: 10px 18px; + background: #238636; + color: #fff; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.dash-new-btn:hover { background: #2ea043; } + +/* Счётчики */ +.dash-counters { + display: flex; + gap: 12px; + margin-bottom: 18px; +} + +.counter-card { + flex: 0 0 160px; + padding: 12px 16px; + background: #161b22; + border: 1px solid #30363d; + border-radius: 6px; +} + +.counter-value { + font-size: 26px; + font-weight: 700; + color: #e6edf3; +} + +.counter-active .counter-value { color: var(--accent, #ff4d00); } +.counter-closed .counter-value { color: #3fb950; } + +.counter-label { + font-size: 12px; + color: #8b949e; + margin-top: 2px; +} + +/* Фильтры */ +.dash-filters { + display: flex; + gap: 8px; + margin-bottom: 14px; +} + +.dash-filter { + padding: 7px 14px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + color: #8b949e; + font-size: 13px; + cursor: pointer; +} + +.dash-filter.selected { + color: #e6edf3; + border-color: var(--accent, #ff4d00); + background: #161b22; +} + +/* Состояния */ +.dash-loading, .dash-empty { + padding: 32px; + text-align: center; + color: #8b949e; + background: #161b22; + border-radius: 6px; +} + +/* Сетка карточек операций */ +.dash-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 12px; +} + +.op-card { + padding: 14px 16px; + background: #161b22; + border: 1px solid #30363d; + border-radius: 6px; + cursor: pointer; + transition: border-color 0.15s; +} + +.op-card:hover { + border-color: var(--accent, #ff4d00); +} + +.op-status { + display: inline-block; + padding: 2px 10px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + margin-bottom: 8px; +} + +.st-active { background: rgba(255, 77, 0, 0.18); color: var(--accent, #ff4d00); } +.st-paused { background: rgba(255, 209, 102, 0.15); color: #ffd166; } +.st-planned { background: rgba(139, 148, 158, 0.15); color: #8b949e; } +.st-completed{ background: rgba(63, 185, 80, 0.15); color: #3fb950; } +.st-archived { background: rgba(139, 148, 158, 0.10); color: #6e7681; } + +.op-title { + font-size: 15px; + font-weight: 600; + color: #e6edf3; + margin-bottom: 8px; +} + +.op-meta { + display: flex; + justify-content: space-between; + font-size: 12px; + color: #8b949e; +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..1740584 --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,129 @@ +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { apiFetch } from "../api/client"; +import "./Dashboard.css"; + +const STATUS_LABEL = { + planned: 'Запланирован', + active: 'Активен', + paused: 'Приостановлен', + completed: 'Завершён', + archived: 'В архиве', +}; + +const STATUS_CLASS = { + planned: 'st-planned', + active: 'st-active', + paused: 'st-paused', + completed: 'st-completed', + archived: 'st-archived', +}; + +const Dashboard = () => { + const navigate = useNavigate(); + const [operations, setOperations] = useState([]); + const [summary, setSummary] = useState({ active: 0, total: 0, completed_24h: 0 }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [statusFilter, setStatusFilter] = useState('active'); + const [creating, setCreating] = useState(false); + + const load = async () => { + try { + setLoading(true); + setError(null); + const qs = statusFilter ? `?status=${statusFilter}` : ''; + const resp = await apiFetch(`/api/v1/operations${qs}`); + if (!resp.ok) throw new Error('Не удалось загрузить поиски'); + const data = await resp.json(); + setOperations(data.items); + + const sumResp = await apiFetch('/api/v1/operations/summary'); + if (sumResp.ok) setSummary(await sumResp.json()); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, [statusFilter]); + + const openCase = (op) => { + // Рабочее пространство операции: анализ по её карточке + navigate(`/analysis/${op.case_id}`, { state: { operation: op } }); + }; + + const startNewSearch = () => navigate('/desktop'); + + return ( +
+
+

ВЕКТОР Активные поиски

+ +
+ +
+
+
{summary.active}
+
Активных
+
+
+
{summary.total}
+
Всего операций
+
+
+
{summary.completed_24h}
+
Завершено за 24 ч
+
+
+ +
+ {['active', 'paused', 'completed', '', ].map((s) => ( + + ))} +
+ + {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && operations.length === 0 && ( +
+ {statusFilter === 'active' + ? 'Активных поисков нет. Создайте новый поиск.' + : 'Операций в этом статусе нет.'} +
+ )} + + {!loading && !error && ( +
+ {operations.map((op) => ( +
openCase(op)}> +
+ {STATUS_LABEL[op.status] || op.status} +
+
{op.title}
+
+ {op.created_by_name && {op.created_by_name}} + {op.updated_at && ( + + обновлено {new Date(op.updated_at).toLocaleString('ru-RU', { + day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', + })} + + )} +
+
+ ))} +
+ )} +
+ ); +}; + +export default Dashboard; \ No newline at end of file