Files
auth-portal/apps/web/src/components/reui-kit/admin-users-grid.tsx
T
DenozordecandCursor cc38bf06f8
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m57s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
feat(admin): журналы входов и изменений с IP/UA и сессиями
Разделены экраны «Входы» и «Изменения»; логин пишет IP/UA и last_login_ip;
SSO handoff и revoke refresh-сессий; улучшены audit-карточки.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 03:12:39 +07:00

1035 lines
31 KiB
TypeScript

/**
* Admin users grid — DNA solution-users-1 (members-grid, columns, bulk-action-bar).
* Preview: https://reui.io/preview/base/solution-users-1
* Docs: https://reui.io/docs/components/base/filters · https://reui.io/docs/components/base/data-grid
*/
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { AdminUser, AppId } from '@authportal/shared'
import { APPS } from '@authportal/shared'
import {
CircleDotIcon,
FilterIcon,
FunnelXIcon,
LockIcon,
MailIcon,
MoreHorizontalIcon,
PauseCircleIcon,
PencilIcon,
ScrollTextIcon,
Settings2Icon,
ShieldCheckIcon,
Trash2Icon,
TriangleAlertIcon,
UserIcon,
UserPlusIcon,
} from 'lucide-react'
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type ColumnDef,
type PaginationState,
type Row,
type RowSelectionState,
type SortingState,
type VisibilityState,
useReactTable,
} from '@tanstack/react-table'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
DataGridTable,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import {
Avatar,
AvatarFallback,
} from '@authportal/ui/components/avatar'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Separator } from '@authportal/ui/components/separator'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
const APP_TITLE: Record<AppId, string> = Object.fromEntries(
APPS.map((app) => [app.id, app.title]),
) as Record<AppId, string>
const BULK_ROLE_OPTIONS = [
{ value: 'admin', label: 'Админ' },
{ value: 'user', label: 'Пользователь' },
] as const
type BulkRole = (typeof BULK_ROLE_OPTIONS)[number]['value']
export interface AdminUsersGridProps {
users: AdminUser[]
isLoading?: boolean
onCreateClick: () => void
onOpenAudit: (user: AdminUser) => void
onOpenAccess: (user: AdminUser) => void
onDeactivate: (user: AdminUser) => void
onDelete: (user: AdminUser) => void
onBulkSetRole: (userIds: string[], isAdmin: boolean) => void
onBulkDeactivate: (userIds: string[]) => void
}
function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (
values.every((value) => typeof value === 'string' && value.trim() === '')
)
return false
if (values.every((value) => value === null || value === undefined))
return false
if (values.every((value) => Array.isArray(value) && value.length === 0))
return false
return true
})
}
function serializeActiveFiltersKey(active: Filter[]) {
return JSON.stringify(
active.map((f) => ({
field: f.field,
operator: f.operator,
values: f.values,
})),
)
}
function filterFieldValue(item: AdminUser, field: string): unknown {
if (field === 'name') return item.name
if (field === 'email') return item.email
if (field === 'role') return item.is_admin ? 'admin' : 'user'
if (field === 'status') return item.disabled ? 'disabled' : 'active'
if (field === 'apps') return item.apps.join(' ')
return undefined
}
function applyFiltersToUsers(data: AdminUser[], filters: Filter[]): AdminUser[] {
const active = getActiveFilters(filters)
let result = [...data]
for (const filter of active) {
const { field, operator, values } = filter
result = result.filter((item) => {
if (field === 'apps') {
const selected = values.map(String)
switch (operator) {
case 'is':
return (
selected.length > 0 && item.apps.includes(selected[0] as AppId)
)
case 'is_any_of':
return selected.some((v) => item.apps.includes(v as AppId))
case 'is_not_any_of':
return !selected.some((v) => item.apps.includes(v as AppId))
case 'contains': {
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
item.apps.some((appId) =>
APP_TITLE[appId].toLowerCase().includes(token.toLowerCase()),
),
)
}
default:
return true
}
}
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? raw : ''
switch (operator) {
case 'is':
return values.includes(fieldValue)
case 'is_not':
return !values.includes(fieldValue)
case 'is_any_of':
return values.some((v) => fieldValue === v)
case 'is_not_any_of':
return !values.some((v) => fieldValue === v)
case 'contains': {
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
)
}
case 'not_contains':
return !values.some((v) =>
String(fieldValue).toLowerCase().includes(String(v).toLowerCase()),
)
case 'starts_with':
return values.some((v) =>
String(fieldValue)
.toLowerCase()
.startsWith(String(v).toLowerCase()),
)
case 'ends_with':
return values.some((v) =>
String(fieldValue).toLowerCase().endsWith(String(v).toLowerCase()),
)
case 'empty':
return fieldValue === '' || fieldValue == null
case 'not_empty':
return fieldValue !== '' && fieldValue != null
default:
return true
}
})
}
return result
}
function createDefaultFilters(): Filter[] {
return [createFilter('name', 'contains', [''])]
}
function userInitials(name: string, email: string) {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return (name || email).slice(0, 2).toUpperCase()
}
function formatLastActive(iso: string | null) {
if (!iso) return '—'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано`
return null
}
function DotSeparator() {
return (
<span
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
)
}
function RoleBadge({ isAdmin }: { isAdmin: boolean }) {
return isAdmin ? (
<Badge variant="info-outline">Админ</Badge>
) : (
<Badge variant="secondary">Пользователь</Badge>
)
}
function StatusBadge({ disabled }: { disabled: boolean }) {
return disabled ? (
<Badge variant="outline">
<span className="bg-muted-foreground size-1.5 shrink-0 rounded-full!" />
Отключён
</Badge>
) : (
<Badge variant="outline">
<span className="bg-success size-1.5 shrink-0 rounded-full!" />
Активен
</Badge>
)
}
const AppTags = memo(function AppTags({ apps }: { apps: AppId[] }) {
if (apps.length === 0) {
return <span className="text-muted-foreground text-sm"></span>
}
return (
<div className="flex flex-wrap items-center gap-1">
{apps.map((appId) => (
<Badge key={appId} variant="secondary" className="border-0">
{appId.toUpperCase()}
</Badge>
))}
</div>
)
})
const MemberCell = memo(function MemberCell({ row }: { row: Row<AdminUser> }) {
const user = row.original
return (
<div className="flex min-w-0 items-center gap-2">
<Avatar className="size-8 shrink-0">
<AvatarFallback className="text-xs">
{userInitials(user.name, user.email)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="text-foreground line-clamp-1 font-medium">
{user.name}
</div>
<div
className="text-muted-foreground line-clamp-1 text-xs"
title={user.email}
>
{user.email}
</div>
</div>
</div>
)
})
function AdminUsersBulkActionBar({
selectedCount,
roleValue,
onRoleChange,
onChangeRole,
onDeactivate,
onClear,
}: {
selectedCount: number
roleValue: BulkRole
onRoleChange: (value: BulkRole) => void
onChangeRole: () => void
onDeactivate: () => void
onClear: () => void
}) {
return (
<div className="bg-muted/25 flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py) lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">
Выбрано: {selectedCount}
</span>
<span className="text-muted-foreground text-xs">
Массовое изменение роли или отключение доступа.
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
value={roleValue}
onValueChange={(value) => {
if (!value) return
onRoleChange(value as BulkRole)
}}
items={BULK_ROLE_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
}))}
>
<SelectTrigger size="sm" className="w-[164px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{BULK_ROLE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button type="button" size="sm" variant="outline" onClick={onChangeRole}>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Сменить роль
</Button>
<Button type="button" size="sm" onClick={onDeactivate}>
<PauseCircleIcon data-icon="inline-start" aria-hidden="true" />
Отключить
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onClear}>
Сбросить
</Button>
</div>
</div>
)
}
function ActionsCell({
row,
onOpenAudit,
onOpenAccess,
onDeactivate,
onDelete,
}: {
row: Row<AdminUser>
onOpenAudit: (user: AdminUser) => void
onOpenAccess: (user: AdminUser) => void
onDeactivate: (user: AdminUser) => void
onDelete: (user: AdminUser) => void
}) {
const [deleteOpen, setDeleteOpen] = useState(false)
const user = row.original
return (
<div onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label="Действия"
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onOpenAudit(user)}>
<ScrollTextIcon className="size-4" aria-hidden="true" />
Журнал
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onOpenAccess(user)}>
<ShieldCheckIcon className="size-4" aria-hidden="true" />
Права
</DropdownMenuItem>
{!user.disabled ? (
<DropdownMenuItem onClick={() => onDeactivate(user)}>
<PauseCircleIcon className="size-4" aria-hidden="true" />
Отключить
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2Icon className="size-4" aria-hidden="true" />
Удалить
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
Учётная запись{' '}
<span className="text-foreground font-medium">{user.email}</span>{' '}
будет удалена без возможности восстановления.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setDeleteOpen(false)
onDelete(user)
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
function createAdminUserColumns(handlers: {
onOpenAudit: (user: AdminUser) => void
onOpenAccess: (user: AdminUser) => void
onDeactivate: (user: AdminUser) => void
onDelete: (user: AdminUser) => void
}): ColumnDef<AdminUser>[] {
return [
{
id: 'select',
header: () => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelectAll />
</div>
),
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelect row={row} />
</div>
),
enableSorting: false,
size: 40,
enableResizing: false,
enableHiding: false,
meta: {
skeleton: <Skeleton className="mx-auto size-5" />,
headerClassName:
'[--data-grid-header-cell-ps:var(--frame-panel-header-px)]',
cellClassName: '[--data-grid-body-cell-ps:var(--frame-panel-px)]',
},
},
{
accessorKey: 'name',
id: 'name',
header: ({ column }) => (
<DataGridColumnHeader title="Участник" visibility column={column} />
),
cell: ({ row }) => <MemberCell row={row} />,
enableSorting: true,
enableHiding: false,
enableResizing: true,
minSize: 220,
meta: {
headerTitle: 'Участник',
autoSize: true,
skeleton: (
<div className="flex min-w-0 items-center gap-2">
<Skeleton className="size-8 shrink-0 rounded-full" />
<div className="flex min-w-0 flex-col gap-0.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-40" />
</div>
</div>
),
},
},
{
id: 'role',
accessorFn: (row) => (row.is_admin ? 'admin' : 'user'),
header: ({ column }) => (
<DataGridColumnHeader title="Роль" visibility column={column} />
),
cell: ({ row }) => <RoleBadge isAdmin={row.original.is_admin} />,
size: 130,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Роль',
skeleton: <Skeleton className="h-6 w-20 rounded-full" />,
},
},
{
id: 'apps',
accessorFn: (row) => row.apps.length,
header: ({ column }) => (
<DataGridColumnHeader title="Apps" visibility column={column} />
),
cell: ({ row }) => <AppTags apps={row.original.apps} />,
size: 160,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Apps',
skeleton: (
<div className="flex flex-wrap items-center gap-1">
<Skeleton className="h-5 w-12 rounded-full" />
<Skeleton className="h-5 w-12 rounded-full" />
</div>
),
},
},
{
id: 'status',
accessorFn: (row) => (row.disabled ? 'disabled' : 'active'),
header: ({ column }) => (
<DataGridColumnHeader title="Статус" visibility column={column} />
),
cell: ({ row }) => <StatusBadge disabled={row.original.disabled} />,
size: 130,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Статус',
skeleton: <Skeleton className="h-6 w-24 rounded-full" />,
},
},
{
id: 'auth',
header: ({ column }) => (
<DataGridColumnHeader title="Auth" visibility column={column} />
),
cell: () => (
<Badge variant="warning-outline">
<LockIcon aria-hidden="true" />
Локальный
</Badge>
),
size: 120,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Auth',
skeleton: <Skeleton className="h-5 w-20 rounded-full" />,
},
},
{
id: 'twoFactor',
header: ({ column }) => (
<DataGridColumnHeader title="2FA" visibility column={column} />
),
cell: () => (
<Badge variant="destructive-outline">
<TriangleAlertIcon aria-hidden="true" />
Выкл
</Badge>
),
size: 100,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: '2FA',
skeleton: <Skeleton className="h-5 w-16 rounded-full" />,
},
},
{
accessorKey: 'last_login_at',
id: 'lastActive',
header: ({ column }) => (
<DataGridColumnHeader
title="Last Active"
visibility
column={column}
/>
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{formatLastActive(row.original.last_login_at)}
</span>
),
size: 150,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Last Active',
skeleton: <Skeleton className="h-4 w-28" />,
},
},
{
accessorKey: 'last_login_ip',
id: 'lastIp',
header: ({ column }) => (
<DataGridColumnHeader title="Последний IP" visibility column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground font-mono text-xs tabular-nums">
{row.original.last_login_ip ?? '—'}
</span>
),
size: 140,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Последний IP',
skeleton: <Skeleton className="h-4 w-24" />,
},
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<ActionsCell
row={row}
onOpenAudit={handlers.onOpenAudit}
onOpenAccess={handlers.onOpenAccess}
onDeactivate={handlers.onDeactivate}
onDelete={handlers.onDelete}
/>
),
size: 60,
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: {
skeleton: <Skeleton className="mx-auto size-7 rounded-md" />,
headerClassName:
'[--data-grid-header-cell-pe:var(--frame-panel-header-px)]',
cellClassName: '[--data-grid-body-cell-pe:var(--frame-panel-px)]',
},
},
]
}
export function AdminUsersGrid({
users,
isLoading = false,
onCreateClick,
onOpenAudit,
onOpenAccess,
onDeactivate,
onDelete,
onBulkSetRole,
onBulkDeactivate,
}: AdminUsersGridProps) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: 'name', desc: false },
])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
const [filteredData, setFilteredData] = useState<AdminUser[]>(users)
const [filterLoading, setFilterLoading] = useState(false)
const isInitialLoad = useRef(true)
const lastAppliedActiveKey = useRef<string>(
serializeActiveFiltersKey(getActiveFilters(createDefaultFilters())),
)
const stats = useMemo(
() => ({
total: users.length,
admins: users.filter((u) => u.is_admin).length,
disabled: users.filter((u) => u.disabled).length,
}),
[users],
)
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'name',
label: 'Участник',
icon: <UserIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-44',
placeholder: 'Поиск…',
},
{
key: 'email',
label: 'Email',
icon: <MailIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'Поиск…',
},
{
key: 'role',
label: 'Роль',
icon: <ShieldCheckIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[160px]',
options: BULK_ROLE_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
})),
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return (
<RoleBadge isAdmin={(values[0] as BulkRole) === 'admin'} />
)
},
},
{
key: 'status',
label: 'Статус',
icon: <CircleDotIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[150px]',
options: [
{ value: 'active', label: 'Активен' },
{ value: 'disabled', label: 'Отключён' },
],
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return (
<StatusBadge disabled={(values[0] as string) === 'disabled'} />
)
},
},
],
[],
)
const applyFilters = useCallback(
(newFilters: Filter[]) => applyFiltersToUsers(users, newFilters),
[users],
)
const simulateAsyncFiltering = useCallback(
async (newFilters: Filter[]) => {
setFilterLoading(true)
await new Promise((resolve) => setTimeout(resolve, 200))
setFilteredData(applyFilters(newFilters))
setFilterLoading(false)
},
[applyFilters],
)
const handleFiltersChange = useCallback(
(newFilters: Filter[]) => {
setFilters(newFilters)
const newActive = getActiveFilters(newFilters)
const nextKey = serializeActiveFiltersKey(newActive)
if (nextKey === lastAppliedActiveKey.current) return
lastAppliedActiveKey.current = nextKey
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
setRowSelection({})
void simulateAsyncFiltering(newFilters)
},
[simulateAsyncFiltering],
)
useEffect(() => {
if (isInitialLoad.current) {
setFilteredData(applyFilters(filters))
isInitialLoad.current = false
return
}
setFilteredData(applyFilters(filters))
lastAppliedActiveKey.current = serializeActiveFiltersKey(
getActiveFilters(filters),
)
}, [users, filters, applyFilters])
const columns = useMemo(
() =>
createAdminUserColumns({
onOpenAudit,
onOpenAccess,
onDeactivate,
onDelete,
}),
[onOpenAudit, onOpenAccess, onDeactivate, onDelete],
)
const [columnOrder, setColumnOrder] = useState<string[]>(
() => columns.map((c) => c.id as string),
)
const table = useReactTable({
columns,
data: filteredData,
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
getRowId: (row) => row.id,
state: {
pagination,
sorting,
columnOrder,
columnVisibility,
rowSelection,
},
enableRowSelection: true,
columnResizeMode: 'onChange',
onColumnOrderChange: setColumnOrder,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const selectedCount = table.getSelectedRowModel().rows.length
const selectedIds = table
.getSelectedRowModel()
.rows.map((row) => row.original.id)
const showClearButton = filters.length > 0
const gridLoading = isLoading || filterLoading
const handleClearSelection = useCallback(() => setRowSelection({}), [])
const handleChangeRole = useCallback(() => {
if (selectedCount === 0) return
onBulkSetRole(selectedIds, bulkRole === 'admin')
setRowSelection({})
}, [bulkRole, onBulkSetRole, selectedCount, selectedIds])
const handleBulkDeactivate = useCallback(() => {
if (selectedCount === 0) return
onBulkDeactivate(selectedIds)
setRowSelection({})
}, [onBulkDeactivate, selectedCount, selectedIds])
const handleClearFilters = useCallback(() => {
const next = createDefaultFilters()
lastAppliedActiveKey.current = serializeActiveFiltersKey(
getActiveFilters(next),
)
setFilters(next)
setRowSelection({})
void simulateAsyncFiltering(next)
}, [simulateAsyncFiltering])
return (
<TooltipProvider delay={200}>
<DataGrid
table={table}
isLoading={gridLoading}
loadingMode="skeleton"
recordCount={filteredData.length}
onRowClick={onOpenAudit}
emptyMessage={
!gridLoading && filteredData.length === 0
? 'Нет пользователей по текущим фильтрам. Сбросьте фильтры или измените условия.'
: undefined
}
tableLayout={{
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
headerSticky: true,
dense: true,
}}
>
<Frame spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle id="page-heading" className="text-balance">
Пользователи
</FrameTitle>
<FrameDescription className="flex items-center gap-1.5 text-xs text-pretty">
<span>{stats.total} учётных записей</span>
<DotSeparator />
<span>{stats.admins} админов</span>
{stats.disabled > 0 ? (
<>
<DotSeparator />
<span>{stats.disabled} отключено</span>
</>
) : null}
</FrameDescription>
</div>
<Button type="button" size="default" onClick={onCreateClick}>
<UserPlusIcon aria-hidden="true" />
Создать
</Button>
</FrameHeader>
<FramePanel className="p-0 shadow-none">
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button
type="button"
size="default"
variant="outline"
aria-label="Фильтры"
>
<FilterIcon aria-hidden />
Filters
</Button>
}
/>
<div className="flex flex-wrap items-center gap-2">
{showClearButton ? (
<Button
type="button"
size="default"
variant="outline"
className="shrink-0"
onClick={handleClearFilters}
disabled={gridLoading}
>
<FunnelXIcon aria-hidden />
Clear
</Button>
) : null}
<DataGridColumnVisibility
table={table}
trigger={
<Button
type="button"
size="default"
variant="outline"
aria-label="Настройки таблицы"
>
<Settings2Icon aria-hidden="true" />
View settings
</Button>
}
/>
</div>
</div>
<Separator />
{selectedCount > 0 ? (
<>
<AdminUsersBulkActionBar
selectedCount={selectedCount}
roleValue={bulkRole}
onRoleChange={setBulkRole}
onChangeRole={handleChangeRole}
onDeactivate={handleBulkDeactivate}
onClear={handleClearSelection}
/>
<Separator />
</>
) : null}
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination sizes={[10, 20, 30]} />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
</TooltipProvider>
)
}