Files
auth-portal/apps/web/src/design-preview/access-matrix.tsx
T
DenozordecandClaude Sonnet 5.5 c8351424f4 feat(design-preview): матрица доступа «пользователи × приложения» (solution-users-3)
Переключатели в ячейках, счётчик выданных доступов в заголовке колонки, заблокированные ячейки
администраторов, пометки «Не сохранено». Исправлена вёрстка рейки вкладок профиля.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 02:39:06 +07:00

83 lines
3.4 KiB
TypeScript

import { useMemo } from 'react'
import { LockIcon } from 'lucide-react'
import { APPS, type AdminUser, type AppId } from '@authportal/shared'
import { Switch } from '@authportal/ui/components/switch'
import { PendingChip, SignalAlert } from './bits'
import { APP_SHORT } from './mock'
import type { RowMark } from './preview-store'
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid'
/**
* Матрица доступа (solution-users-3): пользователи в строках, приложения в колонках,
* переключатели в ячейках. Администраторам доступ выдан ко всем приложениям — их ячейки заблокированы.
*/
export function AccessMatrix({
users,
marks,
onToggle,
}: {
users: AdminUser[]
marks: Record<string, RowMark>
onToggle: (user: AdminUser, appId: AppId, on: boolean) => void
}) {
const columns = useMemo<GridColumn<AdminUser>[]>(() => {
const granted = (id: AppId) => users.filter((u) => u.is_admin || u.apps.includes(id)).length
return [
{
id: 'user',
title: 'Пользователь',
size: 260,
sortValue: (u) => u.email,
cell: (u) => (
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<TwoLineCell dot={u.disabled ? 'mu' : 'ok'} title={u.email} sub={u.is_admin ? 'Админ · все приложения' : `${u.apps.length} из ${APPS.length}`} />
{marks[u.id] ? <PendingChip mark={marks[u.id]!} /> : null}
</div>
),
},
...APPS.map<GridColumn<AdminUser>>((a) => ({
id: a.id,
title: `${APP_SHORT[a.id]} · ${granted(a.id)}`,
size: 128,
cell: (u) =>
u.is_admin ? (
<span className="text-muted-foreground flex items-center gap-1.5 text-xs" title="Администратор: доступ ко всем приложениям">
<Switch size="sm" checked disabled aria-label={`${u.email}: ${a.title} (заблокировано)`} />
<LockIcon className="size-3" aria-hidden />
</span>
) : (
<Switch
size="sm"
checked={u.apps.includes(a.id)}
onCheckedChange={(on) => onToggle(u, a.id, on)}
aria-label={`${u.email}: ${a.title}`}
/>
),
})),
]
}, [users, marks, onToggle])
return (
<div className="flex min-w-0 flex-col gap-3">
<SignalAlert tone="in" title="Администраторы видят все приложения">
Их ячейки заблокированы. Тонкую настройку разделов откройте в правах пользователя.
</SignalAlert>
<PreviewSectionGrid
title="Матрица доступа"
description="В заголовке колонки — сколько пользователей имеют доступ к приложению"
rows={users}
columns={columns}
getRowId={(u) => u.id}
rowStatus={(u) => marks[u.id]}
searchText={(u) => u.email}
searchPlaceholder="Email"
views={[
{ label: 'Все', match: () => true },
{ label: 'Пользователи', match: (u) => !u.is_admin },
{ label: 'Админы', match: (u) => u.is_admin },
]}
/>
</div>
)
}