Маршрут /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
148 lines
6.3 KiB
TypeScript
148 lines
6.3 KiB
TypeScript
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}
|
||
/>
|
||
</>
|
||
);
|
||
}
|