diff --git a/apps/web/src/design-preview/bits.tsx b/apps/web/src/design-preview/bits.tsx index 14ef41f..fb29db1 100644 --- a/apps/web/src/design-preview/bits.tsx +++ b/apps/web/src/design-preview/bits.tsx @@ -398,3 +398,12 @@ export function Segmented({ ) } + +/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */ +export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) { + return ( + + {mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'} + + ) +} diff --git a/apps/web/src/design-preview/pages/app-links-page.tsx b/apps/web/src/design-preview/pages/app-links-page.tsx index 11c68e7..468eaee 100644 --- a/apps/web/src/design-preview/pages/app-links-page.tsx +++ b/apps/web/src/design-preview/pages/app-links-page.tsx @@ -92,7 +92,8 @@ function SettingRow({ title, description, children }: { title: string; descripti } export function AppLinksPage() { - const { addChange } = usePreviewStore() + const { addChange, marksFor } = usePreviewStore() + const marks = marksFor('app-links') const [rows, setRows] = useState(INITIAL) const rowsRef = useRef(rows) useEffect(() => { @@ -113,6 +114,8 @@ export function AppLinksPage() { kind: 'chg', title: enabled ? 'Показать в меню' : 'Скрыть из меню', lines: names.map((n) => `${enabled ? '+' : '−'} ${n}`), + resource: 'app-links', + marks: Object.fromEntries(ids.map((i) => [i, 'dirty' as const])), revert: () => setRows(before), }) }, @@ -134,6 +137,8 @@ export function AppLinksPage() { kind: 'chg', title: 'Порядок изменён', lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`], + resource: 'app-links', + marks: { [item!.id]: 'dirty' }, revert: () => setRows(before), }) }, @@ -157,6 +162,8 @@ export function AppLinksPage() { kind: 'chg', title: 'Порядок сброшен', lines: ['− пользовательский порядок', '+ порядок каталога'], + resource: 'app-links', + marks: Object.fromEntries(before.map((r) => [r.id, 'dirty' as const])), revert: () => setRows(before), }) }, [addChange]) @@ -304,6 +311,7 @@ export function AppLinksPage() { )} onReorder={dropOn} + rowStatus={(r) => marks[r.id]} onRowClick={setEditing} /> diff --git a/apps/web/src/design-preview/pages/users-page.tsx b/apps/web/src/design-preview/pages/users-page.tsx index 4546f29..aaa1fad 100644 --- a/apps/web/src/design-preview/pages/users-page.tsx +++ b/apps/web/src/design-preview/pages/users-page.tsx @@ -18,7 +18,8 @@ import { UsersGrid, type UsersGridHandlers } from '../users-grid' import { usePreviewStore } from '../preview-store' export function UsersPage() { - const { addChange } = usePreviewStore() + const { addChange, marksFor } = usePreviewStore() + const marks = marksFor('users') const [users, setUsers] = useState(MOCK_USERS) const usersRef = useRef(users) useEffect(() => { @@ -30,6 +31,7 @@ export function UsersPage() { const [accessFor, setAccessFor] = useState(null) const [deleteFor, setDeleteFor] = useState(null) const [resetFor, setResetFor] = useState(null) + const [bulkDeleteIds, setBulkDeleteIds] = useState(null) /** Меняет поля у выбранных пользователей и кладёт изменение в «Не сохранено». */ const patch = useCallback( @@ -42,6 +44,8 @@ export function UsersPage() { kind: 'chg', title, lines, + resource: 'users', + marks: Object.fromEntries(before.map((b) => [b.id, 'dirty' as const])), revert: () => setUsers((prev) => prev.map((u) => before.find((b) => b.id === u.id) ?? u)), }) }, @@ -78,6 +82,8 @@ export function UsersPage() { kind: 'add', title: `Создан ${created.email}`, lines: [`+ email: ${created.email}`, `+ name: ${created.name}`, `+ is_admin: ${String(created.is_admin)}`], + resource: 'users', + marks: { [created.id]: 'new' }, revert: () => setUsers((prev) => prev.filter((u) => u.id !== created.id)), }) setCreateOpen(false) @@ -97,6 +103,7 @@ export function UsersPage() { onToggleDisabled: onDeactivate, onResetPasskeys, onDelete, + onBulkDelete: (ids) => setBulkDeleteIds(ids), onBulkDisabled: (ids, disabled) => patch(ids, (u) => ({ ...u, disabled }), `${disabled ? 'Отключено' : 'Включено'} пользователей: ${ids.length}`, () => [ `− disabled: ${String(!disabled)}`, @@ -142,7 +149,7 @@ export function UsersPage() { Доступ по приложениям · {ALL_APP_IDS.length} - + { const target = deleteFor if (!target) return - const index = usersRef.current.findIndex((u) => u.id === target.id) - setUsers((prev) => prev.filter((u) => u.id !== target.id)) addChange({ kind: 'del', title: `Удалён ${target.email}`, lines: [`− email: ${target.email}`, `− apps: ${target.apps.length}`], - revert: () => - setUsers((prev) => { - const copy = [...prev] - copy.splice(Math.min(index, copy.length), 0, target) - return copy - }), + resource: 'users', + marks: { [target.id]: 'deleted' }, + commit: () => setUsers((prev) => prev.filter((u) => u.id !== target.id)), }) setDeleteFor(null) }} /> + !o && setBulkDeleteIds(null)} + title="Удалить выбранных пользователей?" + description={`Будут удалены учётные записи: ${bulkDeleteIds?.length ?? 0}. Действие попадёт в панель «Не сохранено» и в журнал.`} + expected="удалить" + confirmLabel="Удалить" + onConfirm={() => { + const ids = bulkDeleteIds ?? [] + const targets = usersRef.current.filter((u) => ids.includes(u.id)) + addChange({ + kind: 'del', + title: `Удалено пользователей: ${targets.length}`, + lines: targets.map((t) => `− ${t.email}`), + resource: 'users', + marks: Object.fromEntries(targets.map((t) => [t.id, 'deleted' as const])), + commit: () => setUsers((prev) => prev.filter((u) => !ids.includes(u.id))), + }) + setBulkDeleteIds(null) + }} + /> + !o && setResetFor(null)} diff --git a/apps/web/src/design-preview/preview-provider.tsx b/apps/web/src/design-preview/preview-provider.tsx index b86407e..e31d472 100644 --- a/apps/web/src/design-preview/preview-provider.tsx +++ b/apps/web/src/design-preview/preview-provider.tsx @@ -5,6 +5,7 @@ import { type Contrast, type Density, type PendingChange, + type RowMark, type PreviewStore, } from './preview-store' @@ -77,15 +78,32 @@ export function PreviewProvider({ children }: { children: ReactNode }) { const applyAll = useCallback(() => { const count = pendingRef.current.length + for (const c of pendingRef.current) c.commit?.() setPending([]) toast.success(`Сохранено изменений: ${count}`, { description: 'Записано в портал и журнал «Изменения» (макет: ничего не отправляется).', }) }, []) + const marksFor = useCallback( + (resource: string) => { + const out: Record = {} + for (const c of pending) { + if (c.resource !== resource || !c.marks) continue + for (const [id, mark] of Object.entries(c.marks)) { + // «Будет удалено» сильнее остальных пометок. + if (out[id] !== 'deleted') out[id] = mark + } + } + return out + }, + [pending], + ) + const value = useMemo( () => ({ pending, + marksFor, addChange, discardAll, applyAll, @@ -98,7 +116,7 @@ export function PreviewProvider({ children }: { children: ReactNode }) { contrast, setContrast, }), - [pending, addChange, discardAll, applyAll, paletteOpen, pins, togglePin, density, contrast], + [pending, marksFor, addChange, discardAll, applyAll, paletteOpen, pins, togglePin, density, contrast], ) return ( diff --git a/apps/web/src/design-preview/preview-store.ts b/apps/web/src/design-preview/preview-store.ts index 26adb84..8d2063d 100644 --- a/apps/web/src/design-preview/preview-store.ts +++ b/apps/web/src/design-preview/preview-store.ts @@ -2,10 +2,18 @@ import { createContext, useContext } from 'react' export type ChangeKind = 'add' | 'chg' | 'del' +export type RowMark = 'new' | 'dirty' | 'deleted' + export type PendingChange = { id: string kind: ChangeKind title: string + /** Раздел, к которому относятся пометки строк («users», «app-links»). */ + resource?: string + /** Пометки строк: «Не сохранено» (dirty/new) и «Будет удалено» (deleted). */ + marks?: Record + /** Выполняется при «Сохранить»: окончательно применяет правку (например, убирает удалённую строку). */ + commit?: () => void /** Строки diff: «+ …», «− …», « … ». */ lines: string[] /** Откатывает локальное состояние страницы при «Отменить». */ @@ -17,6 +25,8 @@ export type Contrast = 'standard' | 'high' export type PreviewStore = { pending: PendingChange[] + /** Пометки строк раздела из накопленных правок. */ + marksFor: (resource: string) => Record addChange: (change: Omit) => void discardAll: () => void applyAll: () => void diff --git a/apps/web/src/design-preview/section-grid.tsx b/apps/web/src/design-preview/section-grid.tsx index 18d7e24..efb6559 100644 --- a/apps/web/src/design-preview/section-grid.tsx +++ b/apps/web/src/design-preview/section-grid.tsx @@ -30,6 +30,7 @@ import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/com import { cn } from '@authportal/ui/lib/utils' import { Segmented } from './bits' import type { Tone } from './mock' +import type { RowMark } from './preview-store' import { TONE_DOT } from './tone' export type GridColumn = { @@ -66,6 +67,7 @@ export function PreviewSectionGrid({ bulkActions, toolbar, onReorder, + rowStatus, pageSize = 8, emptyMessage = 'Нет записей по текущим фильтрам.', className, @@ -84,6 +86,8 @@ export function PreviewSectionGrid({ toolbar?: ReactNode /** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */ onReorder?: (activeId: string, overId: string) => void + /** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */ + rowStatus?: (row: T) => RowMark | undefined pageSize?: number emptyMessage?: string className?: string @@ -193,6 +197,7 @@ export function PreviewSectionGrid({ i18n={DATA_GRID_RU} recordCount={data.length} onRowClick={onRowClick} + getRowStatus={rowStatus} emptyMessage={emptyMessage} tableLayout={{ headerSticky: false, dense: true, width: 'auto', rowsDraggable: Boolean(onReorder) }} > diff --git a/apps/web/src/design-preview/users-grid.tsx b/apps/web/src/design-preview/users-grid.tsx index 8eb717e..abcc677 100644 --- a/apps/web/src/design-preview/users-grid.tsx +++ b/apps/web/src/design-preview/users-grid.tsx @@ -17,7 +17,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@authportal/ui/components/dropdown-menu' -import { StatusBadge } from './bits' +import { PendingChip, StatusBadge } from './bits' +import type { RowMark } from './preview-store' import { ALL_APP_IDS, APP_SHORT, USER_META, type Tone } from './mock' import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid' @@ -46,10 +47,11 @@ export type UsersGridHandlers = { onResetPasskeys: (u: AdminUser) => void onDelete: (u: AdminUser) => void onBulkDisabled: (ids: string[], disabled: boolean) => void + onBulkDelete: (ids: string[]) => void } /** Каталог пользователей по канвасу: участник, статус, роль, приложения, passkey, активность. */ -export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: UsersGridHandlers }) { +export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; handlers: UsersGridHandlers; marks: Record }) { const columns = useMemo[]>( () => [ { @@ -59,7 +61,13 @@ export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: U sortValue: (u) => u.email, cell: (u) => , }, - { id: 'status', title: 'Статус', size: 140, cell: (u) => {userStatus(u).label} }, + { id: 'status', title: 'Статус', size: 140, cell: (u) => ( +
+ {userStatus(u).label} + {marks[u.id] ? : null} +
+ ), + }, { id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => {u.is_admin ? 'Админ' : 'Пользователь'} }, { id: 'apps', title: 'Приложения', size: 220, cell: (u) => {appsSummary(u)} }, { id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => {passkeyLabel(u.passkey_count)} }, @@ -104,7 +112,7 @@ export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: U ), }, ], - [handlers], + [handlers, marks], ) return ( @@ -124,6 +132,7 @@ export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: U { label: 'Отключённые', match: (u) => u.disabled }, ]} onRowClick={handlers.onOpenAccess} + rowStatus={(u) => marks[u.id]} bulkActions={(ids, clear) => ( <> + )} />