import { App, Button, Checkbox, Flex, Table, type TableColumnsType, Tag, Tooltip, Typography } from 'antd'; import { useRef, useState } from 'react'; import { ApiError, api } from '../../api/client'; import type { Permission, Role } from '../../api/types'; import { useAsyncData, useMountedRef } from './hooks'; type MatrixData = { roles: Role[]; permissions: Permission[] }; // Роли и права нужны вместе: без любой из половин матрицу не построить const loadMatrix = async (): Promise => { const [roles, permissions] = await Promise.all([api.get('/admin/roles'), api.get('/admin/permissions')]); return { roles, permissions }; }; /** * Черновик: новые наборы прав только тех ролей, которые отличаются от сервера (код роли → коды прав). * `Map`, а не объект: у объекта есть «унаследованные» ключи (`constructor`, `toString`), и проверка `code in draft` * могла бы ошибиться на роли с таким кодом. */ type Draft = ReadonlyMap; const EMPTY_DRAFT: Draft = new Map(); function sameSet(a: string[], b: string[]): boolean { return a.length === b.length && a.every((code) => b.includes(code)); } /** * Матрица «право × роль»: строки — права, колонки — роли, в ячейке — галочка. * * Галочки меняют только локальный черновик; «Сохранить» отправляет `PUT /admin/roles/{code}/permissions` для каждой * изменённой роли — по очереди, а не параллельно: проверка «не снят ли последний USERS_ADMIN» на сервере идёт по * текущему состоянию БД, и параллельные запросы могли бы пройти её одновременно. После сохранения (даже частично * неудачного) роли из ответов сервера сразу подставляются в матрицу, черновик сбрасывается и матрица перечитывается: * на экране — то, что реально сохранено, даже если перечитать не удалось. */ export function PermissionMatrix() { const matrix = useAsyncData(loadMatrix); const [draft, setDraft] = useState(EMPTY_DRAFT); const [saving, setSaving] = useState(false); const savingLock = useRef(false); const { message } = App.useApp(); const mounted = useMountedRef(); const roles = matrix.data?.roles ?? []; const permissions = matrix.data?.permissions ?? []; const changedRoles = roles.filter((role) => draft.has(role.code)); const permissionsOf = (role: Role): string[] => draft.get(role.code) ?? role.permissions; const toggle = (role: Role, permissionCode: string, checked: boolean) => { // считаем от предыдущего черновика (prev), а не от отрисованного: два быстрых клика не потеряют первый setDraft((prev) => { const current = prev.get(role.code) ?? role.permissions; const without = current.filter((code) => code !== permissionCode); const next = checked ? [...without, permissionCode] : without; const updated = new Map(prev); // вернули как было на сервере — роль больше не считается изменённой if (sameSet(next, role.permissions)) { updated.delete(role.code); } else { updated.set(role.code, next); } return updated; }); }; const save = async () => { if (savingLock.current || changedRoles.length === 0) { return; } savingLock.current = true; setSaving(true); const failed: string[] = []; // роли из ответов успешных PUT: подставим их в матрицу, не дожидаясь перечитывания const saved = new Map(); for (const role of changedRoles) { try { const permissionCodes = draft.get(role.code) ?? role.permissions; const updated = await api.put(`/admin/roles/${encodeURIComponent(role.code)}/permissions`, { permissions: permissionCodes, }); saved.set(updated.code, updated); } catch (e: unknown) { failed.push(`${role.code}: ${e instanceof ApiError ? e.message : 'не удалось выполнить запрос'}`); } } savingLock.current = false; if (!mounted.current) { return; } if (saved.size > 0) { message.success(`Сохранено ролей: ${saved.size}`); } if (failed.length > 0) { message.error(`Не сохранены: ${failed.join('; ')}`); } // если перечитывание ниже не удастся, в матрице уже будут сохранённые наборы прав, а не прежние matrix.setData((prev) => (prev ? { ...prev, roles: prev.roles.map((r) => saved.get(r.code) ?? r) } : prev)); setDraft(EMPTY_DRAFT); await matrix.reload(); if (mounted.current) { setSaving(false); } }; const roleColumns: TableColumnsType = roles.map((role) => ({ key: role.code, align: 'center', title: ( {role.code} {role.is_system && системная} {!role.is_active && неактивна} {draft.has(role.code) && изменена} ), render: (_, permission) => ( toggle(role, permission.code, e.target.checked)} /> ), })); const columns: TableColumnsType = [ { title: 'Право', key: 'permission', fixed: 'left', width: 260, render: (_, permission) => ( <> {permission.code}
{permission.name}
), }, ...roleColumns, ]; return ( <> {changedRoles.length > 0 && Изменено ролей: {changedRoles.length}} rowKey='code' size='small' columns={columns} dataSource={permissions} loading={matrix.loading} pagination={false} scroll={{ x: 'max-content' }} /> ); }