arm-core/web/src/widgets/Admin/UsersTable.tsx
Andrey Epifancev d3c2f8ab1f feat(web): страница администратора — пользователи, роли, матрица прав
Маршрут /admin (вкладки «Пользователи» и «Матрица прав») и пункт «Система → Пользователи и роли» в меню;
без USERS_ADMIN в /me — экран «Нет доступа», таблицы не монтируются и запросов к /admin/* нет, пункт меню скрыт.

Почему без Redux: данные экрана нужны только ему, после каждой правки перечитываются с сервера — хватает
локального state и маленького хука useAsyncData (отбрасывает устаревшие ответы и ответы после ухода со страницы).
Клиент один — api/client.ts (Bearer из sessionStorage, ошибки сервера -> ApiError с кодом и текстом); ошибки
показываются через App.useApp().message (корень обёрнут в antd App). Экран показывает только серверное состояние:
при отказе (409 LAST_ADMIN и др.) — уведомление и перечитывание, матрица сохраняет роли по очереди.
РАИСА появится в auth/: токен даст SSO вместо dev-входа; этот экран и клиент не меняются.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 01:04:32 +04:00

148 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

import { PlusOutlined } from '@ant-design/icons';
import { App, Button, Flex, Select, Switch, Table, type TableColumnsType, Typography } from 'antd';
import { useRef, useState } from 'react';
import { api } from '../../api/client';
import type { AdminUser, Page, RegionalOffice, Role } from '../../api/types';
import { CreateUserModal } from './CreateUserModal';
import { useAsyncData, useMountedRef, useNotifyError } from './hooks';
// Сервер отдаёт не больше 200 пользователей за запрос; постраничный просмотр появится, когда пользователей станет больше
const USERS_LIMIT = 200;
// Загрузчики — функции уровня модуля: они не меняются между отрисовками (требование useAsyncData)
const loadUsers = () => api.get<Page<AdminUser>>(`/admin/users?limit=${USERS_LIMIT}`);
const loadRoles = () => api.get<Role[]>('/admin/roles');
const loadOffices = () => api.get<RegionalOffice[]>('/directories/regional-offices');
/**
* Таблица пользователей: роли меняются прямо в строке (`PUT /admin/users/{id}/roles`), активность — переключателем
* (`PATCH /admin/users/{id}`), новый пользователь — через окно создания.
*
* Состояние на экране всегда серверное: значение в строке меняется только после ответа сервера (берём пользователя из
* ответа). При ошибке (например, 409 `LAST_ADMIN`) показываем уведомление и перечитываем список, поэтому контрол
* никогда не показывает отвергнутое сервером состояние. Пока одна строка сохраняется, правка остальных заблокирована —
* запросы не идут параллельно и не обгоняют друг друга.
*/
export function UsersTable() {
const users = useAsyncData(loadUsers);
const roles = useAsyncData(loadRoles);
const offices = useAsyncData(loadOffices);
const [savingUserId, setSavingUserId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const notifyError = useNotifyError();
const { message } = App.useApp();
const mounted = useMountedRef();
// Замок от двойного клика: состояние React обновится только к следующей отрисовке, а ref — сразу
const savingLock = useRef(false);
const roleList = roles.data ?? [];
const page = users.data;
const busy = savingUserId !== null || users.loading;
// Одна правка строки: запрос → строка из ответа сервера; ошибка → уведомление и перечитывание списка
const saveRow = async (userId: string, request: () => Promise<AdminUser>) => {
if (savingLock.current) {
return;
}
savingLock.current = true;
setSavingUserId(userId);
try {
const updated = await request();
if (mounted.current) {
users.setData((prev) => (prev ? { ...prev, items: prev.items.map((u) => (u.id === updated.id ? updated : u)) } : prev));
}
} catch (e: unknown) {
if (mounted.current) {
notifyError(e);
await users.reload();
}
} finally {
savingLock.current = false;
if (mounted.current) {
setSavingUserId(null);
}
}
};
const changeRoles = (user: AdminUser, codes: string[]) =>
saveRow(user.id, () => api.put<AdminUser>(`/admin/users/${user.id}/roles`, { roles: codes }));
const changeActive = (user: AdminUser, isActive: boolean) =>
saveRow(user.id, () => api.patch<AdminUser>(`/admin/users/${user.id}`, { is_active: isActive }));
const onCreated = (user: AdminUser) => {
setCreateOpen(false);
message.success(`Пользователь ${user.email ?? user.full_name} создан`);
void users.reload();
};
const columns: TableColumnsType<AdminUser> = [
{ title: 'Email', dataIndex: 'email', render: (email: string | null) => email ?? '—' },
{ title: 'ФИО', dataIndex: 'full_name' },
{ title: 'Филиал', key: 'office', render: (_, user) => user.regional_office.name },
{
title: 'Роли',
key: 'roles',
width: 360,
render: (_, user) => (
<Select
mode='multiple'
style={{ width: '100%' }}
placeholder='Без ролей'
aria-label={`Роли пользователя ${user.email ?? user.full_name}`}
value={user.roles}
loading={savingUserId === user.id}
disabled={busy || roles.data === null}
// в строке — коды ролей (коротко), в выпадающем списке — код и название
options={roleList.map((r) => ({ value: r.code, label: r.code, title: r.name, disabled: !r.is_active }))}
optionRender={(option) => `${option.data.label} — ${option.data.title}`}
onChange={(codes: string[]) => void changeRoles(user, codes)}
/>
),
},
{
title: 'Активен',
key: 'is_active',
width: 100,
render: (_, user) => (
<Switch
checked={user.is_active}
loading={savingUserId === user.id}
disabled={busy}
aria-label={`Активен: ${user.email ?? user.full_name}`}
onChange={(checked) => void changeActive(user, checked)}
/>
),
},
];
return (
<>
<Flex justify='space-between' align='center' style={{ marginBottom: 12 }}>
<Typography.Text type='secondary'>
{page && `Всего пользователей: ${page.total}`}
{page && page.total > page.items.length && ` (показаны первые ${page.items.length})`}
</Typography.Text>
<Button type='primary' icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} disabled={roles.data === null}>
Создать пользователя
</Button>
</Flex>
<Table<AdminUser>
rowKey='id'
size='small'
columns={columns}
dataSource={page?.items ?? []}
loading={users.loading}
pagination={false}
/>
<CreateUserModal
open={createOpen}
roles={roleList}
offices={offices.data ?? []}
onClose={() => setCreateOpen(false)}
onCreated={onCreated}
/>
</>
);
}