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) => (
<>