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>(`/admin/users?limit=${USERS_LIMIT}`); const loadRoles = () => api.get('/admin/roles'); const loadOffices = () => api.get('/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(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) => { 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(`/admin/users/${user.id}/roles`, { roles: codes })); const changeActive = (user: AdminUser, isActive: boolean) => saveRow(user.id, () => api.patch(`/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 = [ { 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) => (