refactor(web): общие форматтеры, единая навигация, RBAC-гейтинг и чистка мёртвого кода
- lib/format.ts: ru-RU форматтеры дат/чисел в одном месте — убраны дубли из 8 компонентов (agent-card, fleet-grid, blocked-ips/ports, facts, lifecycle, host-firewall, lists-columns, system-monitor) - lib/nav.ts: единый конфиг маршрутов — sidebar, ⌘K-поиск и breadcrumbs рендерятся из одного источника (было 3 расходящихся копии) - lib/permissions.ts + useCan: клиентский RBAC — кнопки создания/подтверждения скрываются без fw:*:write/admin (сервер остаётся авторитетным) - удалён мёртвый код ~2500 строк: stepper, data-grid dnd/virtual/visibility варианты, settings-shell
This commit is contained in:
@@ -19,6 +19,7 @@ import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
|||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { agentBlockedIpsQueryOptions } from '@/queries'
|
import { agentBlockedIpsQueryOptions } from '@/queries'
|
||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
|
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-IP blocked stats — Linux nft/ipset counters or MikroTik EVOFW_HITS.
|
* Per-IP blocked stats — Linux nft/ipset counters or MikroTik EVOFW_HITS.
|
||||||
@@ -45,21 +46,6 @@ type AgentBlockedIpsProps = {
|
|||||||
platform: string
|
platform: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const packetFmt = new Intl.NumberFormat('ru-RU')
|
|
||||||
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatSeen(iso: string): string {
|
|
||||||
const t = Date.parse(iso)
|
|
||||||
if (Number.isNaN(t)) return '—'
|
|
||||||
return seenFmt.format(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPorts(ports: BlockedIpPort[] | undefined): string {
|
function formatPorts(ports: BlockedIpPort[] | undefined): string {
|
||||||
if (!ports?.length) return '—'
|
if (!ports?.length) return '—'
|
||||||
@@ -99,7 +85,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{packetFmt.format(row.original.packets)}
|
{formatNumber(row.original.packets)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: packetsTitle },
|
meta: { headerTitle: packetsTitle },
|
||||||
@@ -128,7 +114,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
{formatSeen(row.original.last_seen_at)}
|
{formatStampDateTime(row.original.last_seen_at)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Last seen' },
|
meta: { headerTitle: 'Last seen' },
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
|||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { agentBlockedPortsQueryOptions } from '@/queries'
|
import { agentBlockedPortsQueryOptions } from '@/queries'
|
||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
|
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate destination ports hit by denied sources (Linux nft).
|
* Aggregate destination ports hit by denied sources (Linux nft).
|
||||||
@@ -37,21 +38,6 @@ type AgentBlockedPortsProps = {
|
|||||||
agentId: string
|
agentId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const packetFmt = new Intl.NumberFormat('ru-RU')
|
|
||||||
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatSeen(iso: string): string {
|
|
||||||
const t = Date.parse(iso)
|
|
||||||
if (Number.isNaN(t)) return '—'
|
|
||||||
return seenFmt.format(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
||||||
const q = useQuery(agentBlockedPortsQueryOptions(agentId))
|
const q = useQuery(agentBlockedPortsQueryOptions(agentId))
|
||||||
@@ -92,7 +78,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{packetFmt.format(row.original.packets)}
|
{formatNumber(row.original.packets)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Packets' },
|
meta: { headerTitle: 'Packets' },
|
||||||
@@ -105,7 +91,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
{formatSeen(row.original.last_seen_at)}
|
{formatStampDateTime(row.original.last_seen_at)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Last seen' },
|
meta: { headerTitle: 'Last seen' },
|
||||||
|
|||||||
@@ -21,36 +21,13 @@ import {
|
|||||||
} from '@evofw/ui/components/item'
|
} from '@evofw/ui/components/item'
|
||||||
import { Separator } from '@evofw/ui/components/separator'
|
import { Separator } from '@evofw/ui/components/separator'
|
||||||
import { cn } from '@evofw/ui/lib/utils'
|
import { cn } from '@evofw/ui/lib/utils'
|
||||||
|
import { formatPackets, formatShortDateTime } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent catalog card — hybrid card-3 header + stats strip + stats-12 values.
|
* Agent catalog card — hybrid card-3 header + stats strip + stats-12 values.
|
||||||
* Preview: https://reui.io/preview/base/card-3 · https://reui.io/preview/base/stats-12
|
* Preview: https://reui.io/preview/base/card-3 · https://reui.io/preview/base/stats-12
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const packetFmt = new Intl.NumberFormat('ru-RU', {
|
|
||||||
notation: 'compact',
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatPackets(n: number | undefined, hasApply: boolean): string {
|
|
||||||
if (!hasApply || n === undefined) return '—'
|
|
||||||
return packetFmt.format(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatShort(iso: string | null | undefined): string {
|
|
||||||
if (!iso) return '—'
|
|
||||||
const t = Date.parse(iso)
|
|
||||||
if (Number.isNaN(t)) return '—'
|
|
||||||
return seenFmt.format(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentCardProps = {
|
type AgentCardProps = {
|
||||||
agent: Agent
|
agent: Agent
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
@@ -65,13 +42,13 @@ export function AgentCard({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}: AgentCardProps) {
|
}: AgentCardProps) {
|
||||||
const hasApply = agentHasTrafficSample(agent)
|
const hasApply = agentHasTrafficSample(agent)
|
||||||
const dropped = formatPackets(agentTrafficDropped(agent), hasApply)
|
const dropped = hasApply ? formatPackets(agentTrafficDropped(agent)) : '—'
|
||||||
const accepted = formatPackets(agentTrafficAccepted(agent), hasApply)
|
const accepted = hasApply ? formatPackets(agentTrafficAccepted(agent)) : '—'
|
||||||
const traffic =
|
const traffic =
|
||||||
dropped === '—' && accepted === '—'
|
dropped === '—' && accepted === '—'
|
||||||
? '—'
|
? '—'
|
||||||
: `↓${dropped} · ↑${accepted}`
|
: `↓${dropped} · ↑${accepted}`
|
||||||
const seen = formatShort(agent.last_seen_at ?? agent.last_apply_at)
|
const seen = formatShortDateTime(agent.last_seen_at ?? agent.last_apply_at)
|
||||||
const defaultAction =
|
const defaultAction =
|
||||||
agent.default_action === 'drop' ? 'Drop' : 'Accept'
|
agent.default_action === 'drop' ? 'Drop' : 'Accept'
|
||||||
const subtitle = [
|
const subtitle = [
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@evofw/ui/components/select'
|
} from '@evofw/ui/components/select'
|
||||||
import { Separator } from '@evofw/ui/components/separator'
|
import { Separator } from '@evofw/ui/components/separator'
|
||||||
|
import { formatDateTime } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent facts panel — SA3 RunFacts DNA (editable default_action).
|
* Agent facts panel — SA3 RunFacts DNA (editable default_action).
|
||||||
@@ -31,9 +32,7 @@ type AgentFactsPanelProps = {
|
|||||||
|
|
||||||
function formatWhen(iso?: string | null): string {
|
function formatWhen(iso?: string | null): string {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
const d = new Date(iso)
|
return formatDateTime(iso)
|
||||||
if (Number.isNaN(d.getTime())) return iso
|
|
||||||
return d.toLocaleString('ru-RU')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentFactsPanel({ agent }: AgentFactsPanelProps) {
|
export function AgentFactsPanel({ agent }: AgentFactsPanelProps) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@evofw/ui/components/tooltip'
|
} from '@evofw/ui/components/tooltip'
|
||||||
|
import { formatPackets, formatShortDateTime } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fleet triage DataGrid — firewall ops density.
|
* Fleet triage DataGrid — firewall ops density.
|
||||||
@@ -34,30 +35,6 @@ import {
|
|||||||
* · https://reui.io/preview/base/solution-agents-1
|
* · https://reui.io/preview/base/solution-agents-1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const packetFmt = new Intl.NumberFormat('ru-RU', {
|
|
||||||
notation: 'compact',
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatPackets(n: number | undefined, hasApply: boolean): string {
|
|
||||||
if (!hasApply || n === undefined) return '—'
|
|
||||||
return packetFmt.format(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAgentSeen(iso: string | null | undefined): string {
|
|
||||||
if (!iso) return '—'
|
|
||||||
const t = Date.parse(iso)
|
|
||||||
if (Number.isNaN(t)) return '—'
|
|
||||||
return seenFmt.format(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentFleetDataGridProps = {
|
export type AgentFleetDataGridProps = {
|
||||||
data: Agent[]
|
data: Agent[]
|
||||||
filterFields: FilterFieldConfig[]
|
filterFields: FilterFieldConfig[]
|
||||||
@@ -231,8 +208,8 @@ export function AgentFleetDataGrid({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const a = row.original
|
const a = row.original
|
||||||
const hasApply = agentHasTrafficSample(a)
|
const hasApply = agentHasTrafficSample(a)
|
||||||
const dropped = formatPackets(agentTrafficDropped(a), hasApply)
|
const dropped = hasApply ? formatPackets(agentTrafficDropped(a)) : '—'
|
||||||
const accepted = formatPackets(agentTrafficAccepted(a), hasApply)
|
const accepted = hasApply ? formatPackets(agentTrafficAccepted(a)) : '—'
|
||||||
if (dropped === '—' && accepted === '—') {
|
if (dropped === '—' && accepted === '—') {
|
||||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||||
}
|
}
|
||||||
@@ -255,7 +232,7 @@ export function AgentFleetDataGrid({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const a = row.original
|
const a = row.original
|
||||||
const short = formatAgentSeen(a.last_seen_at)
|
const short = formatShortDateTime(a.last_seen_at)
|
||||||
if (!a.last_seen_at || short === '—') {
|
if (!a.last_seen_at || short === '—') {
|
||||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@evofw/ui/components/select'
|
} from '@evofw/ui/components/select'
|
||||||
import { TabsContent } from '@evofw/ui/components/tabs'
|
import { TabsContent } from '@evofw/ui/components/tabs'
|
||||||
|
import { formatDateTime } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Observed host firewall + listeners (Linux).
|
* Observed host firewall + listeners (Linux).
|
||||||
@@ -229,7 +230,7 @@ export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
|
|||||||
<FrameDescription>
|
<FrameDescription>
|
||||||
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
|
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
|
||||||
{q.data?.collected_at
|
{q.data?.collected_at
|
||||||
? ` Обновлено: ${new Date(q.data.collected_at).toLocaleString('ru-RU')}`
|
? ` Обновлено: ${formatDateTime(q.data.collected_at)}`
|
||||||
: ' Пока нет снимка — дождитесь sync агента.'}
|
: ' Пока нет снимка — дождитесь sync агента.'}
|
||||||
</FrameDescription>
|
</FrameDescription>
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
|
import { formatDateTime } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent lifecycle timeline.
|
* Agent lifecycle timeline.
|
||||||
@@ -32,9 +33,7 @@ type Step = {
|
|||||||
|
|
||||||
function formatWhen(iso?: string | null): string | undefined {
|
function formatWhen(iso?: string | null): string | undefined {
|
||||||
if (!iso) return undefined
|
if (!iso) return undefined
|
||||||
const d = new Date(iso)
|
return formatDateTime(iso)
|
||||||
if (Number.isNaN(d.getTime())) return iso
|
|
||||||
return d.toLocaleString('ru-RU')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentLifecycleTimeline({ agent }: { agent: Agent }) {
|
export function AgentLifecycleTimeline({ agent }: { agent: Agent }) {
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import { Link, useRouterState } from '@tanstack/react-router'
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
import {
|
|
||||||
LayoutDashboardIcon,
|
|
||||||
ServerIcon,
|
|
||||||
ListIcon,
|
|
||||||
ShieldIcon,
|
|
||||||
BarChart3Icon,
|
|
||||||
SettingsIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { AppSwitcher } from '@/components/app-switcher'
|
import { AppSwitcher } from '@/components/app-switcher'
|
||||||
import { NavUser } from '@/components/layout/nav-user'
|
import { NavUser } from '@/components/layout/nav-user'
|
||||||
|
import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav'
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -22,22 +15,7 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@evofw/ui/components/sidebar'
|
} from '@evofw/ui/components/sidebar'
|
||||||
|
|
||||||
const overviewNav = [
|
function isNavActive(pathname: string, to: string, exact?: boolean) {
|
||||||
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const opsNav = [
|
|
||||||
{ to: '/agents', label: 'Агенты', icon: ServerIcon, exact: false },
|
|
||||||
{ to: '/lists', label: 'Списки', icon: ListIcon, exact: false },
|
|
||||||
{ to: '/rules', label: 'Наборы правил', icon: ShieldIcon, exact: false },
|
|
||||||
{ to: '/stats', label: 'Статистика', icon: BarChart3Icon, exact: false },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const systemNav = [
|
|
||||||
{ to: '/settings', label: 'Настройки', icon: SettingsIcon, exact: false },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
function isNavActive(pathname: string, to: string, exact: boolean) {
|
|
||||||
if (exact) return pathname === to
|
if (exact) return pathname === to
|
||||||
return pathname === to || pathname.startsWith(`${to}/`)
|
return pathname === to || pathname.startsWith(`${to}/`)
|
||||||
}
|
}
|
||||||
@@ -48,12 +26,7 @@ function NavSection({
|
|||||||
pathname,
|
pathname,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
items: readonly {
|
items: readonly NavItem[]
|
||||||
to: string
|
|
||||||
label: string
|
|
||||||
icon: typeof ServerIcon
|
|
||||||
exact: boolean
|
|
||||||
}[]
|
|
||||||
pathname: string
|
pathname: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -90,12 +63,18 @@ export function AppSidebar() {
|
|||||||
<AppSwitcher />
|
<AppSwitcher />
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavSection label="Обзор" items={overviewNav} pathname={pathname} />
|
{NAV_SECTIONS.map((section) => (
|
||||||
<NavSection label="Операции" items={opsNav} pathname={pathname} />
|
<NavSection
|
||||||
<NavSection label="Система" items={systemNav} pathname={pathname} />
|
key={section.id}
|
||||||
|
label={section.label}
|
||||||
|
items={navItemsForSection(section.id)}
|
||||||
|
pathname={pathname}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser />
|
<NavUser />
|
||||||
</SidebarFooter> </Sidebar>
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import { useEffect, useId, useMemo, useState } from 'react'
|
import { useEffect, useId, useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate } from '@tanstack/react-router'
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
import {
|
import { SearchIcon } from 'lucide-react'
|
||||||
BarChart3Icon,
|
import { NAV_ITEMS } from '@/lib/nav'
|
||||||
LayoutDashboardIcon,
|
|
||||||
ListIcon,
|
|
||||||
SearchIcon,
|
|
||||||
ServerIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
ShieldIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -26,45 +19,6 @@ import {
|
|||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from '@evofw/ui/components/item'
|
} from '@evofw/ui/components/item'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
|
||||||
{
|
|
||||||
to: '/',
|
|
||||||
label: 'Панель управления',
|
|
||||||
keywords: ['dashboard', 'панель', 'обзор'],
|
|
||||||
icon: LayoutDashboardIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/agents',
|
|
||||||
label: 'Агенты',
|
|
||||||
keywords: ['agents', 'агенты', 'nodes'],
|
|
||||||
icon: ServerIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/lists',
|
|
||||||
label: 'Списки IP',
|
|
||||||
keywords: ['lists', 'списки', 'blocklist'],
|
|
||||||
icon: ListIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/rules',
|
|
||||||
label: 'Наборы правил',
|
|
||||||
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
|
|
||||||
icon: ShieldIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/stats',
|
|
||||||
label: 'Статистика',
|
|
||||||
keywords: ['stats', 'статистика', 'packets'],
|
|
||||||
icon: BarChart3Icon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/settings',
|
|
||||||
label: 'Настройки',
|
|
||||||
keywords: ['settings', 'настройки'],
|
|
||||||
icon: SettingsIcon,
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
/** Command-K search — hotkey dialog (no header chrome trigger). */
|
/** Command-K search — hotkey dialog (no header chrome trigger). */
|
||||||
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|||||||
@@ -12,55 +12,41 @@ import { Separator } from '@evofw/ui/components/separator'
|
|||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||||
import { SidebarTrigger } from '@evofw/ui/components/sidebar'
|
import { SidebarTrigger } from '@evofw/ui/components/sidebar'
|
||||||
|
import { navLabel, navParentForDetail } from '@/lib/nav'
|
||||||
|
|
||||||
export interface RouteBreadcrumbLoaderData {
|
export interface RouteBreadcrumbLoaderData {
|
||||||
breadcrumb?: string
|
breadcrumb?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeTitles: Record<string, string> = {
|
|
||||||
'/': 'Панель управления',
|
|
||||||
'/agents': 'Агенты',
|
|
||||||
'/lists': 'Списки',
|
|
||||||
'/rules': 'Наборы правил',
|
|
||||||
'/stats': 'Статистика',
|
|
||||||
'/settings': 'Настройки',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBreadcrumbs(
|
function getBreadcrumbs(
|
||||||
pathname: string,
|
pathname: string,
|
||||||
dynamicLabels: Record<string, string>,
|
dynamicLabels: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
if (pathname === '/') {
|
if (pathname === '/') {
|
||||||
return [{ label: 'Панель управления', href: '/' }]
|
return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.match(/^\/agents\/[^/]+$/)) {
|
const parentTo = navParentForDetail(pathname)
|
||||||
|
if (parentTo) {
|
||||||
|
const parentLabel = navLabel(parentTo)
|
||||||
|
const fallback =
|
||||||
|
parentTo === '/agents'
|
||||||
|
? 'Агент'
|
||||||
|
: parentTo === '/lists'
|
||||||
|
? 'Список'
|
||||||
|
: 'Набор'
|
||||||
return [
|
return [
|
||||||
{ label: 'Агенты', href: '/agents' },
|
{ label: parentLabel ?? parentTo, href: parentTo },
|
||||||
{ label: dynamicLabels[pathname] ?? 'Агент', href: pathname },
|
{ label: dynamicLabels[pathname] ?? fallback, href: pathname },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.match(/^\/rules\/[^/]+$/)) {
|
const title = navLabel(pathname)
|
||||||
return [
|
|
||||||
{ label: 'Наборы правил', href: '/rules' },
|
|
||||||
{ label: dynamicLabels[pathname] ?? 'Набор', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.match(/^\/lists\/[^/]+$/)) {
|
|
||||||
return [
|
|
||||||
{ label: 'Списки', href: '/lists' },
|
|
||||||
{ label: dynamicLabels[pathname] ?? 'Список', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = routeTitles[pathname]
|
|
||||||
if (title) {
|
if (title) {
|
||||||
return [{ label: title, href: pathname }]
|
return [{ label: title, href: pathname }]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{ label: 'Панель управления', href: '/' }]
|
return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDynamicBreadcrumbLabels() {
|
function useDynamicBreadcrumbLabels() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Activity, HeartPulse, List, Server, Shield } from 'lucide-react'
|
import { Activity, HeartPulse, List, Server, Shield } from 'lucide-react'
|
||||||
|
import { formatTime } from '@/lib/format'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@evofw/ui/lib/utils'
|
import { cn } from '@evofw/ui/lib/utils'
|
||||||
@@ -201,7 +202,7 @@ export function SystemMonitorPopover() {
|
|||||||
Монитор EvoFirewall
|
Монитор EvoFirewall
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||||
{new Date().toLocaleTimeString('ru-RU')}
|
{formatTime(new Date())}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2">
|
<div className="grid grid-cols-2">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { StatusBadge } from '@/components/status-badge'
|
|||||||
import { ListTypeIcon } from '@/components/lists/list-type-icon'
|
import { ListTypeIcon } from '@/components/lists/list-type-icon'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { isManualListType, type IpList } from '@evofw/shared'
|
import { isManualListType, type IpList } from '@evofw/shared'
|
||||||
|
import { formatDateTime } from '@/lib/format'
|
||||||
|
|
||||||
export const LIST_TABS = [
|
export const LIST_TABS = [
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
@@ -67,7 +68,7 @@ export function createListColumns(opts: {
|
|||||||
row.original.last_error
|
row.original.last_error
|
||||||
? 'Ошибка обновления'
|
? 'Ошибка обновления'
|
||||||
: row.original.refreshed_at
|
: row.original.refreshed_at
|
||||||
? `Обновлён ${new Date(row.original.refreshed_at).toLocaleString('ru-RU')}`
|
? `Обновлён ${formatDateTime(row.original.refreshed_at)}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -102,7 +103,7 @@ export function createListColumns(opts: {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridMutedCell>
|
<DataGridMutedCell>
|
||||||
{row.original.updated_at
|
{row.original.updated_at
|
||||||
? new Date(row.original.updated_at).toLocaleString('ru-RU')
|
? formatDateTime(row.original.updated_at)
|
||||||
: '—'}
|
: '—'}
|
||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export {
|
|||||||
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||||
export { OpsDashboard } from './ops-dashboard'
|
export { OpsDashboard } from './ops-dashboard'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
|
||||||
|
|
||||||
export { PageShell } from '@/components/page-shell'
|
export { PageShell } from '@/components/page-shell'
|
||||||
export { PageHeader } from '@/components/page-header'
|
export { PageHeader } from '@/components/page-header'
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
import { useIsMobile } from '@evofw/ui/hooks/use-mobile'
|
|
||||||
import { cn } from '@evofw/ui/lib/utils'
|
|
||||||
import { PageShell } from '@/components/page-shell'
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multi-section settings layout — only when tabs are provided.
|
|
||||||
* Single-page settings use PageShell + Frame + SettingRow directly.
|
|
||||||
* Preview: https://reui.io/preview/base/settings-16
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface SettingsTabConfig {
|
|
||||||
id: string
|
|
||||||
to: string
|
|
||||||
label: string
|
|
||||||
icon?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsShellProps {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
/** Required — no phantom default routes. */
|
|
||||||
tabs: SettingsTabConfig[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsShell({
|
|
||||||
title = 'Настройки',
|
|
||||||
description = 'Конфигурация control plane',
|
|
||||||
tabs,
|
|
||||||
}: SettingsShellProps) {
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell>
|
|
||||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
|
||||||
<PageHeader title={title} description={description} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex gap-5',
|
|
||||||
isMobile ? 'flex-col' : 'flex-row items-start',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tabs.length > 1 ? (
|
|
||||||
<nav
|
|
||||||
aria-label="Разделы настроек"
|
|
||||||
className={cn(
|
|
||||||
'flex gap-1',
|
|
||||||
isMobile
|
|
||||||
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
|
||||||
: 'w-44 shrink-0 flex-col',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const isActive = pathname.startsWith(tab.to)
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tab.id}
|
|
||||||
to={tab.to}
|
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
|
||||||
isMobile && 'shrink-0',
|
|
||||||
!isMobile && 'w-full',
|
|
||||||
isActive
|
|
||||||
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
|
||||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.icon}
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"use no memo"
|
|
||||||
|
|
||||||
import { ReactElement } from "react"
|
|
||||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import { Table } from "@tanstack/react-table"
|
|
||||||
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@evofw/ui/components/dropdown-menu"
|
|
||||||
|
|
||||||
function DataGridColumnVisibility<TData>({
|
|
||||||
table,
|
|
||||||
trigger,
|
|
||||||
}: {
|
|
||||||
table: Table<TData>
|
|
||||||
trigger: ReactElement<Record<string, unknown>>
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger render={trigger} />
|
|
||||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="font-medium">
|
|
||||||
Toggle Columns
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{table
|
|
||||||
.getAllColumns()
|
|
||||||
.filter((column) => column.getCanHide())
|
|
||||||
.map((column) => {
|
|
||||||
return (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={column.id}
|
|
||||||
className="capitalize"
|
|
||||||
checked={column.getIsVisible()}
|
|
||||||
onSelect={(event) => event.preventDefault()}
|
|
||||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
|
||||||
>
|
|
||||||
{getColumnHeaderLabel(column)}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridColumnVisibility }
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
"use client"
|
|
||||||
"use no memo"
|
|
||||||
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
CSSProperties,
|
|
||||||
memo,
|
|
||||||
ReactNode,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useId,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import {
|
|
||||||
DataGridTableBase,
|
|
||||||
DataGridTableBody,
|
|
||||||
DataGridTableBodyRow,
|
|
||||||
DataGridTableBodyRowCell,
|
|
||||||
DataGridTableBodyRowExpandded,
|
|
||||||
DataGridTableBodyRowSkeleton,
|
|
||||||
DataGridTableBodyRowSkeletonCell,
|
|
||||||
DataGridTableEmpty,
|
|
||||||
DataGridTableFillBodyCell,
|
|
||||||
DataGridTableFillHeadCell,
|
|
||||||
DataGridTableFoot,
|
|
||||||
DataGridTableHead,
|
|
||||||
DataGridTableHeadRow,
|
|
||||||
DataGridTableHeadRowCell,
|
|
||||||
DataGridTableHeadRowCellResize,
|
|
||||||
DataGridTableRowSpacer,
|
|
||||||
DataGridTableViewport,
|
|
||||||
} from "@/components/reui/data-grid/data-grid-table"
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
KeyboardSensor,
|
|
||||||
MouseSensor,
|
|
||||||
TouchSensor,
|
|
||||||
UniqueIdentifier,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
type DragEndEvent,
|
|
||||||
type Modifier,
|
|
||||||
} from "@dnd-kit/core"
|
|
||||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable"
|
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
|
||||||
import {
|
|
||||||
Cell,
|
|
||||||
flexRender,
|
|
||||||
HeaderGroup,
|
|
||||||
Row,
|
|
||||||
Table,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
|
|
||||||
import { cn } from "@evofw/ui/lib/utils"
|
|
||||||
import { Button } from "@evofw/ui/components/button"
|
|
||||||
import { GripHorizontalIcon } from "lucide-react"
|
|
||||||
|
|
||||||
// Context to share sortable listeners from row to handle
|
|
||||||
type SortableContextValue = ReturnType<typeof useSortable>
|
|
||||||
const SortableRowContext = createContext<Pick<
|
|
||||||
SortableContextValue,
|
|
||||||
"attributes" | "listeners"
|
|
||||||
> | null>(null)
|
|
||||||
|
|
||||||
function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
|
||||||
const context = useContext(SortableRowContext)
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
// Fallback if context is not available (shouldn't happen in normal usage)
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={cn(
|
|
||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
aria-label="Drag to reorder row"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<GripHorizontalIcon aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={cn(
|
|
||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
aria-label="Drag to reorder row"
|
|
||||||
{...context.attributes}
|
|
||||||
{...context.listeners}
|
|
||||||
>
|
|
||||||
<GripHorizontalIcon aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
|
||||||
const {
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
setNodeRef,
|
|
||||||
isDragging,
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
} = useSortable({
|
|
||||||
id: row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition: transition,
|
|
||||||
opacity: isDragging ? 0.8 : 1,
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
position: "relative",
|
|
||||||
cursor: isDragging ? "grabbing" : undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
|
||||||
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
|
|
||||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</DataGridTableBodyRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<DataGridTableFillBodyCell />
|
|
||||||
</DataGridTableBodyRow>
|
|
||||||
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
|
||||||
</SortableRowContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndRowsBody<TData>({
|
|
||||||
table,
|
|
||||||
dataIds,
|
|
||||||
}: {
|
|
||||||
table: Table<TData>
|
|
||||||
dataIds: UniqueIdentifier[]
|
|
||||||
}) {
|
|
||||||
const { isLoading, props } = useDataGrid()
|
|
||||||
const pagination = table.getState().pagination
|
|
||||||
|
|
||||||
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
|
||||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
|
||||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowSkeletonCell
|
|
||||||
column={column}
|
|
||||||
key={colIndex}
|
|
||||||
>
|
|
||||||
{column.columnDef.meta?.skeleton}
|
|
||||||
</DataGridTableBodyRowSkeletonCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<DataGridTableFillBodyCell />
|
|
||||||
</DataGridTableBodyRowSkeleton>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableContext items={dataIds} strategy={verticalListSortingStrategy}>
|
|
||||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
|
||||||
return <DataGridTableDndRow row={row} key={row.id} />
|
|
||||||
})}
|
|
||||||
</SortableContext>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memoized body rows: skip re-renders during active column resize.
|
|
||||||
* Column widths update via CSS variables on the <table> element,
|
|
||||||
* so the browser handles width changes without React re-renders.
|
|
||||||
*/
|
|
||||||
const MemoizedDataGridTableDndRowsBody = memo(
|
|
||||||
DataGridTableDndRowsBody,
|
|
||||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
|
||||||
) as typeof DataGridTableDndRowsBody
|
|
||||||
|
|
||||||
function DataGridTableDndRows<TData>({
|
|
||||||
handleDragEnd,
|
|
||||||
dataIds,
|
|
||||||
footerContent,
|
|
||||||
}: {
|
|
||||||
handleDragEnd: (event: DragEndEvent) => void
|
|
||||||
dataIds: UniqueIdentifier[]
|
|
||||||
footerContent?: ReactNode
|
|
||||||
}) {
|
|
||||||
const { table, props } = useDataGrid()
|
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(MouseSensor, {}),
|
|
||||||
useSensor(TouchSensor, {}),
|
|
||||||
// Keyboard reordering moves one sortable position per keypress instead
|
|
||||||
// of the sensor's raw 25px default.
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDraggingRow) return
|
|
||||||
|
|
||||||
const { body, documentElement } = document
|
|
||||||
const previousBodyCursor = body.style.cursor
|
|
||||||
const previousDocumentCursor = documentElement.style.cursor
|
|
||||||
|
|
||||||
body.style.cursor = "grabbing"
|
|
||||||
documentElement.style.cursor = "grabbing"
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
body.style.cursor = previousBodyCursor
|
|
||||||
documentElement.style.cursor = previousDocumentCursor
|
|
||||||
}
|
|
||||||
}, [isDraggingRow])
|
|
||||||
|
|
||||||
const modifiers = useMemo(() => {
|
|
||||||
const restrictToTableContainer: Modifier = ({
|
|
||||||
transform,
|
|
||||||
draggingNodeRect,
|
|
||||||
}) => {
|
|
||||||
if (!tableContainerRef.current || !draggingNodeRect) {
|
|
||||||
return transform
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRect = tableContainerRef.current.getBoundingClientRect()
|
|
||||||
const { x, y } = transform
|
|
||||||
|
|
||||||
const minX = containerRect.left - draggingNodeRect.left
|
|
||||||
const maxX = containerRect.right - draggingNodeRect.right
|
|
||||||
const minY = containerRect.top - draggingNodeRect.top
|
|
||||||
const maxY = containerRect.bottom - draggingNodeRect.bottom
|
|
||||||
|
|
||||||
return {
|
|
||||||
...transform,
|
|
||||||
x: Math.max(minX, Math.min(maxX, x)),
|
|
||||||
y: Math.max(minY, Math.min(maxY, y)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [restrictToVerticalAxis, restrictToTableContainer]
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
id={useId()}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
modifiers={modifiers}
|
|
||||||
onDragCancel={() => setIsDraggingRow(false)}
|
|
||||||
onDragEnd={(event) => {
|
|
||||||
setIsDraggingRow(false)
|
|
||||||
handleDragEnd(event)
|
|
||||||
}}
|
|
||||||
onDragStart={() => setIsDraggingRow(true)}
|
|
||||||
sensors={sensors}
|
|
||||||
>
|
|
||||||
<DataGridTableViewport
|
|
||||||
viewportRef={tableContainerRef}
|
|
||||||
className={
|
|
||||||
isDraggingRow
|
|
||||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
|
||||||
: "relative"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
<DataGridTableHead>
|
|
||||||
{table
|
|
||||||
.getHeaderGroups()
|
|
||||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header, index) => {
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell header={header} key={index}>
|
|
||||||
{header.isPlaceholder ? null : props.tableLayout
|
|
||||||
?.columnsResizable && column.getCanResize() ? (
|
|
||||||
<div className="truncate">
|
|
||||||
{flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<DataGridTableFillHeadCell />
|
|
||||||
</DataGridTableHeadRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableHead>
|
|
||||||
|
|
||||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
|
||||||
<DataGridTableRowSpacer />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGridTableBody>
|
|
||||||
<MemoizedDataGridTableDndRowsBody table={table} dataIds={dataIds} />
|
|
||||||
</DataGridTableBody>
|
|
||||||
|
|
||||||
{footerContent && (
|
|
||||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
|
||||||
)}
|
|
||||||
</DataGridTableBase>
|
|
||||||
</DataGridTableViewport>
|
|
||||||
</DndContext>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
"use no memo"
|
|
||||||
|
|
||||||
import {
|
|
||||||
CSSProperties,
|
|
||||||
Fragment,
|
|
||||||
memo,
|
|
||||||
ReactNode,
|
|
||||||
useEffect,
|
|
||||||
useId,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import {
|
|
||||||
DataGridTableBase,
|
|
||||||
DataGridTableBody,
|
|
||||||
DataGridTableBodyRow,
|
|
||||||
DataGridTableBodyRowCell,
|
|
||||||
DataGridTableBodyRowExpandded,
|
|
||||||
DataGridTableBodyRowSkeleton,
|
|
||||||
DataGridTableBodyRowSkeletonCell,
|
|
||||||
DataGridTableEmpty,
|
|
||||||
DataGridTableFillBodyCell,
|
|
||||||
DataGridTableFillHeadCell,
|
|
||||||
DataGridTableFoot,
|
|
||||||
DataGridTableHead,
|
|
||||||
DataGridTableHeadRow,
|
|
||||||
DataGridTableHeadRowCell,
|
|
||||||
DataGridTableHeadRowCellResize,
|
|
||||||
DataGridTableRowSpacer,
|
|
||||||
DataGridTableViewport,
|
|
||||||
} from "@/components/reui/data-grid/data-grid-table"
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
KeyboardSensor,
|
|
||||||
Modifier,
|
|
||||||
MouseSensor,
|
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
type DragEndEvent,
|
|
||||||
} from "@dnd-kit/core"
|
|
||||||
import {
|
|
||||||
horizontalListSortingStrategy,
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
useSortable,
|
|
||||||
} from "@dnd-kit/sortable"
|
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
|
||||||
import {
|
|
||||||
Cell,
|
|
||||||
flexRender,
|
|
||||||
Header,
|
|
||||||
HeaderGroup,
|
|
||||||
Row,
|
|
||||||
Table,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
|
|
||||||
import { Button } from "@evofw/ui/components/button"
|
|
||||||
import { GripVerticalIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function DataGridTableDndHeader<TData>({
|
|
||||||
header,
|
|
||||||
}: {
|
|
||||||
header: Header<TData, unknown>
|
|
||||||
}) {
|
|
||||||
const { props } = useDataGrid()
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
// Check if column ordering is enabled for this column
|
|
||||||
const canOrder =
|
|
||||||
(column.columnDef as { enableColumnOrdering?: boolean })
|
|
||||||
.enableColumnOrdering !== false
|
|
||||||
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
isDragging,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
} = useSortable({
|
|
||||||
id: header.column.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
opacity: isDragging ? 0.8 : 1,
|
|
||||||
position: "relative",
|
|
||||||
transform: CSS.Translate.toString(transform),
|
|
||||||
transition,
|
|
||||||
cursor: isDragging ? "grabbing" : undefined,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
width: props.tableLayout?.columnsResizable
|
|
||||||
? `calc(var(--header-${header.id}-size) * 1px)`
|
|
||||||
: header.column.getSize(),
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell
|
|
||||||
header={header}
|
|
||||||
dndStyle={style}
|
|
||||||
dndRef={setNodeRef}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-start gap-0.5">
|
|
||||||
{canOrder && (
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
aria-label="Drag to reorder"
|
|
||||||
>
|
|
||||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div className="grow">
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</div>
|
|
||||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
|
||||||
const { props } = useDataGrid()
|
|
||||||
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
|
||||||
id: cell.column.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
opacity: isDragging ? 0.8 : 1,
|
|
||||||
position: "relative",
|
|
||||||
transform: CSS.Translate.toString(transform),
|
|
||||||
transition,
|
|
||||||
cursor: isDragging ? "grabbing" : undefined,
|
|
||||||
width: props.tableLayout?.columnsResizable
|
|
||||||
? `calc(var(--col-${cell.column.id}-size) * 1px)`
|
|
||||||
: cell.column.getSize(),
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</DataGridTableBodyRowCell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndBodyRows<TData>({ table }: { table: Table<TData> }) {
|
|
||||||
const { isLoading, props } = useDataGrid()
|
|
||||||
const pagination = table.getState().pagination
|
|
||||||
|
|
||||||
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
|
||||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
|
||||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowSkeletonCell
|
|
||||||
column={column}
|
|
||||||
key={colIndex}
|
|
||||||
>
|
|
||||||
{column.columnDef.meta?.skeleton}
|
|
||||||
</DataGridTableBodyRowSkeletonCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<DataGridTableFillBodyCell />
|
|
||||||
</DataGridTableBodyRowSkeleton>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
|
||||||
return (
|
|
||||||
<Fragment key={row.id}>
|
|
||||||
<DataGridTableBodyRow row={row}>
|
|
||||||
<SortableContext
|
|
||||||
items={table.getState().columnOrder}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
|
|
||||||
<DataGridTableDndCell cell={cell} key={cell.id} />
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
<DataGridTableFillBodyCell />
|
|
||||||
</DataGridTableBodyRow>
|
|
||||||
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memoized body rows: skip re-renders during active column resize.
|
|
||||||
* Column widths update via CSS variables on the <table> element,
|
|
||||||
* so the browser handles width changes without React re-renders.
|
|
||||||
*/
|
|
||||||
const MemoizedDataGridTableDndBodyRows = memo(
|
|
||||||
DataGridTableDndBodyRows,
|
|
||||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
|
||||||
) as typeof DataGridTableDndBodyRows
|
|
||||||
|
|
||||||
function DataGridTableDnd<TData>({
|
|
||||||
handleDragEnd,
|
|
||||||
footerContent,
|
|
||||||
}: {
|
|
||||||
handleDragEnd: (event: DragEndEvent) => void
|
|
||||||
footerContent?: ReactNode
|
|
||||||
}) {
|
|
||||||
const { table, props } = useDataGrid()
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(MouseSensor, {}),
|
|
||||||
useSensor(TouchSensor, {}),
|
|
||||||
// Keyboard reordering moves one sortable position per keypress instead
|
|
||||||
// of the sensor's raw 25px default.
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDraggingColumn) return
|
|
||||||
|
|
||||||
const { body, documentElement } = document
|
|
||||||
const previousBodyCursor = body.style.cursor
|
|
||||||
const previousDocumentCursor = documentElement.style.cursor
|
|
||||||
|
|
||||||
body.style.cursor = "grabbing"
|
|
||||||
documentElement.style.cursor = "grabbing"
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
body.style.cursor = previousBodyCursor
|
|
||||||
documentElement.style.cursor = previousDocumentCursor
|
|
||||||
}
|
|
||||||
}, [isDraggingColumn])
|
|
||||||
|
|
||||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
|
||||||
const modifiers = useMemo(() => {
|
|
||||||
const restrictToTableBounds: Modifier = ({
|
|
||||||
draggingNodeRect,
|
|
||||||
transform,
|
|
||||||
}) => {
|
|
||||||
if (!draggingNodeRect || !containerRef.current) {
|
|
||||||
return { ...transform, y: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRect = containerRef.current.getBoundingClientRect()
|
|
||||||
const edgeOffset = 0
|
|
||||||
|
|
||||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
|
||||||
const maxX =
|
|
||||||
containerRect.right -
|
|
||||||
draggingNodeRect.left -
|
|
||||||
draggingNodeRect.width +
|
|
||||||
edgeOffset
|
|
||||||
|
|
||||||
return {
|
|
||||||
...transform,
|
|
||||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
|
||||||
y: 0, // Lock vertical movement
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [restrictToTableBounds]
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
id={useId()}
|
|
||||||
modifiers={modifiers}
|
|
||||||
onDragCancel={() => setIsDraggingColumn(false)}
|
|
||||||
onDragEnd={(event) => {
|
|
||||||
setIsDraggingColumn(false)
|
|
||||||
handleDragEnd(event)
|
|
||||||
}}
|
|
||||||
onDragStart={() => setIsDraggingColumn(true)}
|
|
||||||
sensors={sensors}
|
|
||||||
>
|
|
||||||
<DataGridTableViewport
|
|
||||||
viewportRef={containerRef}
|
|
||||||
className={
|
|
||||||
isDraggingColumn
|
|
||||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
|
||||||
: "relative"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
<DataGridTableHead>
|
|
||||||
{table
|
|
||||||
.getHeaderGroups()
|
|
||||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
|
||||||
<SortableContext
|
|
||||||
items={table.getState().columnOrder}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<DataGridTableDndHeader
|
|
||||||
header={header}
|
|
||||||
key={header.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
<DataGridTableFillHeadCell />
|
|
||||||
</DataGridTableHeadRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableHead>
|
|
||||||
|
|
||||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
|
||||||
<DataGridTableRowSpacer />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGridTableBody>
|
|
||||||
<MemoizedDataGridTableDndBodyRows table={table} />
|
|
||||||
</DataGridTableBody>
|
|
||||||
|
|
||||||
{footerContent && (
|
|
||||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
|
||||||
)}
|
|
||||||
</DataGridTableBase>
|
|
||||||
</DataGridTableViewport>
|
|
||||||
</DndContext>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridTableDnd }
|
|
||||||
@@ -1,634 +0,0 @@
|
|||||||
"use client"
|
|
||||||
"use no memo"
|
|
||||||
|
|
||||||
import {
|
|
||||||
CSSProperties,
|
|
||||||
memo,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|
||||||
import {
|
|
||||||
DataGridTableBase,
|
|
||||||
DataGridTableBody,
|
|
||||||
DataGridTableEmpty,
|
|
||||||
DataGridTableFillBodyCell,
|
|
||||||
DataGridTableFillHeadCell,
|
|
||||||
DataGridTableFoot,
|
|
||||||
DataGridTableHead,
|
|
||||||
DataGridTableHeadRow,
|
|
||||||
DataGridTableHeadRowCell,
|
|
||||||
DataGridTableHeadRowCellResize,
|
|
||||||
DataGridTableRenderedRow,
|
|
||||||
DataGridTableRowSpacer,
|
|
||||||
DataGridTableViewport,
|
|
||||||
getDataGridScrollAreaViewport,
|
|
||||||
getDataGridTableMergedHeaderGroups,
|
|
||||||
getDataGridTableRowSections,
|
|
||||||
getPinningStyles,
|
|
||||||
hasDataGridTableRightPinnedColumns,
|
|
||||||
} from "@/components/reui/data-grid/data-grid-table"
|
|
||||||
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
|
|
||||||
import {
|
|
||||||
useVirtualizer,
|
|
||||||
VirtualItem,
|
|
||||||
Virtualizer,
|
|
||||||
VirtualizerOptions,
|
|
||||||
} from "@tanstack/react-virtual"
|
|
||||||
|
|
||||||
import { cn } from "@evofw/ui/lib/utils"
|
|
||||||
import { Spinner } from "@evofw/ui/components/spinner"
|
|
||||||
|
|
||||||
type DataGridTableVirtualScrollElements = {
|
|
||||||
containerElement: HTMLDivElement | null
|
|
||||||
scrollElement: HTMLElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type DataGridTableVirtualizerInstance = Virtualizer<
|
|
||||||
HTMLElement,
|
|
||||||
HTMLTableRowElement
|
|
||||||
>
|
|
||||||
|
|
||||||
type DataGridTableVirtualizerOptions<TData> = Omit<
|
|
||||||
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
|
||||||
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
|
||||||
> & {
|
|
||||||
estimateSize?: (index: number, row: Row<TData>) => number
|
|
||||||
getItemKey?: (index: number, row: Row<TData>) => string | number
|
|
||||||
getScrollElement?: (
|
|
||||||
elements: DataGridTableVirtualScrollElements
|
|
||||||
) => HTMLElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DataGridTableVirtualProps<TData> {
|
|
||||||
height?: number | string
|
|
||||||
estimateSize?: number
|
|
||||||
overscan?: number
|
|
||||||
footerContent?: ReactNode
|
|
||||||
renderHeader?: boolean
|
|
||||||
onFetchMore?: () => void
|
|
||||||
isFetchingMore?: boolean
|
|
||||||
hasMore?: boolean
|
|
||||||
fetchMoreOffset?: number
|
|
||||||
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VirtualBodyProps<TData> {
|
|
||||||
table: Table<TData>
|
|
||||||
topRows: Row<TData>[]
|
|
||||||
centerRows: Row<TData>[]
|
|
||||||
bottomRows: Row<TData>[]
|
|
||||||
virtualItems: VirtualItem[]
|
|
||||||
totalSize: number
|
|
||||||
isVirtualizationEnabled: boolean
|
|
||||||
isInfiniteMode: boolean
|
|
||||||
isFetchingMore: boolean
|
|
||||||
hasMore?: boolean
|
|
||||||
loadingMoreMessage: ReactNode
|
|
||||||
allRowsLoadedMessage: ReactNode
|
|
||||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
|
|
||||||
column,
|
|
||||||
}: {
|
|
||||||
column: Column<TData>
|
|
||||||
}) {
|
|
||||||
const { props } = useDataGrid()
|
|
||||||
const isPinned = column.getIsPinned()
|
|
||||||
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
|
|
||||||
const isFirstRightPinned =
|
|
||||||
isPinned === "right" && column.getIsFirstColumn("right")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
...(props.tableLayout?.columnsPinnable &&
|
|
||||||
column.getCanPin() &&
|
|
||||||
getPinningStyles(column)),
|
|
||||||
...(props.tableLayout?.columnsResizable && {
|
|
||||||
width: `calc(var(--col-${column.id}-size) * 1px)`,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
data-pinned={isPinned || undefined}
|
|
||||||
data-last-col={
|
|
||||||
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"p-0",
|
|
||||||
props.tableLayout?.cellBorder && "border-e",
|
|
||||||
props.tableLayout?.columnsPinnable &&
|
|
||||||
column.getCanPin() &&
|
|
||||||
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualUtilityRow<TData>({
|
|
||||||
table,
|
|
||||||
children,
|
|
||||||
centerCellClassName,
|
|
||||||
centerCellStyle,
|
|
||||||
rowClassName,
|
|
||||||
ariaHidden,
|
|
||||||
}: {
|
|
||||||
table: Table<TData>
|
|
||||||
children: ReactNode
|
|
||||||
centerCellClassName?: string
|
|
||||||
centerCellStyle?: CSSProperties
|
|
||||||
rowClassName?: string
|
|
||||||
ariaHidden?: boolean
|
|
||||||
}) {
|
|
||||||
const { props } = useDataGrid()
|
|
||||||
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
|
|
||||||
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
|
||||||
const rightVisibleColumns = table.getRightVisibleLeafColumns()
|
|
||||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
|
|
||||||
{leftVisibleColumns.map((column) => (
|
|
||||||
<DataGridTableVirtualPinnedPlaceholderCell
|
|
||||||
column={column}
|
|
||||||
key={column.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<td
|
|
||||||
colSpan={Math.max(centerVisibleColumns.length, 1)}
|
|
||||||
className={centerCellClassName}
|
|
||||||
style={centerCellStyle}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</td>
|
|
||||||
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
|
||||||
<DataGridTableFillBodyCell />
|
|
||||||
) : null}
|
|
||||||
{rightVisibleColumns.map((column) => (
|
|
||||||
<DataGridTableVirtualPinnedPlaceholderCell
|
|
||||||
column={column}
|
|
||||||
key={column.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
|
||||||
<DataGridTableFillBodyCell />
|
|
||||||
) : null}
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualSpacer<TData>({
|
|
||||||
table,
|
|
||||||
height,
|
|
||||||
}: {
|
|
||||||
table: Table<TData>
|
|
||||||
height: number
|
|
||||||
}) {
|
|
||||||
if (height <= 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableVirtualUtilityRow
|
|
||||||
table={table}
|
|
||||||
ariaHidden
|
|
||||||
centerCellClassName="p-0"
|
|
||||||
centerCellStyle={{ height, padding: 0 }}
|
|
||||||
>
|
|
||||||
{null}
|
|
||||||
</DataGridTableVirtualUtilityRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualStatusRow<TData>({
|
|
||||||
table,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
table: Table<TData>
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DataGridTableVirtualUtilityRow
|
|
||||||
table={table}
|
|
||||||
centerCellClassName={cn(
|
|
||||||
"text-muted-foreground py-4 text-center text-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DataGridTableVirtualUtilityRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualBody<TData>({
|
|
||||||
table,
|
|
||||||
topRows,
|
|
||||||
centerRows,
|
|
||||||
bottomRows,
|
|
||||||
virtualItems,
|
|
||||||
totalSize,
|
|
||||||
isVirtualizationEnabled,
|
|
||||||
isInfiniteMode,
|
|
||||||
isFetchingMore,
|
|
||||||
hasMore,
|
|
||||||
loadingMoreMessage,
|
|
||||||
allRowsLoadedMessage,
|
|
||||||
measureRowRef,
|
|
||||||
}: VirtualBodyProps<TData>) {
|
|
||||||
const { isLoading } = useDataGrid()
|
|
||||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
|
||||||
|
|
||||||
if (!totalRows) {
|
|
||||||
// Initial load must not flash the empty state as if the query returned
|
|
||||||
// nothing.
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<DataGridTableVirtualStatusRow table={table}>
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Spinner className="size-4 opacity-60" />
|
|
||||||
{loadingMoreMessage}
|
|
||||||
</div>
|
|
||||||
</DataGridTableVirtualStatusRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DataGridTableEmpty />
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasCenterRows = centerRows.length > 0
|
|
||||||
const showFetchingRow = isInfiniteMode && isFetchingMore
|
|
||||||
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
|
|
||||||
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
|
|
||||||
const leadingSpacerHeight =
|
|
||||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
|
||||||
? (virtualItems[0]?.start ?? 0)
|
|
||||||
: 0
|
|
||||||
const trailingSpacerHeight =
|
|
||||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
|
||||||
? Math.max(
|
|
||||||
0,
|
|
||||||
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
|
||||||
)
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const renderedRows: ReactNode[] = []
|
|
||||||
|
|
||||||
topRows.forEach((row, index) => {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableRenderedRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
pinnedBoundary={
|
|
||||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isVirtualizationEnabled) {
|
|
||||||
if (leadingSpacerHeight > 0) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualSpacer
|
|
||||||
key="virtual-spacer-start"
|
|
||||||
table={table}
|
|
||||||
height={leadingSpacerHeight}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
virtualItems.forEach((virtualRow) => {
|
|
||||||
const row = centerRows[virtualRow.index]
|
|
||||||
|
|
||||||
if (!row) return
|
|
||||||
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableRenderedRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
rowRef={measureRowRef}
|
|
||||||
rowIndex={virtualRow.index}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (trailingSpacerHeight > 0) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualSpacer
|
|
||||||
key="virtual-spacer-end"
|
|
||||||
table={table}
|
|
||||||
height={trailingSpacerHeight}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
centerRows.forEach((row) => {
|
|
||||||
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showFetchingRow) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Spinner className="size-4 opacity-60" />
|
|
||||||
{loadingMoreMessage}
|
|
||||||
</div>
|
|
||||||
</DataGridTableVirtualStatusRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCompleteRow) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualStatusRow
|
|
||||||
key="virtual-status-complete"
|
|
||||||
table={table}
|
|
||||||
className="py-3 text-xs"
|
|
||||||
>
|
|
||||||
{allRowsLoadedMessage}
|
|
||||||
</DataGridTableVirtualStatusRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
bottomRows.forEach((row, index) => {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableRenderedRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
pinnedBoundary={
|
|
||||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
|
||||||
? "bottom"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return <>{renderedRows}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memoized virtual body: skip re-renders during active column resize.
|
|
||||||
* Column widths update via CSS variables on the <table> element,
|
|
||||||
* so the browser handles width changes without React re-renders.
|
|
||||||
*/
|
|
||||||
const MemoizedVirtualBody = memo(
|
|
||||||
DataGridTableVirtualBody,
|
|
||||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
|
||||||
) as typeof DataGridTableVirtualBody
|
|
||||||
|
|
||||||
function DataGridTableVirtual<TData>({
|
|
||||||
height,
|
|
||||||
estimateSize = 48,
|
|
||||||
overscan = 10,
|
|
||||||
footerContent,
|
|
||||||
renderHeader = true,
|
|
||||||
onFetchMore,
|
|
||||||
isFetchingMore = false,
|
|
||||||
hasMore,
|
|
||||||
fetchMoreOffset = 0,
|
|
||||||
virtualizerOptions,
|
|
||||||
}: DataGridTableVirtualProps<TData>) {
|
|
||||||
const { table, props } = useDataGrid()
|
|
||||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
|
||||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
|
||||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
|
||||||
table,
|
|
||||||
props.tableLayout?.rowsPinnable
|
|
||||||
)
|
|
||||||
const isInfiniteMode = typeof onFetchMore === "function"
|
|
||||||
const [viewportElements, setViewportElements] =
|
|
||||||
useState<DataGridTableVirtualScrollElements>({
|
|
||||||
containerElement: null,
|
|
||||||
scrollElement: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
|
||||||
estimateSize: customEstimateSize,
|
|
||||||
getItemKey: customGetItemKey,
|
|
||||||
getScrollElement: customGetScrollElement,
|
|
||||||
measureElement: customMeasureElement,
|
|
||||||
overscan: customOverscan,
|
|
||||||
...virtualizerOptionsRest
|
|
||||||
} = virtualizerOptions ?? {}
|
|
||||||
|
|
||||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
|
||||||
const loadingMoreMessage =
|
|
||||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
|
||||||
const allRowsLoadedMessage =
|
|
||||||
props.allRowsLoadedMessage || "All records loaded"
|
|
||||||
|
|
||||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
|
||||||
setViewportElements({
|
|
||||||
containerElement: node,
|
|
||||||
scrollElement: node
|
|
||||||
? (getDataGridScrollAreaViewport(node) ?? node)
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const usesExternalScrollArea =
|
|
||||||
viewportElements.scrollElement !== null &&
|
|
||||||
viewportElements.scrollElement !== viewportElements.containerElement
|
|
||||||
|
|
||||||
const resolveScrollElement = useCallback(() => {
|
|
||||||
if (customGetScrollElement) {
|
|
||||||
return customGetScrollElement(viewportElements)
|
|
||||||
}
|
|
||||||
|
|
||||||
return viewportElements.scrollElement
|
|
||||||
}, [customGetScrollElement, viewportElements])
|
|
||||||
|
|
||||||
const resolveItemKey = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const row = centerRows[index]
|
|
||||||
|
|
||||||
if (!row) return index
|
|
||||||
|
|
||||||
return customGetItemKey?.(index, row) ?? row.id ?? index
|
|
||||||
},
|
|
||||||
[centerRows, customGetItemKey]
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolveEstimateSize = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const row = centerRows[index]
|
|
||||||
|
|
||||||
return row
|
|
||||||
? (customEstimateSize?.(index, row) ?? estimateSize)
|
|
||||||
: estimateSize
|
|
||||||
},
|
|
||||||
[centerRows, customEstimateSize, estimateSize]
|
|
||||||
)
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
|
||||||
count: centerRows.length,
|
|
||||||
getScrollElement: resolveScrollElement,
|
|
||||||
getItemKey: resolveItemKey,
|
|
||||||
estimateSize: resolveEstimateSize,
|
|
||||||
overscan: customOverscan ?? overscan,
|
|
||||||
measureElement: customMeasureElement,
|
|
||||||
...virtualizerOptionsRest,
|
|
||||||
}) as DataGridTableVirtualizerInstance
|
|
||||||
|
|
||||||
const virtualItems = isVirtualizationEnabled
|
|
||||||
? virtualizer.getVirtualItems()
|
|
||||||
: []
|
|
||||||
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
|
|
||||||
const measureRowRef =
|
|
||||||
isVirtualizationEnabled && customMeasureElement
|
|
||||||
? virtualizer.measureElement
|
|
||||||
: undefined
|
|
||||||
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
|
|
||||||
// Latch onFetchMore per row count: virtualItems gets a new identity every
|
|
||||||
// scroll frame, so without it the effect fires duplicate page requests
|
|
||||||
// before the consumer flips isFetchingMore, and loops at end-of-data when
|
|
||||||
// hasMore is never set.
|
|
||||||
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isVirtualizationEnabled ||
|
|
||||||
!isInfiniteMode ||
|
|
||||||
hasMore === false ||
|
|
||||||
isFetchingMore
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastItem = virtualItems[virtualItems.length - 1]
|
|
||||||
if (!lastItem) return
|
|
||||||
|
|
||||||
if (fetchMoreFiredAtCountRef.current === centerRows.length) return
|
|
||||||
|
|
||||||
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
|
||||||
fetchMoreFiredAtCountRef.current = centerRows.length
|
|
||||||
onFetchMore?.()
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
centerRows.length,
|
|
||||||
hasMore,
|
|
||||||
isFetchingMore,
|
|
||||||
isInfiniteMode,
|
|
||||||
isVirtualizationEnabled,
|
|
||||||
onFetchMore,
|
|
||||||
resolvedFetchMoreOffset,
|
|
||||||
virtualItems,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableViewport
|
|
||||||
viewportRef={handleViewportRef}
|
|
||||||
className={!usesExternalScrollArea ? "block" : undefined}
|
|
||||||
style={
|
|
||||||
usesExternalScrollArea
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
height,
|
|
||||||
overflow: "auto",
|
|
||||||
position: "relative",
|
|
||||||
// Standalone mode: this node IS the scroll container, so it
|
|
||||||
// must stay at its parent's width (not the resizable table
|
|
||||||
// width) or horizontal scrolling becomes impossible.
|
|
||||||
width: "auto",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
{renderHeader && (
|
|
||||||
<DataGridTableHead>
|
|
||||||
{mergedHeaderGroups.map((headerGroup) => (
|
|
||||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
|
||||||
{headerGroup.headers
|
|
||||||
.filter((header) => header.column.getIsPinned() !== "right")
|
|
||||||
.map((header) => {
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
hasRightPinnedColumns ? (
|
|
||||||
<DataGridTableFillHeadCell />
|
|
||||||
) : null}
|
|
||||||
{headerGroup.headers
|
|
||||||
.filter((header) => header.column.getIsPinned() === "right")
|
|
||||||
.map((header) => {
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
!hasRightPinnedColumns ? (
|
|
||||||
<DataGridTableFillHeadCell />
|
|
||||||
) : null}
|
|
||||||
</DataGridTableHeadRow>
|
|
||||||
))}
|
|
||||||
</DataGridTableHead>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderHeader &&
|
|
||||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
|
||||||
<DataGridTableRowSpacer />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGridTableBody>
|
|
||||||
<MemoizedVirtualBody
|
|
||||||
table={table}
|
|
||||||
topRows={topRows}
|
|
||||||
centerRows={centerRows}
|
|
||||||
bottomRows={bottomRows}
|
|
||||||
virtualItems={virtualItems}
|
|
||||||
totalSize={totalSize}
|
|
||||||
isVirtualizationEnabled={isVirtualizationEnabled}
|
|
||||||
isInfiniteMode={isInfiniteMode}
|
|
||||||
isFetchingMore={isFetchingMore}
|
|
||||||
hasMore={hasMore}
|
|
||||||
loadingMoreMessage={loadingMoreMessage}
|
|
||||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
|
||||||
measureRowRef={measureRowRef}
|
|
||||||
/>
|
|
||||||
</DataGridTableBody>
|
|
||||||
|
|
||||||
{footerContent && (
|
|
||||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
|
||||||
)}
|
|
||||||
</DataGridTableBase>
|
|
||||||
</DataGridTableViewport>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridTableVirtual }
|
|
||||||
export type {
|
|
||||||
DataGridTableVirtualProps,
|
|
||||||
DataGridTableVirtualScrollElements,
|
|
||||||
DataGridTableVirtualizerOptions,
|
|
||||||
}
|
|
||||||
@@ -1,477 +0,0 @@
|
|||||||
import {
|
|
||||||
Children,
|
|
||||||
createContext,
|
|
||||||
HTMLAttributes,
|
|
||||||
isValidElement,
|
|
||||||
ReactElement,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { mergeProps } from "@base-ui/react/merge-props"
|
|
||||||
import { useRender } from "@base-ui/react/use-render"
|
|
||||||
|
|
||||||
import { cn } from "@evofw/ui/lib/utils"
|
|
||||||
|
|
||||||
// Types
|
|
||||||
type StepperOrientation = "horizontal" | "vertical"
|
|
||||||
type StepState = "active" | "completed" | "inactive" | "loading"
|
|
||||||
type StepIndicators = {
|
|
||||||
active?: React.ReactNode
|
|
||||||
completed?: React.ReactNode
|
|
||||||
inactive?: React.ReactNode
|
|
||||||
loading?: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StepperContextValue {
|
|
||||||
activeStep: number
|
|
||||||
setActiveStep: (step: number) => void
|
|
||||||
stepsCount: number
|
|
||||||
orientation: StepperOrientation
|
|
||||||
registerTrigger: (node: HTMLButtonElement | null) => void
|
|
||||||
triggerNodes: HTMLButtonElement[]
|
|
||||||
focusNext: (currentIdx: number) => void
|
|
||||||
focusPrev: (currentIdx: number) => void
|
|
||||||
focusFirst: () => void
|
|
||||||
focusLast: () => void
|
|
||||||
indicators: StepIndicators
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StepItemContextValue {
|
|
||||||
step: number
|
|
||||||
state: StepState
|
|
||||||
isDisabled: boolean
|
|
||||||
isLoading: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const StepperContext = createContext<StepperContextValue | undefined>(undefined)
|
|
||||||
const StepItemContext = createContext<StepItemContextValue | undefined>(
|
|
||||||
undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
function useStepper() {
|
|
||||||
const ctx = useContext(StepperContext)
|
|
||||||
if (!ctx) throw new Error("useStepper must be used within a Stepper")
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
function useStepItem() {
|
|
||||||
const ctx = useContext(StepItemContext)
|
|
||||||
if (!ctx) throw new Error("useStepItem must be used within a StepperItem")
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StepperProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
defaultValue?: number
|
|
||||||
value?: number
|
|
||||||
onValueChange?: (value: number) => void
|
|
||||||
orientation?: StepperOrientation
|
|
||||||
indicators?: StepIndicators
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stepper({
|
|
||||||
defaultValue = 1,
|
|
||||||
value,
|
|
||||||
onValueChange,
|
|
||||||
orientation = "horizontal",
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
indicators = {},
|
|
||||||
...props
|
|
||||||
}: StepperProps) {
|
|
||||||
const [activeStep, setActiveStep] = useState(defaultValue)
|
|
||||||
const [triggerNodes, setTriggerNodes] = useState<HTMLButtonElement[]>([])
|
|
||||||
|
|
||||||
// Register/unregister triggers
|
|
||||||
const registerTrigger = useCallback((node: HTMLButtonElement | null) => {
|
|
||||||
setTriggerNodes((prev) => {
|
|
||||||
if (node && !prev.includes(node)) {
|
|
||||||
return [...prev, node]
|
|
||||||
} else if (!node && prev.includes(node!)) {
|
|
||||||
return prev.filter((n) => n !== node)
|
|
||||||
} else {
|
|
||||||
return prev
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSetActiveStep = useCallback(
|
|
||||||
(step: number) => {
|
|
||||||
if (value === undefined) {
|
|
||||||
setActiveStep(step)
|
|
||||||
}
|
|
||||||
onValueChange?.(step)
|
|
||||||
},
|
|
||||||
[value, onValueChange]
|
|
||||||
)
|
|
||||||
|
|
||||||
const currentStep = value ?? activeStep
|
|
||||||
|
|
||||||
// Keyboard navigation logic
|
|
||||||
const focusTrigger = (idx: number) => {
|
|
||||||
if (triggerNodes[idx]) triggerNodes[idx].focus()
|
|
||||||
}
|
|
||||||
const focusNext = (currentIdx: number) =>
|
|
||||||
focusTrigger((currentIdx + 1) % triggerNodes.length)
|
|
||||||
const focusPrev = (currentIdx: number) =>
|
|
||||||
focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length)
|
|
||||||
const focusFirst = () => focusTrigger(0)
|
|
||||||
const focusLast = () => focusTrigger(triggerNodes.length - 1)
|
|
||||||
|
|
||||||
// Context value
|
|
||||||
const contextValue = useMemo<StepperContextValue>(
|
|
||||||
() => ({
|
|
||||||
activeStep: currentStep,
|
|
||||||
setActiveStep: handleSetActiveStep,
|
|
||||||
stepsCount: Children.toArray(children).filter(
|
|
||||||
(child): child is ReactElement =>
|
|
||||||
isValidElement(child) &&
|
|
||||||
(child.type as { displayName?: string }).displayName === "StepperItem"
|
|
||||||
).length,
|
|
||||||
orientation,
|
|
||||||
registerTrigger,
|
|
||||||
focusNext,
|
|
||||||
focusPrev,
|
|
||||||
focusFirst,
|
|
||||||
focusLast,
|
|
||||||
triggerNodes,
|
|
||||||
indicators,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
currentStep,
|
|
||||||
handleSetActiveStep,
|
|
||||||
children,
|
|
||||||
orientation,
|
|
||||||
registerTrigger,
|
|
||||||
triggerNodes,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StepperContext.Provider value={contextValue}>
|
|
||||||
<div
|
|
||||||
role="tablist"
|
|
||||||
aria-orientation={orientation}
|
|
||||||
data-slot="stepper"
|
|
||||||
className={cn("w-full", className)}
|
|
||||||
data-orientation={orientation}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</StepperContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
||||||
step: number
|
|
||||||
completed?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
loading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepperItem({
|
|
||||||
step,
|
|
||||||
completed = false,
|
|
||||||
disabled = false,
|
|
||||||
loading = false,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: StepperItemProps) {
|
|
||||||
const { activeStep } = useStepper()
|
|
||||||
|
|
||||||
const state: StepState =
|
|
||||||
completed || step < activeStep
|
|
||||||
? "completed"
|
|
||||||
: activeStep === step
|
|
||||||
? "active"
|
|
||||||
: "inactive"
|
|
||||||
|
|
||||||
const isLoading = loading && step === activeStep
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StepItemContext.Provider
|
|
||||||
value={{ step, state, isDisabled: disabled, isLoading }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-slot="stepper-item"
|
|
||||||
className={cn(
|
|
||||||
"group/step flex items-center justify-center not-last:flex-1 group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
data-state={state}
|
|
||||||
{...(isLoading ? { "data-loading": true } : {})}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</StepItemContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type StepperTriggerProps = useRender.ComponentProps<"button">
|
|
||||||
|
|
||||||
function StepperTrigger({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
tabIndex,
|
|
||||||
render,
|
|
||||||
...props
|
|
||||||
}: StepperTriggerProps) {
|
|
||||||
const { state, isLoading } = useStepItem()
|
|
||||||
const stepperCtx = useStepper()
|
|
||||||
const {
|
|
||||||
setActiveStep,
|
|
||||||
activeStep,
|
|
||||||
registerTrigger,
|
|
||||||
triggerNodes,
|
|
||||||
focusNext,
|
|
||||||
focusPrev,
|
|
||||||
focusFirst,
|
|
||||||
focusLast,
|
|
||||||
} = stepperCtx
|
|
||||||
const { step, isDisabled } = useStepItem()
|
|
||||||
const isSelected = activeStep === step
|
|
||||||
const id = `stepper-tab-${step}`
|
|
||||||
const panelId = `stepper-panel-${step}`
|
|
||||||
|
|
||||||
// Register this trigger for keyboard navigation
|
|
||||||
const btnRef = useRef<HTMLButtonElement>(null)
|
|
||||||
useEffect(() => {
|
|
||||||
if (btnRef.current) {
|
|
||||||
registerTrigger(btnRef.current)
|
|
||||||
}
|
|
||||||
}, [btnRef.current])
|
|
||||||
|
|
||||||
// Find our index among triggers for navigation
|
|
||||||
const myIdx = useMemo(
|
|
||||||
() =>
|
|
||||||
triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
|
|
||||||
[triggerNodes, btnRef.current]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
||||||
switch (e.key) {
|
|
||||||
case "ArrowRight":
|
|
||||||
case "ArrowDown":
|
|
||||||
e.preventDefault()
|
|
||||||
if (myIdx !== -1 && focusNext) focusNext(myIdx)
|
|
||||||
break
|
|
||||||
case "ArrowLeft":
|
|
||||||
case "ArrowUp":
|
|
||||||
e.preventDefault()
|
|
||||||
if (myIdx !== -1 && focusPrev) focusPrev(myIdx)
|
|
||||||
break
|
|
||||||
case "Home":
|
|
||||||
e.preventDefault()
|
|
||||||
if (focusFirst) focusFirst()
|
|
||||||
break
|
|
||||||
case "End":
|
|
||||||
e.preventDefault()
|
|
||||||
if (focusLast) focusLast()
|
|
||||||
break
|
|
||||||
case "Enter":
|
|
||||||
case " ":
|
|
||||||
e.preventDefault()
|
|
||||||
setActiveStep(step)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
role: "tab",
|
|
||||||
id,
|
|
||||||
"aria-selected": isSelected,
|
|
||||||
"aria-controls": panelId,
|
|
||||||
tabIndex: typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1,
|
|
||||||
"data-slot": "stepper-trigger",
|
|
||||||
"data-state": state,
|
|
||||||
"data-loading": isLoading,
|
|
||||||
className: cn(
|
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60",
|
|
||||||
"gap-2.5 rounded-full",
|
|
||||||
className
|
|
||||||
),
|
|
||||||
onClick: () => setActiveStep(step),
|
|
||||||
onKeyDown: handleKeyDown,
|
|
||||||
disabled: isDisabled,
|
|
||||||
children,
|
|
||||||
}
|
|
||||||
|
|
||||||
return useRender({
|
|
||||||
defaultTagName: "button",
|
|
||||||
render,
|
|
||||||
ref: btnRef,
|
|
||||||
props: mergeProps<"button">(defaultProps, props),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepperIndicator({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
const { state, isLoading } = useStepItem()
|
|
||||||
const { indicators } = useStepper()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="stepper-indicator"
|
|
||||||
data-state={state}
|
|
||||||
className={cn(
|
|
||||||
"border-background bg-accent text-accent-foreground data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground data-[state=active]:bg-primary data-[state=active]:text-primary-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden",
|
|
||||||
"rounded-full text-xs",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="absolute">
|
|
||||||
{indicators &&
|
|
||||||
((isLoading && indicators.loading) ||
|
|
||||||
(state === "completed" && indicators.completed) ||
|
|
||||||
(state === "active" && indicators.active) ||
|
|
||||||
(state === "inactive" && indicators.inactive))
|
|
||||||
? (isLoading && indicators.loading) ||
|
|
||||||
(state === "completed" && indicators.completed) ||
|
|
||||||
(state === "active" && indicators.active) ||
|
|
||||||
(state === "inactive" && indicators.inactive)
|
|
||||||
: children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepperSeparator({ className }: React.ComponentProps<"div">) {
|
|
||||||
const { state } = useStepItem()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="stepper-separator"
|
|
||||||
data-state={state}
|
|
||||||
className={cn(
|
|
||||||
"bg-muted rounded-sm group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5 m-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepperTitle({ children, className }: React.ComponentProps<"h3">) {
|
|
||||||
const { state } = useStepItem()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<h3
|
|
||||||
data-slot="stepper-title"
|
|
||||||
data-state={state}
|
|
||||||
className={cn(
|
|
||||||
"text-sm leading-none font-medium",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</h3>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepperDescription({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
const { state } = useStepItem()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="stepper-description"
|
|
||||||
data-state={state}
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground text-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepperNav({ children, className }: React.ComponentProps<"nav">) {
|
|
||||||
const { activeStep, orientation } = useStepper()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
data-slot="stepper-nav"
|
|
||||||
data-state={activeStep}
|
|
||||||
data-orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepperPanel({ children, className }: React.ComponentProps<"div">) {
|
|
||||||
const { activeStep } = useStepper()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="stepper-panel"
|
|
||||||
data-state={activeStep}
|
|
||||||
className={cn("w-full", className)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StepperContentProps extends React.ComponentProps<"div"> {
|
|
||||||
value: number
|
|
||||||
forceMount?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepperContent({
|
|
||||||
value,
|
|
||||||
forceMount,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: StepperContentProps) {
|
|
||||||
const { activeStep } = useStepper()
|
|
||||||
const isActive = value === activeStep
|
|
||||||
|
|
||||||
if (!forceMount && !isActive) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="stepper-content"
|
|
||||||
data-state={activeStep}
|
|
||||||
className={cn("w-full", className, !isActive && forceMount && "hidden")}
|
|
||||||
hidden={!isActive && forceMount}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
useStepper,
|
|
||||||
useStepItem,
|
|
||||||
Stepper,
|
|
||||||
StepperItem,
|
|
||||||
StepperTrigger,
|
|
||||||
StepperIndicator,
|
|
||||||
StepperSeparator,
|
|
||||||
StepperTitle,
|
|
||||||
StepperDescription,
|
|
||||||
StepperPanel,
|
|
||||||
StepperContent,
|
|
||||||
StepperNav,
|
|
||||||
type StepperProps,
|
|
||||||
type StepperItemProps,
|
|
||||||
type StepperTriggerProps,
|
|
||||||
type StepperContentProps,
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Shared ru-RU formatters — single source for dates/numbers across the app.
|
||||||
|
* All timestamps are ISO strings from the API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const packetFmt = new Intl.NumberFormat('ru-RU', {
|
||||||
|
notation: 'compact',
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const numberFmt = new Intl.NumberFormat('ru-RU')
|
||||||
|
|
||||||
|
const shortDateTimeFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateTimeFmt = new Intl.DateTimeFormat('ru-RU')
|
||||||
|
|
||||||
|
const stampDateTimeFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Compact packet counter: 1,2K / 3,4M */
|
||||||
|
export function formatPackets(n: number | undefined | null): string {
|
||||||
|
if (n === undefined || n === null) return '—'
|
||||||
|
return packetFmt.format(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(n: number | undefined | null): string {
|
||||||
|
if (n === undefined || n === null) return '—'
|
||||||
|
return numberFmt.format(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dd.MM HH:mm — dense "seen" stamps in grids/cards */
|
||||||
|
export function formatShortDateTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const t = Date.parse(iso)
|
||||||
|
if (Number.isNaN(t)) return '—'
|
||||||
|
return shortDateTimeFmt.format(new Date(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full locale date-time for detail panels */
|
||||||
|
export function formatDateTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const t = Date.parse(iso)
|
||||||
|
if (Number.isNaN(t)) return iso
|
||||||
|
return dateTimeFmt.format(new Date(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dd.MM.yyyy HH:mm:ss — block-stats "seen" stamps */
|
||||||
|
export function formatStampDateTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const t = Date.parse(iso)
|
||||||
|
if (Number.isNaN(t)) return iso
|
||||||
|
return stampDateTimeFmt.format(new Date(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(d: Date): string {
|
||||||
|
return timeFmt.format(d)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
BarChart3Icon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
ListIcon,
|
||||||
|
ServerIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of the app's route structure: sidebar sections, ⌘K search and
|
||||||
|
* header breadcrumbs all render from this config.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type NavSectionId = 'overview' | 'ops' | 'system'
|
||||||
|
|
||||||
|
export type NavItem = {
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
/** Exact active-state match (root only); prefix match otherwise. */
|
||||||
|
exact?: boolean
|
||||||
|
keywords: string[]
|
||||||
|
icon: typeof ServerIcon
|
||||||
|
section: NavSectionId
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAV_ITEMS: readonly NavItem[] = [
|
||||||
|
{
|
||||||
|
to: '/',
|
||||||
|
label: 'Панель управления',
|
||||||
|
exact: true,
|
||||||
|
keywords: ['dashboard', 'панель', 'обзор'],
|
||||||
|
icon: LayoutDashboardIcon,
|
||||||
|
section: 'overview',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/agents',
|
||||||
|
label: 'Агенты',
|
||||||
|
keywords: ['agents', 'агенты', 'nodes'],
|
||||||
|
icon: ServerIcon,
|
||||||
|
section: 'ops',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/lists',
|
||||||
|
label: 'Списки',
|
||||||
|
keywords: ['lists', 'списки', 'blocklist'],
|
||||||
|
icon: ListIcon,
|
||||||
|
section: 'ops',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/rules',
|
||||||
|
label: 'Наборы правил',
|
||||||
|
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
|
||||||
|
icon: ShieldIcon,
|
||||||
|
section: 'ops',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/stats',
|
||||||
|
label: 'Статистика',
|
||||||
|
keywords: ['stats', 'статистика', 'packets'],
|
||||||
|
icon: BarChart3Icon,
|
||||||
|
section: 'ops',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/settings',
|
||||||
|
label: 'Настройки',
|
||||||
|
keywords: ['settings', 'настройки'],
|
||||||
|
icon: SettingsIcon,
|
||||||
|
section: 'system',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NAV_SECTIONS = [
|
||||||
|
{ id: 'overview', label: 'Обзор' },
|
||||||
|
{ id: 'ops', label: 'Операции' },
|
||||||
|
{ id: 'system', label: 'Система' },
|
||||||
|
] as const satisfies readonly { id: NavSectionId; label: string }[]
|
||||||
|
|
||||||
|
export function navItemsForSection(section: NavSectionId): readonly NavItem[] {
|
||||||
|
return NAV_ITEMS.filter((item) => item.section === section)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navLabel(to: string): string | undefined {
|
||||||
|
return NAV_ITEMS.find((item) => item.to === to)?.label
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detail routes (…/:id) that nest under a nav route. */
|
||||||
|
const DETAIL_PARENT_RE = /^\/(agents|lists|rules)\/[^/]+$/
|
||||||
|
|
||||||
|
export function navParentForDetail(
|
||||||
|
pathname: string,
|
||||||
|
): string | undefined {
|
||||||
|
const m = pathname.match(DETAIL_PARENT_RE)
|
||||||
|
return m ? `/${m[1]}` : undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { hasPermission } from '@evofw/shared'
|
||||||
|
import { getClaims, isAuthEnabled } from './auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side RBAC gating. The backend remains authoritative; this only hides
|
||||||
|
* actions the current portal user cannot perform (fw:<section>:<read|write|admin>).
|
||||||
|
* When auth is disabled (dev) everything is allowed.
|
||||||
|
*/
|
||||||
|
export function can(permission: string): boolean {
|
||||||
|
if (!isAuthEnabled()) return true
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!claims) return false
|
||||||
|
if (claims.is_admin) return true
|
||||||
|
return hasPermission(claims.permissions ?? [], permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCan() {
|
||||||
|
return can
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
|||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
|
import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
|
import { useCan } from '@/lib/permissions'
|
||||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import {
|
import {
|
||||||
@@ -97,6 +98,7 @@ function AgentsPage() {
|
|||||||
const agentsQ = useQuery(agentsQueryOptions())
|
const agentsQ = useQuery(agentsQueryOptions())
|
||||||
const settingsQ = useQuery(settingsQueryOptions())
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
const { copyToClipboard } = useCopyToClipboard()
|
const { copyToClipboard } = useCopyToClipboard()
|
||||||
|
const canWrite = useCan()('fw:agents:write')
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
|
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
@@ -418,12 +420,12 @@ function AgentsPage() {
|
|||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
)
|
)
|
||||||
|
|
||||||
const addButton = (
|
const addButton = canWrite ? (
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
<Plus data-icon="inline-start" />
|
<Plus data-icon="inline-start" />
|
||||||
Добавить агента
|
Добавить агента
|
||||||
</Button>
|
</Button>
|
||||||
)
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -454,6 +456,7 @@ function AgentsPage() {
|
|||||||
>
|
>
|
||||||
Показать
|
Показать
|
||||||
</Button>
|
</Button>
|
||||||
|
{canWrite ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={
|
disabled={
|
||||||
@@ -464,6 +467,7 @@ function AgentsPage() {
|
|||||||
<Check data-icon="inline-start" />
|
<Check data-icon="inline-start" />
|
||||||
Approve all
|
Approve all
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries'
|
import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
|
import { useCan } from '@/lib/permissions'
|
||||||
import {
|
import {
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
AutocompleteContent,
|
AutocompleteContent,
|
||||||
@@ -67,6 +68,7 @@ const CREATE_SOURCE_ITEMS = [
|
|||||||
function ListsPage() {
|
function ListsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const canWrite = useCan()('fw:lists:write')
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [source, setSource] = useState<CreateSource>('static')
|
const [source, setSource] = useState<CreateSource>('static')
|
||||||
@@ -199,10 +201,12 @@ function ListsPage() {
|
|||||||
/>
|
/>
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
{canWrite ? (
|
||||||
<Plus data-icon="inline-start" />
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
Создать
|
<Plus data-icon="inline-start" />
|
||||||
</Button>
|
Создать
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
|||||||
import { PolicySetIcon } from '@/components/rules/policy-set-icon'
|
import { PolicySetIcon } from '@/components/rules/policy-set-icon'
|
||||||
import { policySetsQueryOptions } from '@/queries'
|
import { policySetsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
|
import { useCan } from '@/lib/permissions'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
import { Input } from '@evofw/ui/components/input'
|
||||||
@@ -200,12 +201,13 @@ function PolicySetsPage() {
|
|||||||
[navigate],
|
[navigate],
|
||||||
)
|
)
|
||||||
|
|
||||||
const addButton = (
|
const canCreate = useCan()('fw:policies:write')
|
||||||
|
const addButton = canCreate ? (
|
||||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||||
<Plus data-icon="inline-start" />
|
<Plus data-icon="inline-start" />
|
||||||
Новый набор
|
Новый набор
|
||||||
</Button>
|
</Button>
|
||||||
)
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import { LoadingButton } from '@/components/loading-button'
|
|||||||
import { SettingRow } from '@/components/setting-row'
|
import { SettingRow } from '@/components/setting-row'
|
||||||
import { settingsQueryOptions } from '@/queries'
|
import { settingsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
|
import { useCan } from '@/lib/permissions'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
import { Input } from '@evofw/ui/components/input'
|
||||||
import { Switch } from '@evofw/ui/components/switch'
|
import { Switch } from '@evofw/ui/components/switch'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Control plane settings — single page: PageShell + Frame + SettingRow.
|
* Control plane settings — single page: PageShell + Frame + SettingRow.
|
||||||
* SettingsShell (reui-kit) — только при 2+ секциях.
|
|
||||||
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ export const Route = createFileRoute('/_auth/settings')({
|
|||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const settingsQ = useQuery(settingsQueryOptions())
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
|
const canSave = useCan()('fw:settings:admin')
|
||||||
const [form, setForm] = useState<Record<string, string>>({})
|
const [form, setForm] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
// Seed the form once — a background refetch must not wipe in-progress edits.
|
// Seed the form once — a background refetch must not wipe in-progress edits.
|
||||||
@@ -134,16 +135,18 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
<div className="flex justify-end">
|
{canSave ? (
|
||||||
<LoadingButton
|
<div className="flex justify-end">
|
||||||
onClick={() => save.mutate()}
|
<LoadingButton
|
||||||
isLoading={save.isPending}
|
onClick={() => save.mutate()}
|
||||||
disabled={!initialized.current}
|
isLoading={save.isPending}
|
||||||
loadingLabel="Сохранение…"
|
disabled={!initialized.current}
|
||||||
>
|
loadingLabel="Сохранение…"
|
||||||
Сохранить
|
>
|
||||||
</LoadingButton>
|
Сохранить
|
||||||
</div>
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user