E3 UI: дашборд «Активные поиски» — главная страница после логина
- Dashboard.jsx: счётчики (активных / всего / завершено за 24ч из /operations/summary), фильтры по статусу, сетка карточек операций (title, статус-бейдж, подразделение, время обновления), клик → рабочее пространство (анализ по карточке операции). - Роут /dashboard, «/» → дашборд (было /desktop). - CaseForm: после создания карточки автоматически создаётся поисковая операция (title «Поиск: имя, N лет») — операция не блокирует анализ. - VCL-density, палитра ЦОУ (#ff4d00 акценты, статусы цветом).
This commit is contained in:
+3
-1
@@ -4,6 +4,7 @@ import AdminDashboard from "./pages/AdminDashboard";
|
|||||||
import DesktopSARPage from "./pages/DesktopSARPage";
|
import DesktopSARPage from "./pages/DesktopSARPage";
|
||||||
import AnalysisResult from "./pages/analysis/AnalysisResult";
|
import AnalysisResult from "./pages/analysis/AnalysisResult";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
|
import Dashboard from "./pages/Dashboard";
|
||||||
import ProtectedRoute from "./components/ProtectedRoute";
|
import ProtectedRoute from "./components/ProtectedRoute";
|
||||||
import { AuthProvider } from "./context/AuthContext";
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
@@ -34,7 +35,8 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/desktop" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
|
|
||||||
<Route path="/sar" element={<ProtectedRoute><DesktopSARPage /></ProtectedRoute>} />
|
<Route path="/sar" element={<ProtectedRoute><DesktopSARPage /></ProtectedRoute>} />
|
||||||
<Route path="/desktop" element={<ProtectedRoute><DesktopSARPage /></ProtectedRoute>} />
|
<Route path="/desktop" element={<ProtectedRoute><DesktopSARPage /></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -116,6 +116,19 @@ const CaseForm = ({ isMobile = false }) => {
|
|||||||
|
|
||||||
const result = await response.json();
|
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 карточки.
|
// Перенаправляем на страницу анализа с ID карточки.
|
||||||
// SPA-переход: полная перезагрузка теряла состояние приложения.
|
// SPA-переход: полная перезагрузка теряла состояние приложения.
|
||||||
navigate(`/analysis/${result.id}`);
|
navigate(`/analysis/${result.id}`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="dashboard">
|
||||||
|
<div className="dash-header">
|
||||||
|
<h1><span className="logo">ВЕКТОР</span> Активные поиски</h1>
|
||||||
|
<button className="dash-new-btn" onClick={startNewSearch}>+ Новый поиск</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dash-counters">
|
||||||
|
<div className="counter-card counter-active">
|
||||||
|
<div className="counter-value">{summary.active}</div>
|
||||||
|
<div className="counter-label">Активных</div>
|
||||||
|
</div>
|
||||||
|
<div className="counter-card counter-total">
|
||||||
|
<div className="counter-value">{summary.total}</div>
|
||||||
|
<div className="counter-label">Всего операций</div>
|
||||||
|
</div>
|
||||||
|
<div className="counter-card counter-closed">
|
||||||
|
<div className="counter-value">{summary.completed_24h}</div>
|
||||||
|
<div className="counter-label">Завершено за 24 ч</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dash-filters">
|
||||||
|
{['active', 'paused', 'completed', '', ].map((s) => (
|
||||||
|
<button
|
||||||
|
key={s || 'all'}
|
||||||
|
className={`dash-filter ${statusFilter === s ? 'selected' : ''}`}
|
||||||
|
onClick={() => setStatusFilter(s)}
|
||||||
|
>
|
||||||
|
{s ? STATUS_LABEL[s] : 'Все'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="dash-loading">Загрузка...</div>}
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
{!loading && !error && operations.length === 0 && (
|
||||||
|
<div className="dash-empty">
|
||||||
|
{statusFilter === 'active'
|
||||||
|
? 'Активных поисков нет. Создайте новый поиск.'
|
||||||
|
: 'Операций в этом статусе нет.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="dash-grid">
|
||||||
|
{operations.map((op) => (
|
||||||
|
<div key={op.id} className="op-card" onClick={() => openCase(op)}>
|
||||||
|
<div className={`op-status ${STATUS_CLASS[op.status] || ''}`}>
|
||||||
|
{STATUS_LABEL[op.status] || op.status}
|
||||||
|
</div>
|
||||||
|
<div className="op-title">{op.title}</div>
|
||||||
|
<div className="op-meta">
|
||||||
|
{op.created_by_name && <span className="op-unit">{op.created_by_name}</span>}
|
||||||
|
{op.updated_at && (
|
||||||
|
<span className="op-time">
|
||||||
|
обновлено {new Date(op.updated_at).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
Reference in New Issue
Block a user