klg-asutk-app/app/users/page.tsx
Yuriy e9ef17ba16 KLG ASUTK: единый промт — персонал ПЛГ, ВС, заявки, аудиты, риски, пользователи, контроль ЛГ, ТО, дефекты, настройки, справка, логотип REFLY
- Персонал ПЛГ: демо-программы, таблица прохождений, кнопки Добавить/Редактировать/Экспорт
- ВС: AircraftEditModal, кнопка Редактировать в строке
- Заявки: Создать по шаблону, 5 демо-заявок
- Аудиты: AuditEditModal, чек-лист замечаний, auditsApi.update
- Риски: RiskDetailModal при клике, Скачать отчёт, Закрыть
- Пользователи: таблица с демо (10), CRUD, Экспорт, поиск и фильтры
- Контроль ЛГ: вкладка с 7 демо-записями, модалка, Скачать сертификат
- Тех. обслуживание: демо-наряды при ошибке API, Скачать наряд
- Дефекты: 7 демо-дефектов, детальный просмотр, цвет по серьёзности
- Настройки: Профиль организации, Шаблоны, Справочники, Экспорт/Импорт
- Справка: HelpDocumentModal при клике, оглавление, Скачать PDF
- ReflyLogo: компонент, Sidebar и страница логина

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 16:19:39 +03:00

