Files
MikrotikManager/components/data-grids/route-optimizer-comm-recs-data-grid.tsx
T

297 lines
11 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.
"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 }