253 lines
8.3 KiB
TypeScript
253 lines
8.3 KiB
TypeScript
"use client"
|
|
|
|
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
|
|
|
import { useMemo } from "react"
|
|
import {
|
|
type ColumnDef,
|
|
useTable,
|
|
} from "@tanstack/react-table"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Badge } from "@/components/reui/badge"
|
|
import { cn } from "@/lib/utils"
|
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
|
import {
|
|
DATA_GRID_CELL_PAD,
|
|
DATA_GRID_CELL_PAD_FIRST,
|
|
DATA_GRID_CELL_PAD_LAST,
|
|
} from "@/components/data-grids/shared/data-grid-layout"
|
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
|
import { UsersExpandedDetail } from "@/components/data-grids/users-expanded-detail"
|
|
import { EmptyState } from "@/components/empty-state"
|
|
import { ALL_SECTIONS, ROLE_LABEL, type AppUser } from "@/lib/users"
|
|
import {
|
|
CableIcon,
|
|
ChevronDownIcon,
|
|
ChevronRightIcon,
|
|
PencilIcon,
|
|
TrashIcon,
|
|
UsersIcon,
|
|
} from "lucide-react"
|
|
|
|
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
|
|
return (
|
|
<div aria-hidden="true"
|
|
className={cn(
|
|
"size-8 rounded-full flex items-center justify-center text-foreground text-[10px] font-semibold shrink-0 bg-muted",
|
|
!active && "opacity-50",
|
|
)}
|
|
>
|
|
{avatar}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface UsersDataGridProps {
|
|
users: AppUser[]
|
|
serversCount: number | null
|
|
allSectionsCount?: number
|
|
isLoading?: boolean
|
|
actionsDisabled?: boolean
|
|
onEdit: (user: AppUser) => void
|
|
onDelete: (user: AppUser) => void
|
|
}
|
|
|
|
function UsersDataGrid({
|
|
users,
|
|
serversCount,
|
|
allSectionsCount = ALL_SECTIONS.length,
|
|
isLoading,
|
|
actionsDisabled,
|
|
onEdit,
|
|
onDelete,
|
|
}: UsersDataGridProps) {
|
|
const columns = useMemo<ColumnDef<DataGridFeatures, AppUser>[]>(
|
|
() => [
|
|
{
|
|
id: "name",
|
|
accessorKey: "name",
|
|
header: ({ column }) => (
|
|
<DataGridSortHeader column={column} title="Пользователь" className="ml-1" />
|
|
),
|
|
cell: ({ row }) => {
|
|
const u = row.original
|
|
const expanded = row.getIsExpanded()
|
|
return (
|
|
<div className="flex items-start gap-2 min-w-0">
|
|
<Button type="button" size="sm" variant="ghost" aria-label={"Доступ и привязки пользователя " + u.name} aria-expanded={expanded} className="size-8 shrink-0 p-0" onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
|
|
{expanded ? (
|
|
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
|
|
)}
|
|
</Button>
|
|
<AvatarCircle avatar={u.avatar} active={u.active} />
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">{u.name}</p>
|
|
<p className="text-xs font-mono text-muted-foreground truncate">{u.email || u.login}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
meta: {
|
|
headerTitle: "Пользователь",
|
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
|
expandedContent: (row: AppUser) => (
|
|
<UsersExpandedDetail
|
|
user={row}
|
|
serversCount={serversCount}
|
|
allSectionsCount={allSectionsCount}
|
|
/>
|
|
),
|
|
},
|
|
},
|
|
{
|
|
id: "role",
|
|
accessorKey: "role",
|
|
header: ({ column }) => <DataGridSortHeader column={column} title="Роль" />,
|
|
cell: ({ row }) => (
|
|
<Badge
|
|
variant={row.original.role === "admin" ? "primary-light" : row.original.role === "operator" ? "info-light" : "outline"}
|
|
size="sm"
|
|
>
|
|
{ROLE_LABEL[row.original.role]}
|
|
</Badge>
|
|
),
|
|
meta: {
|
|
headerTitle: "Роль",
|
|
headerClassName: DATA_GRID_CELL_PAD,
|
|
cellClassName: DATA_GRID_CELL_PAD,
|
|
},
|
|
},
|
|
{
|
|
id: "last",
|
|
accessorKey: "last",
|
|
header: ({ column }) => <DataGridSortHeader column={column} title="Последний вход" />,
|
|
cell: ({ row }) => (
|
|
<span className="text-xs text-muted-foreground">{row.original.last}</span>
|
|
),
|
|
meta: {
|
|
headerTitle: "Последний вход",
|
|
headerClassName: DATA_GRID_CELL_PAD,
|
|
cellClassName: DATA_GRID_CELL_PAD,
|
|
},
|
|
},
|
|
{
|
|
id: "ifaces",
|
|
accessorFn: (u) => u.bindings.length,
|
|
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейсы" />,
|
|
cell: ({ row }) => (
|
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
|
|
<CableIcon aria-hidden="true" className="size-3.5" />
|
|
{row.original.bindings.length}
|
|
</span>
|
|
),
|
|
meta: {
|
|
headerTitle: "Интерфейсы",
|
|
headerClassName: DATA_GRID_CELL_PAD,
|
|
cellClassName: DATA_GRID_CELL_PAD,
|
|
},
|
|
},
|
|
{
|
|
id: "status",
|
|
accessorKey: "active",
|
|
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
|
cell: ({ row }) => (
|
|
<span
|
|
className={cn(
|
|
"text-[11px] font-medium",
|
|
row.original.active ? "text-success" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{row.original.active ? "Активен" : "Заблокирован"}
|
|
</span>
|
|
),
|
|
meta: {
|
|
headerTitle: "Статус",
|
|
headerClassName: DATA_GRID_CELL_PAD,
|
|
cellClassName: DATA_GRID_CELL_PAD,
|
|
},
|
|
},
|
|
{
|
|
id: "actions",
|
|
enableSorting: false,
|
|
header: () => <span className="sr-only">Действия</span>,
|
|
cell: ({ row }) => {
|
|
const u = row.original
|
|
return (
|
|
<div
|
|
className="flex items-center justify-end gap-0.5"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="size-11 md:size-8 p-0 text-muted-foreground hover:text-foreground"
|
|
onClick={() => onEdit(u)}
|
|
type="button"
|
|
disabled={actionsDisabled}
|
|
aria-label={"Редактировать пользователя " + u.name}
|
|
title="Редактировать"
|
|
>
|
|
<PencilIcon aria-hidden="true" className="size-3.5" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="size-7 p-0 text-muted-foreground hover:text-destructive"
|
|
onClick={() => onDelete(u)}
|
|
type="button"
|
|
disabled={actionsDisabled}
|
|
aria-label={"Удалить пользователя " + u.name}
|
|
title="Удалить"
|
|
>
|
|
<TrashIcon aria-hidden="true" className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
)
|
|
},
|
|
meta: {
|
|
headerTitle: "Действия",
|
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
|
},
|
|
},
|
|
],
|
|
[allSectionsCount, actionsDisabled, onDelete, onEdit, serversCount],
|
|
)
|
|
|
|
const table = useTable({
|
|
features: dataGridFeatures,
|
|
manualPagination: true,
|
|
data: users,
|
|
columns,
|
|
getRowId: (row) => row.id,
|
|
getRowCanExpand: () => true,
|
|
})
|
|
|
|
if (!isLoading && users.length === 0) {
|
|
return (
|
|
<EmptyState
|
|
icon={<UsersIcon aria-hidden="true" className="size-4" />}
|
|
title="Нет пользователей"
|
|
description="Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"
|
|
className="border-0 py-12"
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<DataGridShell
|
|
table={table}
|
|
recordCount={users.length}
|
|
isLoading={isLoading}
|
|
loadingMode="skeleton"
|
|
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export { UsersDataGrid, type UsersDataGridProps }
|