152 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useMemo } from 'react';
import UserEditModal from '@/components/UserEditModal';
import { PageLayout, DataTable, StatusBadge, Modal } from '@/components/ui';
const RL: Record<string, string> = { admin: 'Администратор', authority_inspector: 'Инспектор', favt_inspector: 'Инспектор ФАВТ', operator_manager: 'Менеджер оператора', operator_user: 'Оператор', mro_manager: 'Менеджер ТОиР', mro_specialist: 'Специалист ТОиР', mro_user: 'Специалист ТОиР', engineer: 'Инженер', inspector: 'Инспектор' };
const RC: Record<string, string> = { admin: 'bg-green-500', authority_inspector: 'bg-blue-500', favt_inspector: 'bg-blue-500', operator_manager: 'bg-orange-500', operator_user: 'bg-orange-400', mro_manager: 'bg-purple-500', mro_specialist: 'bg-purple-400', mro_user: 'bg-purple-400', engineer: 'bg-teal-500', inspector: 'bg-indigo-500' };
const DEMO_USERS = [
{ id: '1', display_name: 'Иванов Иван Иванович', email: 'ivanov@company.ru', role: 'admin', organization_name: 'АО «Авиакомпания»', status: 'active', last_login: '2024-12-01T10:00:00Z' },
{ id: '2', display_name: 'Петрова Мария Сергеевна', email: 'petrova@favt.gov.ru', role: 'authority_inspector', organization_name: 'ФАВТ', status: 'active', last_login: '2024-12-02T09:15:00Z' },
{ id: '3', display_name: 'Сидоров Пётр Андреевич', email: 'sidorov@operator.ru', role: 'operator_manager', organization_name: 'ООО «АвиаСервис»', status: 'active', last_login: '2024-11-28T14:20:00Z' },
{ id: '4', display_name: 'Козлова Анна Викторовна', email: 'kozlova@mro.ru', role: 'mro_manager', organization_name: 'ПАО «ТОиР»', status: 'active', last_login: '2024-12-01T08:00:00Z' },
{ id: '5', display_name: 'Новиков Алексей Дмитриевич', email: 'novikov@company.ru', role: 'engineer', organization_name: 'АО «Авиакомпания»', status: 'active', last_login: '2024-11-30T16:45:00Z' },
{ id: '6', display_name: 'Морозова Елена Игоревна', email: 'morozova@favt.gov.ru', role: 'inspector', organization_name: 'Ространснадзор', status: 'active', last_login: '2024-11-29T11:00:00Z' },
{ id: '7', display_name: 'Волков Дмитрий Николаевич', email: 'volkov@operator.ru', role: 'operator_user', organization_name: 'ООО «АвиаСервис»', status: 'active', last_login: '2024-11-27T12:30:00Z' },
{ id: '8', display_name: 'Соколова Ольга Павловна', email: 'sokolova@mro.ru', role: 'mro_specialist', organization_name: 'ПАО «ТОиР»', status: 'inactive', last_login: '2024-10-15T09:00:00Z' },
{ id: '9', display_name: 'Лебедев Андрей Владимирович', email: 'lebedev@company.ru', role: 'operator_user', organization_name: 'АО «Авиакомпания»', status: 'active', last_login: '2024-12-02T07:20:00Z' },
{ id: '10', display_name: 'Кузнецова Татьяна Александровна', email: 'kuznetsova@favt.gov.ru', role: 'favt_inspector', organization_name: 'Минтранс / ФАВТ', status: 'active', last_login: '2024-12-01T15:10:00Z' },
];
export default function UsersPage() {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [roleFilter, setRoleFilter] = useState<string | undefined>();
const [orgFilter, setOrgFilter] = useState('');
const [search, setSearch] = useState('');
const [addOpen, setAddOpen] = useState(false);
const [addForm, setAddForm] = useState({ display_name: '', email: '', role: 'operator_user', organization_name: '' });
const [editingUser, setEditingUser] = useState<any>(null);
useEffect(() => {
setLoading(true);
fetch('/api/v1/users')
.then(r => r.json())
.then(d => setUsers(Array.isArray(d?.items) && d.items.length > 0 ? d.items : DEMO_USERS))
.catch(() => setUsers(DEMO_USERS))
.finally(() => setLoading(false));
}, []);
const filtered = useMemo(() => {
let list = users;
if (roleFilter) list = list.filter(u => u.role === roleFilter);
if (orgFilter) list = list.filter(u => (u.organization_name || '').toLowerCase().includes(orgFilter.toLowerCase()));
if (search) {
const q = search.toLowerCase();
list = list.filter(u =>
(u.display_name || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q)
);
}
return list;
}, [users, roleFilter, orgFilter, search]);
const handleSaveEdit = (payload: any) => {
if (editingUser?.id) setUsers(prev => prev.map(u => u.id === editingUser.id ? { ...u, ...payload } : u));
setEditingUser(null);
};
const handleAdd = () => {
setUsers(prev => [{ ...addForm, id: String(Date.now()), organization_name: addForm.organization_name || '—', status: 'active', last_login: null }, ...prev]);
setAddOpen(false);
setAddForm({ display_name: '', email: '', role: 'operator_user', organization_name: '' });
};
const handleDelete = (id: string) => {
if (!confirm('Удалить пользователя?')) return;
setUsers(prev => prev.filter(u => u.id !== id));
};
const handleExport = () => {
const headers = ['ФИО', 'Email', 'Роль', 'Организация', 'Статус', 'Последний вход'];
const rows = filtered.map(u => [
u.display_name || '',
u.email || '',
RL[u.role] || u.role,
u.organization_name || '',
u.status === 'active' ? 'Активен' : 'Неактивен',
u.last_login ? new Date(u.last_login).toLocaleString('ru-RU') : '—',
]);
const csv = [headers.join(';'), ...rows.map(r => r.join(';'))].join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'users.csv'; a.click();
URL.revokeObjectURL(url);
};
const rolesForFilter = Array.from(new Set(users.map(u => u.role).filter(Boolean)));
return (
<PageLayout
title="Пользователи"
subtitle={loading ? 'Загрузка...' : `Всего: ${filtered.length}`}
actions={
<>
<input type="text" placeholder="Поиск (ФИО, email)..." value={search} onChange={e => setSearch(e.target.value)} className="input-field w-56" />
<select value={roleFilter ?? ''} onChange={e => setRoleFilter(e.target.value || undefined)} className="input-field w-40">
<option value="">Все роли</option>
{rolesForFilter.map(r => <option key={r} value={r}>{RL[r] || r}</option>)}
</select>
<input type="text" placeholder="Организация..." value={orgFilter} onChange={e => setOrgFilter(e.target.value)} className="input-field w-48" />
<button onClick={handleExport} className="btn-sm bg-gray-100 text-gray-700">Экспорт</button>
<button onClick={() => setAddOpen(true)} className="btn-primary">+ Добавить пользователя</button>
</>
}
>
{loading ? <div className="text-center py-10 text-gray-400"> Загрузка...</div> : filtered.length > 0 ? (
<div className="card overflow-x-auto">
<table className="w-full">
<thead><tr className="bg-gray-50">
<th className="table-header">ФИО</th><th className="table-header">Email</th><th className="table-header">Роль</th>
<th className="table-header">Организация</th><th className="table-header">Статус</th><th className="table-header">Последний вход</th><th className="table-header">Действия</th>
</tr></thead>
<tbody>
{filtered.map(u => (
<tr key={u.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="table-cell font-medium">{u.display_name || '—'}</td>
<td className="table-cell text-gray-600">{u.email || '—'}</td>
<td className="table-cell"><StatusBadge status={u.role} colorMap={RC} labelMap={RL} /></td>
<td className="table-cell text-gray-600">{u.organization_name || '—'}</td>
<td className="table-cell"><span className={`badge ${u.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>{u.status === 'active' ? 'Активен' : 'Неактивен'}</span></td>
<td className="table-cell text-sm text-gray-500">{u.last_login ? new Date(u.last_login).toLocaleString('ru-RU') : '—'}</td>
<td className="table-cell">
<div className="flex gap-1">
<button onClick={() => setEditingUser(u)} className="btn-sm bg-gray-100 text-gray-600 hover:bg-gray-200 p-1.5 rounded" title="Редактировать">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</button>
<button onClick={() => handleDelete(u.id)} className="btn-sm bg-red-100 text-red-600 hover:bg-red-200">Удалить</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : <div className="card p-5 bg-blue-50 flex items-center gap-3"><span></span><span>Нет пользователей</span></div>}
<UserEditModal isOpen={!!editingUser} onClose={() => setEditingUser(null)} user={editingUser} onSave={handleSaveEdit} />
<Modal isOpen={addOpen} onClose={() => setAddOpen(false)} title="Добавить пользователя" size="md"
footer={<><button onClick={() => setAddOpen(false)} className="btn-secondary">Отмена</button><button onClick={handleAdd} className="btn-primary">Добавить</button></>}>
<div className="space-y-3">
<div><label className="text-xs font-medium text-gray-600">ФИО</label><input value={addForm.display_name} onChange={e => setAddForm(f => ({ ...f, display_name: e.target.value }))} className="input-field w-full mt-1" placeholder="Иванов И. И." /></div>
<div><label className="text-xs font-medium text-gray-600">Email</label><input type="email" value={addForm.email} onChange={e => setAddForm(f => ({ ...f, email: e.target.value }))} className="input-field w-full mt-1" placeholder="user@company.ru" /></div>
<div><label className="text-xs font-medium text-gray-600">Роль</label><select value={addForm.role} onChange={e => setAddForm(f => ({ ...f, role: e.target.value }))} className="input-field w-full mt-1">{Object.entries(RL).slice(0, 10).map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select></div>
<div><label className="text-xs font-medium text-gray-600">Организация</label><input value={addForm.organization_name} onChange={e => setAddForm(f => ({ ...f, organization_name: e.target.value }))} className="input-field w-full mt-1" placeholder="Организация" /></div>
</div>
</Modal>
</PageLayout>
);
}