diff --git a/web/src/widgets/Admin/CreateUserModal.tsx b/web/src/widgets/Admin/CreateUserModal.tsx index 6976d77..6db7af2 100644 --- a/web/src/widgets/Admin/CreateUserModal.tsx +++ b/web/src/widgets/Admin/CreateUserModal.tsx @@ -1,5 +1,5 @@ import { Form, Input, Modal, Select } from 'antd'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { ApiError, api } from '../../api/client'; import type { AdminUser, BusinessSegment, RegionalOffice, Role, UserCreate } from '../../api/types'; import { useMountedRef, useNotifyError } from './hooks'; @@ -39,8 +39,15 @@ export function CreateUserModal({ open, roles, offices, onClose, onCreated }: Pr const [submitting, setSubmitting] = useState(false); const notifyError = useNotifyError(); const mounted = useMountedRef(); + // Замок от двойного клика: onFinish вызывается после асинхронной проверки полей, и второй клик успел бы отправить + // второй POST до того, как `submitting` отключит кнопку; ref меняется сразу + const submitLock = useRef(false); const submit = async (values: FormValues) => { + if (submitLock.current) { + return; + } + submitLock.current = true; setSubmitting(true); const body: UserCreate = { email: values.email.trim(), @@ -65,6 +72,7 @@ export function CreateUserModal({ open, roles, offices, onClose, onCreated }: Pr notifyError(e); } } finally { + submitLock.current = false; if (mounted.current) { setSubmitting(false); } diff --git a/web/src/widgets/Admin/PermissionMatrix.tsx b/web/src/widgets/Admin/PermissionMatrix.tsx index f77eb36..9faaa4e 100644 --- a/web/src/widgets/Admin/PermissionMatrix.tsx +++ b/web/src/widgets/Admin/PermissionMatrix.tsx @@ -12,8 +12,14 @@ const loadMatrix = async (): Promise => { return { roles, permissions }; }; -/** Черновик: новые наборы прав только тех ролей, которые отличаются от сервера (код роли → коды прав). */ -type Draft = Record; +/** + * Черновик: новые наборы прав только тех ролей, которые отличаются от сервера (код роли → коды прав). + * `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)); @@ -25,11 +31,12 @@ function sameSet(a: string[], b: string[]): boolean { * Галочки меняют только локальный черновик; «Сохранить» отправляет `PUT /admin/roles/{code}/permissions` для каждой * изменённой роли — по очереди, а не параллельно: проверка «не снят ли последний USERS_ADMIN» на сервере идёт по * текущему состоянию БД, и параллельные запросы могли бы пройти её одновременно. После сохранения (даже частично - * неудачного) черновик сбрасывается и матрица перечитывается: на экране — то, что реально сохранено. + * неудачного) роли из ответов сервера сразу подставляются в матрицу, черновик сбрасывается и матрица перечитывается: + * на экране — то, что реально сохранено, даже если перечитать не удалось. */ export function PermissionMatrix() { const matrix = useAsyncData(loadMatrix); - const [draft, setDraft] = useState({}); + const [draft, setDraft] = useState(EMPTY_DRAFT); const [saving, setSaving] = useState(false); const savingLock = useRef(false); const { message } = App.useApp(); @@ -37,22 +44,22 @@ export function PermissionMatrix() { const roles = matrix.data?.roles ?? []; const permissions = matrix.data?.permissions ?? []; - const changedRoles = roles.filter((role) => role.code in draft); + const changedRoles = roles.filter((role) => draft.has(role.code)); - const permissionsOf = (role: Role): string[] => draft[role.code] ?? role.permissions; + 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[role.code] ?? role.permissions; + const current = prev.get(role.code) ?? role.permissions; const without = current.filter((code) => code !== permissionCode); const next = checked ? [...without, permissionCode] : without; - const updated = { ...prev }; + const updated = new Map(prev); // вернули как было на сервере — роль больше не считается изменённой if (sameSet(next, role.permissions)) { - delete updated[role.code]; + updated.delete(role.code); } else { - updated[role.code] = next; + updated.set(role.code, next); } return updated; }); @@ -65,9 +72,15 @@ export function PermissionMatrix() { savingLock.current = true; setSaving(true); const failed: string[] = []; + // роли из ответов успешных PUT: подставим их в матрицу, не дожидаясь перечитывания + const saved = new Map(); for (const role of changedRoles) { try { - await api.put(`/admin/roles/${encodeURIComponent(role.code)}/permissions`, { permissions: draft[role.code] }); + 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 : 'не удалось выполнить запрос'}`); } @@ -76,14 +89,15 @@ export function PermissionMatrix() { if (!mounted.current) { return; } - const savedCount = changedRoles.length - failed.length; - if (savedCount > 0) { - message.success(`Сохранено ролей: ${savedCount}`); + if (saved.size > 0) { + message.success(`Сохранено ролей: ${saved.size}`); } if (failed.length > 0) { message.error(`Не сохранены: ${failed.join('; ')}`); } - setDraft({}); + // если перечитывание ниже не удастся, в матрице уже будут сохранённые наборы прав, а не прежние + 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); @@ -99,7 +113,7 @@ export function PermissionMatrix() { {role.code} {role.is_system && системная} {!role.is_active && неактивна} - {role.code in draft && изменена} + {draft.has(role.code) && изменена} ), @@ -135,7 +149,7 @@ export function PermissionMatrix() { - {changedRoles.length > 0 && Изменено ролей: {changedRoles.length}} diff --git a/web/src/widgets/Admin/UsersTable.tsx b/web/src/widgets/Admin/UsersTable.tsx index 7f96438..01a4464 100644 --- a/web/src/widgets/Admin/UsersTable.tsx +++ b/web/src/widgets/Admin/UsersTable.tsx @@ -93,8 +93,15 @@ export function UsersTable() { 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 }))} + // в строке — коды ролей (коротко), в выпадающем списке — код и название. Неактивную роль нельзя назначить + // заново (сервер ответит 422 UNKNOWN_ROLE), но уже назначенную — можно снять: у disabled-опции rc-select не + // рисует крестик на теге, поэтому disabled только для тех неактивных, которых у пользователя нет + options={roleList.map((r) => ({ + value: r.code, + label: r.code, + title: r.name, + disabled: !r.is_active && !user.roles.includes(r.code), + }))} optionRender={(option) => `${option.data.label} — ${option.data.title}`} onChange={(codes: string[]) => void changeRoles(user, codes)} /> diff --git a/web/src/widgets/Admin/hooks.ts b/web/src/widgets/Admin/hooks.ts index 3e47ba9..6cdce77 100644 --- a/web/src/widgets/Admin/hooks.ts +++ b/web/src/widgets/Admin/hooks.ts @@ -37,7 +37,10 @@ export function useMountedRef(): { readonly current: boolean } { } type AsyncData = { - /** Последние данные с сервера; `null` — ещё не загружены или загрузка не удалась. */ + /** + * Последние успешно полученные данные. `null` — первая загрузка ещё идёт или не удалась; неудачный `reload()` + * оставляет прежние данные (ошибка показывается уведомлением). + */ data: T | null; /** Замена данных без запроса (например, строка из ответа `PUT`). */ setData: Dispatch>; diff --git a/web/src/widgets/Sidebar/Sidebar.tsx b/web/src/widgets/Sidebar/Sidebar.tsx index e4b4063..95e6afa 100644 --- a/web/src/widgets/Sidebar/Sidebar.tsx +++ b/web/src/widgets/Sidebar/Sidebar.tsx @@ -1,6 +1,6 @@ import { HomeOutlined, LeftOutlined, RightOutlined, TeamOutlined } from '@ant-design/icons'; import { Divider, Layout, Tooltip } from 'antd'; -import { type ReactNode, memo, useState } from 'react'; +import { type ReactNode, memo, useMemo, useState } from 'react'; import { NavLink } from 'react-router-dom'; import { useCurrentUser } from '../../auth/MeContext'; import { USERS_ADMIN } from '../../auth/permissions'; @@ -83,6 +83,8 @@ const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boo export function Sidebar() { const [isCollapsed, setIsCollapsed] = useState(false); const me = useCurrentUser(); + // useMemo: группы пересчитываются только при смене прав, иначе memo(Section) получал бы новый объект на каждой отрисовке + const groups = useMemo(() => visibleGroups(me.permissions), [me.permissions]); return ( : } - {visibleGroups(me.permissions).map((g) => ( + {groups.map((g) => (
))}