297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
"use client"
|
||
|
||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||
|
||
import { useEffect, useMemo, useState } from "react"
|
||
import { Input } from "@/components/ui/input"
|
||
import { DataGridContainer, DataGridScrollArea, DataGridTable } from "@/components/reui/data-grid"
|
||
import {
|
||
type ColumnDef,
|
||
useTable,
|
||
} from "@tanstack/react-table"
|
||
import type { CommRec } from "@/lib/route-optimizer-data"
|
||
import { cn } from "@/lib/utils"
|
||
import { Button } from "@/components/ui/button"
|
||
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 { ArrowRightIcon, PinIcon, RefreshCwIcon } from "lucide-react"
|
||
|
||
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||
return (
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
|
||
color ?? "bg-muted text-muted-foreground border-border",
|
||
)}
|
||
>
|
||
{children}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function ProbChip({ prob, best }: { prob: number | null; best?: boolean }) {
|
||
if (prob === null) return <span className="text-muted-foreground" aria-label="Индекс неизвестен">—</span>
|
||
return (
|
||
<Chip
|
||
color={
|
||
best
|
||
? "bg-success/10 text-success border-success/20"
|
||
: "bg-muted text-muted-foreground border-border"
|
||
}
|
||
>
|
||
{prob} б.
|
||
</Chip>
|
||
)
|
||
}
|
||
|
||
interface RouteOptimizerCommRecsDataGridProps {
|
||
recs: CommRec[]
|
||
homeId: string
|
||
pinned: Set<string>
|
||
applied: Set<string>
|
||
applying: Set<string>
|
||
onPin: (key: string) => void
|
||
simulation?: boolean
|
||
onApply: (community: string, homeId: string) => void
|
||
}
|
||
|
||
function RouteOptimizerCommRecsDataGrid({
|
||
recs,
|
||
homeId,
|
||
pinned,
|
||
applied,
|
||
applying,
|
||
onPin,
|
||
onApply,
|
||
simulation = false,
|
||
}: RouteOptimizerCommRecsDataGridProps) {
|
||
const [query, setQuery] = useState("")
|
||
const rows = useMemo(
|
||
() => recs.map((r, idx) => ({ ...r, _rowKey: `${homeId}::${r.community}::${idx}` })),
|
||
[homeId, recs],
|
||
)
|
||
|
||
const filtered = useMemo(() => rows.filter(row =>
|
||
[row.community, row.communityName, row.current?.wan, row.current?.jh, row.current?.exit, row.current?.gateway,
|
||
row.recommended?.wan, row.recommended?.jh, row.recommended?.exit, row.recommended?.gateway]
|
||
.join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), [rows, query])
|
||
|
||
const columns = useMemo<ColumnDef<DataGridFeatures, (typeof rows)[number]>[]>(
|
||
() => [
|
||
{
|
||
id: "community",
|
||
size: 180,
|
||
accessorKey: "community",
|
||
header: ({ column }) => (
|
||
<DataGridSortHeader column={column} title="Community" className="ml-1" />
|
||
),
|
||
cell: ({ row }) => (
|
||
<div>
|
||
<div className="font-mono text-xs font-medium">{row.original.community}</div>
|
||
<div className="text-[11px] text-muted-foreground">{row.original.communityName}</div>
|
||
</div>
|
||
),
|
||
meta: {
|
||
headerTitle: "Community",
|
||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||
},
|
||
},
|
||
{
|
||
id: "current",
|
||
size: 260,
|
||
header: () => (
|
||
<span className="text-[11px] font-medium text-muted-foreground">
|
||
Базовый расчёт (WAN → JH → Exit)
|
||
</span>
|
||
),
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const r = row.original
|
||
if (!r.current) return <span className="text-muted-foreground text-xs">—</span>
|
||
return (
|
||
<div className="text-xs flex items-center gap-1 flex-wrap">
|
||
<span className="font-mono font-medium text-info ">
|
||
{r.current.wan}
|
||
</span>
|
||
<ArrowRightIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
|
||
<span>{r.current.jh}</span>
|
||
<ArrowRightIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
|
||
<span className="text-muted-foreground">{r.current.exit}</span>
|
||
<span className="font-mono text-[10px] text-muted-foreground">
|
||
({r.current.gateway})
|
||
</span>
|
||
</div>
|
||
)
|
||
},
|
||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||
},
|
||
{
|
||
id: "recommended",
|
||
size: 260,
|
||
header: () => <span className="text-[11px] font-medium text-muted-foreground">Рекомендуемый</span>,
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const r = row.original
|
||
const pinKey = `${homeId}::${r.community}`
|
||
const isPinned = pinned.has(pinKey)
|
||
if (!r.recommended) return <span className="text-muted-foreground text-xs">—</span>
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"text-xs flex items-center gap-1 flex-wrap",
|
||
r.shouldSwitch && !isPinned && "text-warning ",
|
||
)}
|
||
>
|
||
<span className="font-mono font-medium">{r.recommended.wan}</span>
|
||
<ArrowRightIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
|
||
<span>{r.recommended.jh}</span>
|
||
<ArrowRightIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
|
||
<span>{r.recommended.exit}</span>
|
||
{r.shouldSwitch && r.current && !isPinned && (
|
||
<span className="ml-1 text-[10px] font-bold bg-warning/10 border border-warning/20 px-1.5 py-0.5 rounded">
|
||
+{r.recommended.prob - r.current.prob} б.
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
},
|
||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||
},
|
||
{
|
||
id: "probability",
|
||
size: 160,
|
||
header: () => (
|
||
<span className="text-[11px] font-medium text-muted-foreground block text-center">
|
||
Индекс (баз / рек)
|
||
</span>
|
||
),
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const r = row.original
|
||
const pinKey = `${homeId}::${r.community}`
|
||
return (
|
||
<div className="flex items-center justify-center gap-1">
|
||
<ProbChip prob={r.current?.prob ?? null} />
|
||
<span className="text-muted-foreground text-[10px]">/</span>
|
||
<ProbChip prob={r.recommended?.prob ?? null} best={r.shouldSwitch && !pinned.has(pinKey)} />
|
||
</div>
|
||
)
|
||
},
|
||
meta: {
|
||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||
},
|
||
},
|
||
{
|
||
id: "actions",
|
||
size: 240,
|
||
header: () => (
|
||
<span className="text-[11px] font-medium text-muted-foreground block text-right">
|
||
Действие
|
||
</span>
|
||
),
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const r = row.original
|
||
const pinKey = `${homeId}::${r.community}`
|
||
const isPinned = pinned.has(pinKey)
|
||
const isApplied = simulation && applied.has(pinKey)
|
||
const isApplying = simulation && applying.has(pinKey)
|
||
const canApply = simulation && r.shouldSwitch && !isPinned && !isApplied
|
||
return (
|
||
<div className="flex items-center justify-end gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||
{isPinned && <PinIcon aria-hidden="true" className="size-3 text-info fill-info" />}
|
||
<Button type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className={cn("h-7 text-xs", isPinned && "text-info border-info/30")}
|
||
onClick={() => onPin(pinKey)}
|
||
>
|
||
<PinIcon aria-hidden="true" className={cn("size-3", isPinned && "fill-current")} />
|
||
{isPinned ? "Открепить" : "Закрепить"}
|
||
</Button>
|
||
{canApply && (
|
||
<Button type="button"
|
||
size="sm"
|
||
className="h-7 text-xs"
|
||
disabled={isApplying}
|
||
onClick={() => onApply(r.community, homeId)}
|
||
>
|
||
{isApplying ? (
|
||
<RefreshCwIcon aria-hidden="true" className="size-3 animate-spin" />
|
||
) : (
|
||
"Симулировать"
|
||
)}
|
||
</Button>
|
||
)}
|
||
{isApplied && (
|
||
<span className="text-[10px] text-success font-medium">
|
||
✓ симуляция завершена
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
},
|
||
meta: {
|
||
headerClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
|
||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||
},
|
||
},
|
||
],
|
||
[applied, applying, homeId, onApply, onPin, pinned, simulation],
|
||
)
|
||
|
||
const table = useTable({
|
||
features: dataGridFeatures,
|
||
manualPagination: false,
|
||
autoResetPageIndex: false,
|
||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["community"], end: [] } },
|
||
data: filtered,
|
||
columns,
|
||
getRowId: (row) => row._rowKey,
|
||
})
|
||
|
||
const { pageIndex, pageSize } = table.state.pagination
|
||
useEffect(() => {
|
||
const lastPage = Math.max(0, Math.ceil(filtered.length / pageSize) - 1)
|
||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||
}, [filtered.length, pageIndex, pageSize, table])
|
||
|
||
return (
|
||
<section aria-label="Рекомендации BGP Community" className="min-w-0">
|
||
<div className="px-5 py-3 space-y-2">
|
||
<label className="block text-xs">Поиск Community
|
||
<Input className="mt-1" value={query} onChange={event => { setQuery(event.target.value); table.setPageIndex(0) }} placeholder="Community, название или шлюз" />
|
||
</label>
|
||
<p className="text-xs text-muted-foreground">Community: {filtered.length} из {rows.length}. Индексы и пути — расчётная модель.</p>
|
||
</div>
|
||
<DataGridShell
|
||
table={table}
|
||
recordCount={filtered.length}
|
||
pagination={filtered.length > 50 || pageSize !== 50}
|
||
tableClassNames={{
|
||
headerRow: "border-b bg-muted/20",
|
||
bodyRow: cn(
|
||
"group/row hover:bg-muted/30",
|
||
"[&:has([data-comm-switch=true])]:bg-warning/5",
|
||
"[&:has([data-comm-applied=true])]:bg-success/5",
|
||
),
|
||
}}
|
||
tableLayout={{ dense: true, width: "fixed", columnsPinnable: true }}
|
||
>
|
||
<DataGridContainer border={false}>
|
||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||
</DataGridContainer>
|
||
</DataGridShell>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export { RouteOptimizerCommRecsDataGrid, type RouteOptimizerCommRecsDataGridProps }
|