feat(design-preview): пометки строк «Не сохранено» и «Будет удалено», массовое удаление с подтверждением
Строки с накопленными правками подсвечиваются и получают плашку (getRowStatus из DataGrid v9), удаление применяется при «Сохранить», в каталоге пользователей добавлено «Удалить…» в панели массовых действий. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
41f5669812
commit
e02cfad0b1
7 files changed
+102
-16
No files matched your search
@@ -398,3 +398,12 @@ export function Segmented({
|
||||
</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() {
|
||||
const { addChange } = usePreviewStore()
|
||||
const { addChange, marksFor } = usePreviewStore()
|
||||
const marks = marksFor('app-links')
|
||||
const [rows, setRows] = useState<LinkRow[]>(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}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -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<AdminUser[]>(MOCK_USERS)
|
||||
const usersRef = useRef(users)
|
||||
useEffect(() => {
|
||||
@@ -30,6 +31,7 @@ export function UsersPage() {
|
||||
const [accessFor, setAccessFor] = useState<AdminUser | null>(null)
|
||||
const [deleteFor, setDeleteFor] = useState<AdminUser | null>(null)
|
||||
const [resetFor, setResetFor] = useState<AdminUser | null>(null)
|
||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(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() {
|
||||
<TabsTrigger value="matrix">Доступ по приложениям · {ALL_APP_IDS.length}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="users" className="min-w-0">
|
||||
<UsersGrid users={users} handlers={handlers} />
|
||||
<UsersGrid users={users} handlers={handlers} marks={marks} />
|
||||
</TabsContent>
|
||||
<TabsContent value="matrix" className="min-w-0">
|
||||
<PreviewSectionGrid
|
||||
@@ -201,23 +208,40 @@ export function UsersPage() {
|
||||
onConfirm={() => {
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
|
||||
<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
|
||||
open={resetFor !== null}
|
||||
onOpenChange={(o) => !o && setResetFor(null)}
|
||||
|
||||
@@ -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<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>(
|
||||
() => ({
|
||||
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 (
|
||||
|
||||
@@ -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<string, RowMark>
|
||||
/** Выполняется при «Сохранить»: окончательно применяет правку (например, убирает удалённую строку). */
|
||||
commit?: () => void
|
||||
/** Строки diff: «+ …», «− …», « … ». */
|
||||
lines: string[]
|
||||
/** Откатывает локальное состояние страницы при «Отменить». */
|
||||
@@ -17,6 +25,8 @@ export type Contrast = 'standard' | 'high'
|
||||
|
||||
export type PreviewStore = {
|
||||
pending: PendingChange[]
|
||||
/** Пометки строк раздела из накопленных правок. */
|
||||
marksFor: (resource: string) => Record<string, RowMark>
|
||||
addChange: (change: Omit<PendingChange, 'id'>) => void
|
||||
discardAll: () => void
|
||||
applyAll: () => void
|
||||
|
||||
@@ -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<T> = {
|
||||
@@ -66,6 +67,7 @@ export function PreviewSectionGrid<T extends object>({
|
||||
bulkActions,
|
||||
toolbar,
|
||||
onReorder,
|
||||
rowStatus,
|
||||
pageSize = 8,
|
||||
emptyMessage = 'Нет записей по текущим фильтрам.',
|
||||
className,
|
||||
@@ -84,6 +86,8 @@ export function PreviewSectionGrid<T extends object>({
|
||||
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<T extends object>({
|
||||
i18n={DATA_GRID_RU}
|
||||
recordCount={data.length}
|
||||
onRowClick={onRowClick}
|
||||
getRowStatus={rowStatus}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={{ headerSticky: false, dense: true, width: 'auto', rowsDraggable: Boolean(onReorder) }}
|
||||
>
|
||||
|
||||
@@ -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<string, RowMark> }) {
|
||||
const columns = useMemo<GridColumn<AdminUser>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -59,7 +61,13 @@ export function UsersGrid({ users, handlers }: { users: AdminUser[]; handlers: U
|
||||
sortValue: (u) => u.email,
|
||||
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: '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> },
|
||||
@@ -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) => (
|
||||
<>
|
||||
<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>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => handlers.onBulkDelete(ids)}>
|
||||
Удалить…
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user