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
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.
353 lines
13 KiB
TypeScript
353 lines
13 KiB
TypeScript
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>
|
||
)
|
||
}
|