Files
EvoBGP/apps/web/src/components/dashboard/dashboard-modules-grid.tsx
T
Denozordec 39f1295438
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m5s
CI / go (push) Successful in 1m5s
CI / bird2 (push) Successful in 19s
CI / release (push) Successful in 4m22s
refactor: update configuration and enhance skeleton components for improved UI consistency
Modified .npmrc to set a new store directory. Updated eslint configuration to ignore additional paths. Adjusted tsconfig to exclude specific components and refined the SectionCardsSkeleton and AnalyticsDashboardSkeleton for better layout and loading states. Removed the deprecated DashboardQuickActions component to streamline the codebase.
2026-07-09 18:23:53 +07:00

353 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ColumnDef } from '@tanstack/react-table'
import { Link, useNavigate } from '@tanstack/react-router'
import {
ArrowUpDownIcon,
BoxesIcon,
ChevronDownIcon,
FilterIcon,
PlusIcon,
SearchIcon,
XIcon,
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
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 { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
DataGridTable,
DataGridTableHeader,
} from '@/components/reui/data-grid/data-grid-table'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api'
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type PaginationState,
type SortingState,
} from '@tanstack/react-table'
type ModuleSort = 'name' | 'type' | 'priority' | 'last_refreshed_at'
type EnabledFilter = 'all' | 'enabled' | 'disabled'
const sortLabels: Record<ModuleSort, string> = {
name: 'Название',
type: 'Тип',
priority: 'Приоритет',
last_refreshed_at: 'Обновлено',
}
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
function buildSorting(sortBy: ModuleSort): SortingState {
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }]
}
export function DashboardModulesGrid({
modules,
isLoading = false,
}: {
modules: ModuleRow[]
isLoading?: boolean
}) {
const navigate = useNavigate()
const [searchQuery, setSearchQuery] = useState('')
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>('all')
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [sortBy, setSortBy] = useState<ModuleSort>('name')
const [sorting, setSorting] = useState<SortingState>(() => buildSorting('name'))
const filteredModules = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
return modules.filter((module) => {
const matchesSearch =
q.length === 0 ||
`${module.name} ${module.type} ${moduleTypeRu(module.type)}`.toLowerCase().includes(q)
const matchesEnabled =
enabledFilter === 'all' ||
(enabledFilter === 'enabled' ? module.enabled !== false : module.enabled === false)
return matchesSearch && matchesEnabled
})
}, [modules, searchQuery, enabledFilter])
const resetPagination = useCallback(() => {
setPagination((current) => ({ ...current, pageIndex: 0 }))
}, [])
const handleSortChange = useCallback(
(value: ModuleSort) => {
setSortBy(value)
setSorting(buildSorting(value))
resetPagination()
},
[resetPagination],
)
const columns = useMemo<ColumnDef<ModuleRow>[]>(
() => [
{
accessorKey: 'name',
id: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2">
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" />
</div>
),
meta: { headerTitle: 'Модуль' },
},
{
accessorKey: 'type',
id: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
meta: { headerTitle: 'Тип' },
},
{
accessorKey: 'priority',
id: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
),
meta: { headerTitle: 'Приоритет' },
},
{
id: 'enabled',
accessorFn: (row) => (row.enabled !== false ? 'enabled' : 'disabled'),
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => <ModeBadge enabled={row.original.enabled !== false} />,
meta: { headerTitle: 'Состояние' },
},
{
id: 'last_refreshed_at',
accessorFn: (row) => row.last_refreshed_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.last_refreshed_at
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
sortingFn: (a, b) =>
(a.original.last_refreshed_at ?? '').localeCompare(b.original.last_refreshed_at ?? ''),
meta: { headerTitle: 'Обновлено' },
},
],
[],
)
const table = useReactTable({
data: filteredModules,
columns,
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
state: { pagination, sorting },
onPaginationChange: setPagination,
onSortingChange: setSorting,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const activeFilterCount = enabledFilter === 'all' ? 0 : 1
return (
<DashboardFramePanel
title="Модули"
description="Поиск, сортировка и быстрый переход к настройке"
actions={
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
<PlusIcon />
Создать
</Button>
}
>
<DataGrid
table={table}
recordCount={filteredModules.length}
isLoading={isLoading}
emptyMessage={EMPTY_MESSAGE}
tableLayout={{
dense: true,
rowBorder: true,
headerSticky: false,
columnsVisibility: false,
columnsResizable: false,
columnsMovable: false,
width: 'fixed',
}}
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
>
<div className="flex flex-col">
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<InputGroup className="w-full min-w-40 sm:max-w-xs">
<InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
</InputGroupAddon>
<InputGroupInput
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value)
resetPagination()
}}
placeholder="Поиск модулей…"
aria-label="Поиск модулей"
/>
{searchQuery.length > 0 ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label="Очистить поиск"
onClick={() => {
setSearchQuery('')
resetPagination()
}}
>
<XIcon className="size-4" aria-hidden />
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
<div className="flex flex-wrap items-center gap-2">
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline" size="sm">
<ArrowUpDownIcon data-icon="inline-start" aria-hidden />
{sortLabels[sortBy]}
<ChevronDownIcon data-icon="inline-end" aria-hidden />
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuGroup>
{(Object.keys(sortLabels) as ModuleSort[]).map((value) => (
<DropdownMenuItem key={value} onClick={() => handleSortChange(value)}>
{sortLabels[value]}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline" size="sm">
<FilterIcon data-icon="inline-start" aria-hidden />
Фильтры
{activeFilterCount > 0 ? (
<Badge variant="outline" radius="full">
{activeFilterCount}
</Badge>
) : null}
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Состояние</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={enabledFilter === 'enabled'}
closeOnClick={false}
onCheckedChange={(checked) => {
setEnabledFilter(checked ? 'enabled' : 'all')
resetPagination()
}}
>
Только включённые
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={enabledFilter === 'disabled'}
closeOnClick={false}
onCheckedChange={(checked) => {
setEnabledFilter(checked ? 'disabled' : 'all')
resetPagination()
}}
>
Только выключенные
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
{activeFilterCount > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
closeOnClick={false}
onClick={() => {
setEnabledFilter('all')
resetPagination()
}}
>
Сбросить фильтры
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{filteredModules.length > 0 ? (
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
) : (
<>
<DataGridScrollArea>
<DataGridTableHeader />
</DataGridScrollArea>
<div className="text-muted-foreground flex min-h-40 items-center justify-center px-4 text-center text-sm">
{EMPTY_MESSAGE}
</div>
</>
)}
<div className="border-t px-4 py-3">
{filteredModules.length > 0 ? (
<DataGridPagination
sizes={[10, 15, 20]}
info="{from}{to} из {count}"
className="py-0"
/>
) : (
<p className="text-muted-foreground text-center text-sm">0 модулей</p>
)}
</div>
</div>
</DataGrid>
</DashboardFramePanel>
)
}