39b2e296d1
- UsersAdmin.jsx (/users): список с фильтрами (подразделение/поиск),
форма создания (логин/email/временный пароль/ФИО/должность/юнит/роль),
inline-смена роли и подразделения, отключение/включение, сброс пароля
(все сессии отзываются), статусы цветом. must_change_password
подсвечивается пользователю при первом входе.
- Доступ: бэкенд require_permission('manage_users') — РЦУ РЧС и ОУМЧС;
скоуп координатора фильтрует список по подразделению (кросс-областной
PATCH отбивается 403 на бэкенде).
- Кнопка «Пользователи» из дашборда.
136 lines
4.8 KiB
React
136 lines
4.8 KiB
React
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>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<button className="dash-new-btn" onClick={startNewSearch}>+ Новый поиск</button>
|
||
<button
|
||
className="dash-new-btn"
|
||
style={{ background: '#21262d' }}
|
||
onClick={() => navigate('/users')}
|
||
>Пользователи</button>
|
||
</div>
|
||
</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; |