feat(design-preview): пометки строк «Не сохранено» и «Будет удалено», массовое удаление с подтверждением

Строки с накопленными правками подсвечиваются и получают плашку (getRowStatus из DataGrid v9),
удаление применяется при «Сохранить», в каталоге пользователей добавлено «Удалить…» в панели
массовых действий.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 02:26:37 +07:00
1 parent 41f5669812
commit e02cfad0b1
7 files changed
+102 -16

No files matched your search

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