Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39f1295438 |
@@ -1 +1 @@
|
||||
engine-strict=true
|
||||
store-dir=C:\Users\shats\AppData\Local\pnpm-test-store\store\v10
|
||||
|
||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import globals from 'globals'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
|
||||
{ ignores: ['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { CardItem } from "./card-item"
|
||||
import { CARDS } from "./data"
|
||||
|
||||
export function CardGrid() {
|
||||
return (
|
||||
<div className="@container w-full">
|
||||
{/* Grid */}
|
||||
<div className="grid gap-5 @2xl:grid-cols-3">
|
||||
{CARDS.map((card) => (
|
||||
<CardItem key={card.label} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
} from "@/components/reui/frame"
|
||||
import { ICard } from "./data"
|
||||
import { LinkIcon } from "lucide-react"
|
||||
|
||||
export function CardItem({ card }: { card: ICard }) {
|
||||
return (
|
||||
<Frame spacing="sm">
|
||||
{/* Header */}
|
||||
<FrameHeader className="px-1! py-1!">
|
||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||
{card.icon}
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
{/* Content */}
|
||||
<FramePanel className="space-y-3.5">
|
||||
<p className="text-xs leading-relaxed">{card.description}</p>
|
||||
<a
|
||||
href="#"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
<LinkIcon aria-hidden="true" className="size-2.5 shrink-0" />
|
||||
{card.link}
|
||||
</a>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { PackageIcon, TrendingUp, MapPinIcon } from "lucide-react"
|
||||
|
||||
export interface ICard {
|
||||
label: string
|
||||
icon: ReactNode
|
||||
description: string
|
||||
link: string
|
||||
}
|
||||
|
||||
export const CARDS: ICard[] = [
|
||||
{
|
||||
label: "Binance",
|
||||
icon: (
|
||||
<PackageIcon aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Track trading volumes, liquidity shifts, and price movements for informed decisions",
|
||||
link: "https://www.binance.com/en/markets/over..",
|
||||
},
|
||||
{
|
||||
label: "Revenue",
|
||||
icon: (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Get instant insights into earnings and cash flow performance.",
|
||||
link: "https://nexo.io/earn/crypto-detailed-portfol..",
|
||||
},
|
||||
{
|
||||
label: "Shipments",
|
||||
icon: (
|
||||
<MapPinIcon aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Stay on top of deliveries and track shipment statuses efficiently.",
|
||||
link: "https://www.educare.io/platform/analytics/e..",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { CardGrid } from "./components/card-grid"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full max-w-5xl items-center justify-center p-6">
|
||||
<CardGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Cell, Pie, PieChart } from "recharts"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@evobgp/ui/components/chart"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
allocationMemberCount,
|
||||
allocationMembers,
|
||||
allocationPeriods,
|
||||
inflowChartConfig,
|
||||
inflowPeriods,
|
||||
SEGMENT_COUNT,
|
||||
type AllocationPeriod,
|
||||
type InflowFund,
|
||||
type InflowPeriod,
|
||||
} from "./data"
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
const segments = Array.from({ length: SEGMENT_COUNT }, (_, index) => index)
|
||||
|
||||
const CHART_REVEAL_STYLE = `
|
||||
@keyframes dashboard-1-flow-reveal-up {
|
||||
from {
|
||||
clip-path: inset(100% 0 0 0);
|
||||
opacity: 0.75;
|
||||
}
|
||||
to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-1-flow-reveal-up {
|
||||
animation: dashboard-1-flow-reveal-up 680ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-1-flow-reveal-up {
|
||||
animation: none;
|
||||
clip-path: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
function AllocationMeter({ period }: { period: AllocationPeriod }) {
|
||||
return (
|
||||
<div
|
||||
aria-label={`${period.label} capacity allocation is ${period.allocation}`}
|
||||
className="flex h-7 w-full items-stretch justify-between"
|
||||
role="img"
|
||||
>
|
||||
{segments.map((segment) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
key={segment}
|
||||
className={cn(
|
||||
"h-full w-1 shrink-0 rounded-full",
|
||||
segment < period.filledSegments ? "bg-success" : "bg-muted"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberStack() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<AvatarGroup className="-space-x-2">
|
||||
{allocationMembers.map((member) => (
|
||||
<Avatar key={member.name} className="size-6">
|
||||
{member.avatar ? (
|
||||
<AvatarImage src={member.avatar} alt={member.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="bg-background text-xs font-medium">
|
||||
{member.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{allocationMemberCount} Members
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AllocationChart() {
|
||||
return (
|
||||
<Frame className="@container h-full w-full">
|
||||
<FramePanel>
|
||||
<Tabs
|
||||
defaultValue={allocationPeriods[0].value}
|
||||
className="h-full w-full min-w-0 gap-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h2 className="text-sm font-medium">Capacity Allocation</h2>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground/70 hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex shrink-0 rounded-full p-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label="Capacity Allocation info"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="max-w-56 px-2.5 py-1.5 text-xs leading-5"
|
||||
>
|
||||
Fulfillment capacity by selected period.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<TabsList>
|
||||
{allocationPeriods.map((period) => (
|
||||
<TabsTrigger key={period.value} value={period.value}>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{allocationPeriods.map((period) => (
|
||||
<TabsContent
|
||||
key={period.value}
|
||||
value={period.value}
|
||||
className="mt-0"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Metric */}
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="text-[26px] font-medium">
|
||||
{period.allocation}
|
||||
</span>
|
||||
<span className="text-success text-xs font-medium">
|
||||
{period.delta}
|
||||
</span>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
{period.comparison}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<AllocationMeter period={period} />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
|
||||
<p>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
Queued Orders:
|
||||
</span>{" "}
|
||||
<span className="text-sm font-medium">
|
||||
{period.exposure}
|
||||
</span>
|
||||
</p>
|
||||
<MemberStack />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
type DonutSlice =
|
||||
| InflowFund
|
||||
| {
|
||||
key: "reserve"
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
function getDonutData(period: InflowPeriod) {
|
||||
const trackedShare = period.funds.reduce(
|
||||
(total, fund) => total + fund.share,
|
||||
0
|
||||
)
|
||||
const reserveShare = Math.max(100 - trackedShare, 0)
|
||||
|
||||
return [
|
||||
...period.funds,
|
||||
{
|
||||
key: "reserve",
|
||||
name: "Reserve Capacity",
|
||||
amount: "",
|
||||
share: reserveShare,
|
||||
color: "var(--muted)",
|
||||
fill: "var(--color-reserve)",
|
||||
},
|
||||
] satisfies DonutSlice[]
|
||||
}
|
||||
|
||||
function ChartTooltipFormatter(item: unknown) {
|
||||
const fund = item as DonutSlice
|
||||
const value = fund.key === "reserve" ? `${fund.share}%` : fund.amount
|
||||
|
||||
return (
|
||||
<div className="flex min-w-40 items-center justify-between gap-6">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground truncate">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-foreground font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoTooltip() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="About Decision Flow"
|
||||
className="text-muted-foreground/70 -my-1"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<InfoIcon aria-hidden="true" className="text-sm" data-icon="inline-start" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<p>Tracked decisions entering fulfillment lanes.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowDonut({ period }: { period: InflowPeriod }) {
|
||||
const chartData = getDonutData(period)
|
||||
|
||||
return (
|
||||
<div className="dashboard-1-flow-reveal-up relative size-[8.25rem] shrink-0">
|
||||
<ChartContainer
|
||||
aria-label={`Decision Flow: ${period.total} total for ${period.label}`}
|
||||
className="aspect-square size-[8.25rem]"
|
||||
config={inflowChartConfig}
|
||||
initialDimension={{ width: 132, height: 132 }}
|
||||
>
|
||||
<PieChart margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
wrapperStyle={{ zIndex: 30 }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
hideIndicator
|
||||
formatter={(_value, _name, item) =>
|
||||
ChartTooltipFormatter(item.payload)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="share"
|
||||
endAngle={-230}
|
||||
innerRadius={47}
|
||||
isAnimationActive={false}
|
||||
nameKey="name"
|
||||
outerRadius={62}
|
||||
paddingAngle={1}
|
||||
cornerRadius={3}
|
||||
startAngle={130}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{chartData.map((item) => (
|
||||
<Cell key={item.key} fill={item.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="bg-background/90 border-border/70 flex size-[5.25rem] flex-col items-center justify-center rounded-full border border-dashed">
|
||||
<span className="text-muted-foreground/70 text-xs">Flow</span>
|
||||
<span className="mt-0.5 text-sm font-semibold">{period.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowLegend({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<ul className="flex min-w-0 flex-1 flex-col">
|
||||
{period.funds.map((fund, index) => (
|
||||
<li key={fund.key}>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{fund.amount}</span>
|
||||
<span className="text-muted-foreground/70 w-8 text-right text-xs">
|
||||
{fund.share}%
|
||||
</span>
|
||||
</div>
|
||||
{index < period.funds.length - 1 ? (
|
||||
<Separator className="w-auto" />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowPeriodPanel({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<div className="grid gap-6 @sm:grid-cols-[8.25rem_minmax(0,1fr)] @sm:items-center">
|
||||
<InflowDonut period={period} />
|
||||
<InflowLegend period={period} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowChart() {
|
||||
return (
|
||||
<TooltipProvider delay={150}>
|
||||
<style>{CHART_REVEAL_STYLE}</style>
|
||||
<Frame className="@container h-full w-full">
|
||||
<FramePanel className="ps-3.5! pe-5! pt-5! pb-3.5!">
|
||||
<Tabs defaultValue="week" className="gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-0.5 ps-1.5">
|
||||
<h2 className="text-sm font-medium">Decision Flow</h2>
|
||||
<InfoTooltip />
|
||||
</div>
|
||||
|
||||
<TabsList className="w-full @sm:w-auto">
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsTrigger key={period.value} value={period.value}>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsContent
|
||||
key={period.value}
|
||||
value={period.value}
|
||||
className="mt-0"
|
||||
>
|
||||
<InflowPeriodPanel period={period} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<div className="@container grid h-full w-full min-w-0 auto-rows-fr gap-3">
|
||||
<AllocationChart />
|
||||
<InflowChart />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
|
||||
import { FULFILLMENT_CARDS, type FulfillmentCard } from "./data"
|
||||
|
||||
function CardItem({ card }: { card: FulfillmentCard }) {
|
||||
return (
|
||||
<FramePanel>
|
||||
{/* Heading */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Item
|
||||
className={cn(
|
||||
"p-0",
|
||||
"border-background flex size-10 items-center justify-center border-2 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white",
|
||||
card.iconBg
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.typeLabel}
|
||||
</p>
|
||||
<h3 className="text-sm leading-tight font-medium">{card.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-1.5">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.metricLabel}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xl font-medium tracking-tight">
|
||||
{card.balance}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
card.change.positive ? "text-teal-600" : "text-rose-600"
|
||||
)}
|
||||
>
|
||||
{card.change.percent} ({card.change.amount})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
{/* Grid */}
|
||||
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
|
||||
{FULFILLMENT_CARDS.map((card) => (
|
||||
<CardItem key={card.title} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameFooter,
|
||||
FramePanel,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Progress } from "@evobgp/ui/components/progress"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
PERFORMANCE_RANGE_OPTIONS,
|
||||
SHIFT_ACTIVITY,
|
||||
SHIFT_PERFORMANCE,
|
||||
SHIFT_PIPELINE_PROGRESS,
|
||||
} from "./data"
|
||||
import { TrendingUp, TrendingDown, CircleCheckIcon } from "lucide-react"
|
||||
|
||||
export function InvestorCard() {
|
||||
return (
|
||||
<Frame className="h-full w-full">
|
||||
{/* Content */}
|
||||
<FramePanel>
|
||||
<div className="mb-6 flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h3 className="text-base font-semibold">Shift Performance</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select defaultValue="today" items={PERFORMANCE_RANGE_OPTIONS}>
|
||||
<SelectTrigger className="h-8! w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
align="start"
|
||||
alignItemWithTrigger={false}
|
||||
className="w-28"
|
||||
>
|
||||
{PERFORMANCE_RANGE_OPTIONS.map((range) => (
|
||||
<SelectItem key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SHIFT_PERFORMANCE.map((item) => (
|
||||
<div
|
||||
className="flex flex-col items-start justify-start"
|
||||
key={item.label}
|
||||
>
|
||||
<div className="text-foreground text-xl font-bold">
|
||||
{item.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
{item.label}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 text-xs font-semibold [&_svg]:h-3 [&_svg]:w-3",
|
||||
item.trend === "positive"
|
||||
? "text-emerald-500"
|
||||
: "text-destructive"
|
||||
)}
|
||||
>
|
||||
{item.trend === "positive" ? (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
) : (
|
||||
<TrendingDown aria-hidden="true" />
|
||||
)}
|
||||
{item.delta}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
Pipeline Progress
|
||||
</span>
|
||||
<span className="text-foreground text-xs font-semibold">
|
||||
{SHIFT_PIPELINE_PROGRESS}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={SHIFT_PIPELINE_PROGRESS}
|
||||
className="h-1! **:data-[slot=progress-track]:h-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="text-foreground mb-2.5 text-sm font-medium">
|
||||
Recent Activity
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{SHIFT_ACTIVITY.map((activity) => (
|
||||
<li
|
||||
key={activity.id}
|
||||
className="flex items-center justify-between gap-2.5 text-sm"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<CircleCheckIcon className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
activity.tone === "success" && "text-emerald-500",
|
||||
activity.tone === "info" && "text-sky-500",
|
||||
activity.tone === "warning" && "text-amber-500"
|
||||
)} aria-hidden="true" />
|
||||
<span className="text-foreground truncate text-xs">
|
||||
{activity.title}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
activity.tone === "success"
|
||||
? "success-light"
|
||||
: activity.tone === "info"
|
||||
? "info-light"
|
||||
: "warning-light"
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
{activity.status}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
{/* Footer */}
|
||||
<FrameFooter className="flex-row items-center gap-2.5 p-2!">
|
||||
<Button variant="outline" className="flex-1">
|
||||
Schedule
|
||||
</Button>
|
||||
<Button className="flex-1">Full Report</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Chart as CapacityChart } from "./capacity-chart"
|
||||
import { Chart as ChartCards } from "./chart-cards"
|
||||
import { InvestorCard as CommanderCard } from "./commander-card"
|
||||
import { ExceptionGrid } from "./exception-grid"
|
||||
import { Navbar } from "./navbar"
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-2">
|
||||
<Navbar />
|
||||
|
||||
<section aria-label="Fulfillment metrics">
|
||||
<ChartCards />
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Fulfillment operations"
|
||||
className="grid min-w-0 items-stretch gap-3 @5xl:grid-cols-2"
|
||||
>
|
||||
<div className="flex min-w-0">
|
||||
<CommanderCard />
|
||||
</div>
|
||||
<div className="flex min-w-0">
|
||||
<CapacityChart />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="Fulfillment exception queue">
|
||||
<ExceptionGrid />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { type BadgeProps } from "@/components/reui/badge"
|
||||
|
||||
import { type ChartConfig } from "@evobgp/ui/components/chart"
|
||||
import { PackageIcon, TruckIcon, TriangleAlertIcon, BotIcon } from "lucide-react"
|
||||
|
||||
export type FulfillmentStatus = "On Time" | "At Risk" | "Delayed" | "Blocked"
|
||||
export type AutomationLevel = "Autopilot" | "Copilot" | "Manual"
|
||||
|
||||
export interface TeamMember {
|
||||
name: string
|
||||
initials: string
|
||||
avatar: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface FulfillmentException {
|
||||
id: string
|
||||
reference: string
|
||||
customer: string
|
||||
email: string
|
||||
avatar: string
|
||||
initials: string
|
||||
lane: string
|
||||
facility: string
|
||||
stage: string
|
||||
promise: string
|
||||
slaMinutes: number
|
||||
automation: AutomationLevel
|
||||
owner: string
|
||||
units: number
|
||||
value: number
|
||||
risk: string
|
||||
status: FulfillmentStatus
|
||||
}
|
||||
|
||||
export const STATUS_ORDER: FulfillmentStatus[] = [
|
||||
"On Time",
|
||||
"At Risk",
|
||||
"Delayed",
|
||||
"Blocked",
|
||||
]
|
||||
|
||||
export const STATUS_BADGE_VARIANT: Record<
|
||||
FulfillmentStatus,
|
||||
BadgeProps["variant"]
|
||||
> = {
|
||||
"On Time": "success-outline",
|
||||
"At Risk": "warning-outline",
|
||||
Delayed: "info-outline",
|
||||
Blocked: "destructive-outline",
|
||||
}
|
||||
|
||||
export const AUTOMATION_BADGE_VARIANT: Record<
|
||||
AutomationLevel,
|
||||
BadgeProps["variant"]
|
||||
> = {
|
||||
Autopilot: "success-light",
|
||||
Copilot: "info-light",
|
||||
Manual: "warning-light",
|
||||
}
|
||||
|
||||
export const NAV_MEMBERS: TeamMember[] = [
|
||||
{
|
||||
name: "Maya Singh",
|
||||
initials: "MS",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
role: "Fulfillment lead",
|
||||
},
|
||||
{
|
||||
name: "Leo Martins",
|
||||
initials: "LM",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
role: "Automation owner",
|
||||
},
|
||||
{
|
||||
name: "Nora Albright",
|
||||
initials: "NA",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
||||
role: "Capacity planner",
|
||||
},
|
||||
]
|
||||
|
||||
export const TEAM_MEMBERS = NAV_MEMBERS.map((member) => ({
|
||||
src: member.avatar,
|
||||
initials: member.initials,
|
||||
name: member.name,
|
||||
}))
|
||||
|
||||
export const TEAM_EXTRA_COUNT = 11
|
||||
|
||||
export interface FulfillmentCardChange {
|
||||
positive: boolean
|
||||
percent: string
|
||||
amount: string
|
||||
}
|
||||
|
||||
export interface FulfillmentCard {
|
||||
typeLabel: string
|
||||
title: string
|
||||
metricLabel: string
|
||||
balance: string
|
||||
change: FulfillmentCardChange
|
||||
icon: ReactNode
|
||||
iconBg: string
|
||||
}
|
||||
|
||||
export const FULFILLMENT_CARDS: FulfillmentCard[] = [
|
||||
{
|
||||
typeLabel: "Outbound",
|
||||
title: "Orders Ready",
|
||||
metricLabel: "Ready Volume",
|
||||
balance: "18,420",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+11.8%",
|
||||
amount: "1,946",
|
||||
},
|
||||
iconBg: "bg-neutral-950",
|
||||
icon: (
|
||||
<PackageIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Promise",
|
||||
title: "Same-Day SLA",
|
||||
metricLabel: "Service Level",
|
||||
balance: "94.8%",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+1.2 pts",
|
||||
amount: "shift",
|
||||
},
|
||||
iconBg: "bg-indigo-600",
|
||||
icon: (
|
||||
<TruckIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Inventory",
|
||||
title: "Stock Risk",
|
||||
metricLabel: "Blocked SKUs",
|
||||
balance: "31",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "13 fewer",
|
||||
amount: "since 06:00",
|
||||
},
|
||||
iconBg: "bg-amber-400",
|
||||
icon: (
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Policy",
|
||||
title: "AI Autopilot",
|
||||
metricLabel: "Auto Resolved",
|
||||
balance: "71.6%",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+8.4 pts",
|
||||
amount: "policy",
|
||||
},
|
||||
iconBg: "bg-cyan-600",
|
||||
icon: (
|
||||
<BotIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export type AllocationPeriod = {
|
||||
value: "week" | "month" | "year"
|
||||
label: string
|
||||
allocation: string
|
||||
delta: string
|
||||
comparison: string
|
||||
exposure: string
|
||||
filledSegments: number
|
||||
}
|
||||
|
||||
export type AllocationMember = {
|
||||
name: string
|
||||
initials: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export const SEGMENT_COUNT = 56
|
||||
export const allocationMemberCount = 6
|
||||
|
||||
export const allocationPeriods: AllocationPeriod[] = [
|
||||
{
|
||||
value: "week",
|
||||
label: "Week",
|
||||
allocation: "86%",
|
||||
delta: "+5.8%",
|
||||
comparison: "vs labor plan",
|
||||
exposure: "3,840 orders",
|
||||
filledSegments: 48,
|
||||
},
|
||||
{
|
||||
value: "month",
|
||||
label: "Month",
|
||||
allocation: "79%",
|
||||
delta: "+2.4%",
|
||||
comparison: "vs prior month",
|
||||
exposure: "18 priority lanes",
|
||||
filledSegments: 44,
|
||||
},
|
||||
{
|
||||
value: "year",
|
||||
label: "Year",
|
||||
allocation: "74%",
|
||||
delta: "+9.2%",
|
||||
comparison: "automation lift",
|
||||
exposure: "6 facilities",
|
||||
filledSegments: 41,
|
||||
},
|
||||
]
|
||||
|
||||
export const allocationMembers: AllocationMember[] = TEAM_MEMBERS.map(
|
||||
(member) => ({
|
||||
name: member.name,
|
||||
initials: member.initials,
|
||||
avatar: member.src,
|
||||
})
|
||||
)
|
||||
|
||||
export type PerformanceTrend = "positive" | "negative"
|
||||
export type ActivityTone = "success" | "info" | "warning"
|
||||
|
||||
export interface PerformanceMetric {
|
||||
label: string
|
||||
value: string
|
||||
trend: PerformanceTrend
|
||||
delta: string
|
||||
}
|
||||
|
||||
export interface ShiftActivity {
|
||||
id: string
|
||||
title: string
|
||||
time: string
|
||||
status: string
|
||||
tone: ActivityTone
|
||||
}
|
||||
|
||||
export const PERFORMANCE_RANGE_OPTIONS = [
|
||||
{ label: "Today", value: "today" },
|
||||
{ label: "Week", value: "week" },
|
||||
{ label: "Month", value: "month" },
|
||||
]
|
||||
|
||||
export const SHIFT_PERFORMANCE: PerformanceMetric[] = [
|
||||
{
|
||||
label: "Orders Cleared",
|
||||
value: "18.4k",
|
||||
trend: "positive",
|
||||
delta: "+11.8%",
|
||||
},
|
||||
{
|
||||
label: "SLA Recovery",
|
||||
value: "94.8%",
|
||||
trend: "positive",
|
||||
delta: "+1.2 pts",
|
||||
},
|
||||
{
|
||||
label: "Risk Exposure",
|
||||
value: "$128k",
|
||||
trend: "negative",
|
||||
delta: "-9.4%",
|
||||
},
|
||||
]
|
||||
|
||||
export const SHIFT_PIPELINE_PROGRESS = 76
|
||||
|
||||
export const SHIFT_ACTIVITY: ShiftActivity[] = [
|
||||
{
|
||||
id: "wave-release",
|
||||
title: "Released priority wave to dock B",
|
||||
time: "4 min ago",
|
||||
status: "Cleared",
|
||||
tone: "success",
|
||||
},
|
||||
{
|
||||
id: "carrier-reprice",
|
||||
title: "Carrier mix repriced for zone 6",
|
||||
time: "12 min ago",
|
||||
status: "Review",
|
||||
tone: "info",
|
||||
},
|
||||
{
|
||||
id: "inventory-hold",
|
||||
title: "Inventory hold isolated to 3 SKUs",
|
||||
time: "23 min ago",
|
||||
status: "Watch",
|
||||
tone: "warning",
|
||||
},
|
||||
]
|
||||
|
||||
export type InflowFundKey = "autopilot" | "copilot" | "manual" | "reserve"
|
||||
|
||||
export interface InflowFund {
|
||||
key: Exclude<InflowFundKey, "reserve">
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
export interface InflowPeriod {
|
||||
value: "week" | "month" | "year"
|
||||
label: string
|
||||
total: string
|
||||
headline: string
|
||||
description: string
|
||||
delta: string
|
||||
funds: InflowFund[]
|
||||
}
|
||||
|
||||
const inflowAutopilotColor = "oklch(0.62 0.19 149)"
|
||||
const inflowCopilotColor = "oklch(0.58 0.18 257)"
|
||||
const inflowManualColor = "oklch(0.72 0.16 78)"
|
||||
|
||||
export const inflowChartConfig = {
|
||||
flow: {
|
||||
label: "Flow",
|
||||
},
|
||||
autopilot: {
|
||||
label: "Autopilot",
|
||||
color: inflowAutopilotColor,
|
||||
},
|
||||
copilot: {
|
||||
label: "Copilot",
|
||||
color: inflowCopilotColor,
|
||||
},
|
||||
manual: {
|
||||
label: "Manual",
|
||||
color: inflowManualColor,
|
||||
},
|
||||
reserve: {
|
||||
label: "Reserve",
|
||||
color: "oklch(0.7 0.04 260)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const inflowPeriods: InflowPeriod[] = [
|
||||
{
|
||||
value: "week",
|
||||
label: "Week",
|
||||
total: "18.4k",
|
||||
headline: "Exception Flow",
|
||||
description: "Orders entering decision lanes",
|
||||
delta: "+6.2%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "9.1k",
|
||||
share: 49.5,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "5.2k",
|
||||
share: 28.3,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "2.8k",
|
||||
share: 15.2,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "month",
|
||||
label: "Month",
|
||||
total: "76.8k",
|
||||
headline: "Resolved Flow",
|
||||
description: "Completed decisions this month",
|
||||
delta: "+14.8%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "41.6k",
|
||||
share: 54.2,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "20.3k",
|
||||
share: 26.4,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "9.8k",
|
||||
share: 12.8,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "year",
|
||||
label: "Year",
|
||||
total: "812k",
|
||||
headline: "Network Flow",
|
||||
description: "Decisions across six facilities",
|
||||
delta: "+21.5%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "428k",
|
||||
share: 52.7,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "224k",
|
||||
share: 27.6,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "103k",
|
||||
share: 12.7,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const FULFILLMENT_ROWS: FulfillmentException[] = [
|
||||
{
|
||||
id: "row-1001",
|
||||
reference: "NSC-84721",
|
||||
customer: "Avery Outdoor",
|
||||
email: "ops@averyoutdoor.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&dpr=2&q=80",
|
||||
initials: "AO",
|
||||
lane: "Chicago to Austin",
|
||||
facility: "ORD-2",
|
||||
stage: "Carrier tender",
|
||||
promise: "Today 18:00",
|
||||
slaMinutes: 42,
|
||||
automation: "Copilot",
|
||||
owner: "Maya Singh",
|
||||
units: 480,
|
||||
value: 38240,
|
||||
risk: "Carrier capacity is tight after midday cutoff",
|
||||
status: "At Risk",
|
||||
},
|
||||
{
|
||||
id: "row-1002",
|
||||
reference: "NSC-84734",
|
||||
customer: "Field & Frame",
|
||||
email: "priority@fieldframe.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
|
||||
initials: "FF",
|
||||
lane: "Dallas to Phoenix",
|
||||
facility: "DFW-1",
|
||||
stage: "Pick wave",
|
||||
promise: "Today 16:30",
|
||||
slaMinutes: 88,
|
||||
automation: "Autopilot",
|
||||
owner: "Leo Martins",
|
||||
units: 310,
|
||||
value: 21480,
|
||||
risk: "Wave optimized by carton density",
|
||||
status: "On Time",
|
||||
},
|
||||
{
|
||||
id: "row-1003",
|
||||
reference: "NSC-84755",
|
||||
customer: "MetroFit Labs",
|
||||
email: "ops@metrofit.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519345182560-3f2917c472ef?w=96&h=96&dpr=2&q=80",
|
||||
initials: "ML",
|
||||
lane: "Newark to Boston",
|
||||
facility: "EWR-3",
|
||||
stage: "Inventory hold",
|
||||
promise: "Today 15:15",
|
||||
slaMinutes: -24,
|
||||
automation: "Manual",
|
||||
owner: "Nora Albright",
|
||||
units: 126,
|
||||
value: 18760,
|
||||
risk: "Lot trace requires human release",
|
||||
status: "Blocked",
|
||||
},
|
||||
{
|
||||
id: "row-1004",
|
||||
reference: "NSC-84763",
|
||||
customer: "Northline Studio",
|
||||
email: "returns@northline.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
|
||||
initials: "NS",
|
||||
lane: "Los Angeles to Seattle",
|
||||
facility: "LAX-4",
|
||||
stage: "Packing",
|
||||
promise: "Today 19:45",
|
||||
slaMinutes: 114,
|
||||
automation: "Autopilot",
|
||||
owner: "Leo Martins",
|
||||
units: 840,
|
||||
value: 52210,
|
||||
risk: "Packing line is running above plan",
|
||||
status: "On Time",
|
||||
},
|
||||
{
|
||||
id: "row-1005",
|
||||
reference: "NSC-84801",
|
||||
customer: "Urban Pantry",
|
||||
email: "supply@urbanpantry.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?w=96&h=96&dpr=2&q=80",
|
||||
initials: "UP",
|
||||
lane: "Atlanta to Miami",
|
||||
facility: "ATL-2",
|
||||
stage: "Cold chain",
|
||||
promise: "Today 17:00",
|
||||
slaMinutes: 9,
|
||||
automation: "Copilot",
|
||||
owner: "Maya Singh",
|
||||
units: 212,
|
||||
value: 30440,
|
||||
risk: "Reefer handoff needs confirmation",
|
||||
status: "Delayed",
|
||||
},
|
||||
{
|
||||
id: "row-1006",
|
||||
reference: "NSC-84819",
|
||||
customer: "Glow Market",
|
||||
email: "vip@glowmarket.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1489424731084-a5d8b219a5bb?w=96&h=96&dpr=2&q=80",
|
||||
initials: "GM",
|
||||
lane: "Las Vegas to Denver",
|
||||
facility: "LAS-1",
|
||||
stage: "Labeling",
|
||||
promise: "Tomorrow 09:20",
|
||||
slaMinutes: 312,
|
||||
automation: "Autopilot",
|
||||
owner: "Nora Albright",
|
||||
units: 94,
|
||||
value: 10920,
|
||||
risk: "No current risk",
|
||||
status: "On Time",
|
||||
},
|
||||
{
|
||||
id: "row-1007",
|
||||
reference: "NSC-84827",
|
||||
customer: "Ridge Supply",
|
||||
email: "buyers@ridgesupply.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
||||
initials: "RS",
|
||||
lane: "Portland to San Jose",
|
||||
facility: "PDX-1",
|
||||
stage: "Split shipment",
|
||||
promise: "Today 20:00",
|
||||
slaMinutes: 36,
|
||||
automation: "Copilot",
|
||||
owner: "Maya Singh",
|
||||
units: 176,
|
||||
value: 14680,
|
||||
risk: "Two SKUs short at primary node",
|
||||
status: "At Risk",
|
||||
},
|
||||
{
|
||||
id: "row-1008",
|
||||
reference: "NSC-84842",
|
||||
customer: "Casa Verde",
|
||||
email: "storeops@casaverde.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544725176-7c40e5a71c5e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "CV",
|
||||
lane: "Nashville to Charlotte",
|
||||
facility: "BNA-2",
|
||||
stage: "Dock queue",
|
||||
promise: "Today 14:30",
|
||||
slaMinutes: -51,
|
||||
automation: "Manual",
|
||||
owner: "Nora Albright",
|
||||
units: 265,
|
||||
value: 22750,
|
||||
risk: "Outbound door is constrained",
|
||||
status: "Delayed",
|
||||
},
|
||||
{
|
||||
id: "row-1009",
|
||||
reference: "NSC-84864",
|
||||
customer: "Beacon Cycle",
|
||||
email: "logistics@beaconcycle.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1552058544-f2b08422138a?w=96&h=96&dpr=2&q=80",
|
||||
initials: "BC",
|
||||
lane: "Columbus to Pittsburgh",
|
||||
facility: "CMH-1",
|
||||
stage: "Fraud review",
|
||||
promise: "Tomorrow 11:45",
|
||||
slaMinutes: 510,
|
||||
automation: "Manual",
|
||||
owner: "Maya Singh",
|
||||
units: 58,
|
||||
value: 8920,
|
||||
risk: "Payment review blocks release",
|
||||
status: "Blocked",
|
||||
},
|
||||
{
|
||||
id: "row-1010",
|
||||
reference: "NSC-84888",
|
||||
customer: "Aster Goods",
|
||||
email: "ops@astergoods.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?w=96&h=96&dpr=2&q=80",
|
||||
initials: "AG",
|
||||
lane: "Reno to Salt Lake City",
|
||||
facility: "RNO-1",
|
||||
stage: "Manifest",
|
||||
promise: "Today 22:15",
|
||||
slaMinutes: 177,
|
||||
automation: "Autopilot",
|
||||
owner: "Leo Martins",
|
||||
units: 390,
|
||||
value: 19340,
|
||||
risk: "Manifest is ready for carrier scan",
|
||||
status: "On Time",
|
||||
},
|
||||
]
|
||||
|
||||
export function fulfillmentSearchBlob(row: FulfillmentException): string {
|
||||
return [
|
||||
row.reference,
|
||||
row.customer,
|
||||
row.email,
|
||||
row.lane,
|
||||
row.facility,
|
||||
row.stage,
|
||||
row.promise,
|
||||
row.automation,
|
||||
row.owner,
|
||||
row.risk,
|
||||
row.status,
|
||||
String(row.units),
|
||||
String(row.value),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
import { memo } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { type ColumnDef, type Row } from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
AUTOMATION_BADGE_VARIANT,
|
||||
STATUS_BADGE_VARIANT,
|
||||
type AutomationLevel,
|
||||
type FulfillmentException,
|
||||
type FulfillmentStatus,
|
||||
} from "./data"
|
||||
import { PackageIcon, InfoIcon, MoreHorizontalIcon, EyeIcon, BellIcon, CopyIcon, TriangleAlertIcon } from "lucide-react"
|
||||
|
||||
const currencyCompact = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
|
||||
const numberCompact = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
|
||||
const availabilityColor: Record<FulfillmentStatus, string> = {
|
||||
"On Time": "bg-success",
|
||||
"At Risk": "bg-warning",
|
||||
Delayed: "bg-info",
|
||||
Blocked: "bg-destructive",
|
||||
}
|
||||
|
||||
const stageProgress: Record<string, number> = {
|
||||
"Carrier tender": 72,
|
||||
"Pick wave": 64,
|
||||
"Inventory hold": 28,
|
||||
Packing: 82,
|
||||
"Cold chain": 48,
|
||||
Labeling: 76,
|
||||
"Split shipment": 39,
|
||||
"Dock queue": 31,
|
||||
"Fraud review": 24,
|
||||
Manifest: 90,
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const StatusBadge = memo(function StatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: FulfillmentStatus
|
||||
}) {
|
||||
return (
|
||||
<Badge variant={STATUS_BADGE_VARIANT[status]} className="gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-1.5 rounded-full", availabilityColor[status])}
|
||||
/>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
|
||||
function AutomationBadge({ level }: { level: AutomationLevel }) {
|
||||
return <Badge variant={AUTOMATION_BADGE_VARIANT[level]}>{level}</Badge>
|
||||
}
|
||||
|
||||
const ReferenceCell = memo(function ReferenceCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<a
|
||||
href="#"
|
||||
className="text-primary truncate text-sm font-medium underline-offset-2 transition-colors hover:underline"
|
||||
aria-label={`View order ${order.reference}`}
|
||||
>
|
||||
{order.reference}
|
||||
</a>
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<span className="shrink-0">{order.facility}</span>
|
||||
<DotSeparator />
|
||||
<span className="truncate">{order.owner}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const CustomerCell = memo(function CustomerCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={order.avatar} alt={order.customer} />
|
||||
<AvatarFallback>{order.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
|
||||
availabilityColor[order.status]
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<a
|
||||
href="#"
|
||||
className="text-foreground hover:text-primary line-clamp-1 font-medium underline-offset-2 transition-colors hover:underline"
|
||||
aria-label={`View customer ${order.customer}`}
|
||||
>
|
||||
{order.customer}
|
||||
</a>
|
||||
<div
|
||||
className="text-muted-foreground line-clamp-1 text-xs"
|
||||
title={order.email}
|
||||
>
|
||||
{order.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const StageCell = memo(function StageCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
const progress = stageProgress[order.stage] ?? 50
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item render={<span />} className="w-auto shrink-0 border-0 p-0">
|
||||
<ItemMedia variant="icon" className="text-muted-foreground size-auto">
|
||||
<PackageIcon className="size-4" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-foreground min-w-0 truncate font-medium">
|
||||
{order.stage}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="bg-muted block h-1.5 min-w-16 flex-1 overflow-hidden rounded-full">
|
||||
<span
|
||||
className={cn(
|
||||
"block h-full rounded-full",
|
||||
availabilityColor[order.status]
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const LaneCell = memo(function LaneCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
|
||||
return (
|
||||
<div className="flex max-w-full min-w-0 flex-col gap-0.5">
|
||||
<span
|
||||
className="text-foreground block max-w-full min-w-0 truncate font-medium"
|
||||
title={order.lane}
|
||||
>
|
||||
{order.lane}
|
||||
</span>
|
||||
<span
|
||||
className="text-muted-foreground block max-w-full min-w-0 truncate text-xs"
|
||||
title={order.facility}
|
||||
>
|
||||
{order.facility}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const ValueCell = memo(function ValueCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const valueHint =
|
||||
row.original.value >= 30000 ? "Priority lane" : "Standard lane"
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{currencyCompact.format(row.original.value)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex size-5 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
aria-label={`Value hint for ${row.original.reference}: ${valueHint}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-48 p-2.5 text-xs leading-5">
|
||||
<div className="flex flex-col">
|
||||
<span>{valueHint}</span>
|
||||
<span className="text-background/80">
|
||||
{numberCompact.format(row.original.units)} units
|
||||
</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function StateCell({ row }: { row: Row<FulfillmentException> }) {
|
||||
const sla = row.original.slaMinutes
|
||||
const slaHint =
|
||||
sla < 0
|
||||
? `${Math.abs(sla)} min overdue`
|
||||
: sla <= 45
|
||||
? `${sla} min buffer`
|
||||
: `Due ${row.original.promise}`
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-start gap-1">
|
||||
<StatusBadge status={row.original.status} />
|
||||
<span className="text-muted-foreground max-w-full truncate text-xs">
|
||||
{slaHint}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskCell({ row }: { row: Row<FulfillmentException> }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex items-center gap-1.5 rounded-full focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
aria-label={`Risk note for ${row.original.reference}: ${row.original.risk}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
<span className="max-w-32 truncate text-xs">{row.original.risk}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs p-3 text-xs leading-5">
|
||||
{row.original.risk}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionsCell({ row }: { row: Row<FulfillmentException> }) {
|
||||
const copyReference = async () => {
|
||||
await navigator.clipboard?.writeText(row.original.reference)
|
||||
toast.success("Reference copied", {
|
||||
description: row.original.reference,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label={`Actions for ${row.original.reference}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-44">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Opening order", {
|
||||
description: row.original.reference,
|
||||
})
|
||||
}
|
||||
>
|
||||
<EyeIcon className="size-4" aria-hidden="true" />
|
||||
View order
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Owner notified", {
|
||||
description: row.original.owner,
|
||||
})
|
||||
}
|
||||
>
|
||||
<BellIcon className="size-4" aria-hidden="true" />
|
||||
Notify owner
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyReference}>
|
||||
<CopyIcon className="size-4" aria-hidden="true" />
|
||||
Copy reference
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
toast.warning("Escalation staged", {
|
||||
description: "Connect this action to your incident workflow.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<TriangleAlertIcon className="size-4" aria-hidden="true" />
|
||||
Escalate
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<FulfillmentException>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
id: "id",
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
enableSorting: false,
|
||||
size: 35,
|
||||
enableResizing: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
headerClassName: "ps-4!",
|
||||
cellClassName: "ps-4!",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "reference",
|
||||
id: "reference",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ReferenceCell row={row} />,
|
||||
size: 138,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Order",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "customer",
|
||||
id: "customer",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <CustomerCell row={row} />,
|
||||
size: 210,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: true,
|
||||
minSize: 190,
|
||||
meta: {
|
||||
headerTitle: "Customer",
|
||||
autoSize: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "lane",
|
||||
id: "lane",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <LaneCell row={row} />,
|
||||
size: 165,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Lane",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "stage",
|
||||
id: "stage",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <StageCell row={row} />,
|
||||
size: 160,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Stage",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "automation",
|
||||
id: "automation",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <AutomationBadge level={row.original.automation} />,
|
||||
size: 112,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Automation",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
id: "value",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ValueCell row={row} />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Value",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "risk",
|
||||
id: "risk",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <RiskCell row={row} />,
|
||||
size: 170,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Risk",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <StateCell row={row} />,
|
||||
size: 142,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "State",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => <ActionsCell row={row} />,
|
||||
size: 46,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGrid as ReuiDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import { Label } from "@evobgp/ui/components/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import { TooltipProvider } from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
FULFILLMENT_ROWS,
|
||||
fulfillmentSearchBlob,
|
||||
STATUS_ORDER,
|
||||
type FulfillmentStatus,
|
||||
} from "./data"
|
||||
import { columns, StatusBadge } from "./exception-columns"
|
||||
import { SearchIcon, XIcon, FilterIcon, MoreHorizontalIcon, FileDownIcon, RefreshCwIcon, SettingsIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
interface ToolbarProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (value: string) => void
|
||||
selectedStatuses: FulfillmentStatus[]
|
||||
onStatusChange: (checked: boolean, status: FulfillmentStatus) => void
|
||||
onClearFilters: () => void
|
||||
hasActiveFilters: boolean
|
||||
statusCounts: Record<string, number>
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
selectedStatuses,
|
||||
onStatusChange,
|
||||
onClearFilters,
|
||||
hasActiveFilters,
|
||||
statusCounts,
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<InputGroup className="w-full min-w-52 sm:w-60">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search orders..."
|
||||
aria-label="Search orders"
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
aria-label="Clear search"
|
||||
size="icon-xs"
|
||||
onClick={() => onSearchChange("")}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" aria-label="Filter by order status">
|
||||
<FilterIcon aria-hidden="true" />
|
||||
Status
|
||||
{selectedStatuses.length > 0 && (
|
||||
<Badge variant="info-outline">
|
||||
{selectedStatuses.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="flex w-48 flex-col gap-2.5 p-3"
|
||||
>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Filter by status
|
||||
</span>
|
||||
{STATUS_ORDER.map((status) => (
|
||||
<div key={status} className="flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
id={`status-${status.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
checked={selectedStatuses.includes(status)}
|
||||
onCheckedChange={(checked) =>
|
||||
onStatusChange(checked === true, status)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`status-${status.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-2 font-normal"
|
||||
>
|
||||
<StatusBadge status={status} />
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{statusCounts[status] ?? 0}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" aria-label="Exception queue actions">
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
Actions
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.success("Export ready", {
|
||||
description: "Exception queue export prepared.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<FileDownIcon aria-hidden="true" />
|
||||
Export CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.message("Queue refreshed", {
|
||||
description: "Live data would refresh through your API.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<RefreshCwIcon aria-hidden="true" />
|
||||
Refresh
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("View settings", {
|
||||
description: "Column and density controls are available.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
View settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExceptionGrid() {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "value", desc: true },
|
||||
])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<FulfillmentStatus[]>(
|
||||
[]
|
||||
)
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(
|
||||
columns.map((column) => column.id as string)
|
||||
)
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
risk: false,
|
||||
})
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
|
||||
const statusCounts = useMemo(
|
||||
() =>
|
||||
FULFILLMENT_ROWS.reduce(
|
||||
(acc, row) => {
|
||||
acc[row.status] = (acc[row.status] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return FULFILLMENT_ROWS.filter((row) => {
|
||||
const matchesStatus =
|
||||
!selectedStatuses.length || selectedStatuses.includes(row.status)
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
fulfillmentSearchBlob(row).includes(searchQuery.toLowerCase())
|
||||
|
||||
return matchesStatus && matchesSearch
|
||||
})
|
||||
}, [searchQuery, selectedStatuses])
|
||||
|
||||
const hasActiveFilters =
|
||||
searchQuery.trim().length > 0 || selectedStatuses.length > 0
|
||||
|
||||
const resetToFirstPage = () => {
|
||||
setPagination((current) =>
|
||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
|
||||
)
|
||||
}
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value)
|
||||
resetToFirstPage()
|
||||
}
|
||||
|
||||
const handleStatusChange = (checked: boolean, status: FulfillmentStatus) => {
|
||||
setSelectedStatuses((current) =>
|
||||
checked ? [...current, status] : current.filter((item) => item !== status)
|
||||
)
|
||||
resetToFirstPage()
|
||||
}
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSelectedStatuses([])
|
||||
setSearchQuery("")
|
||||
resetToFirstPage()
|
||||
}
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: filteredData,
|
||||
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
|
||||
getRowId: (row) => row.id,
|
||||
state: { pagination, sorting, columnOrder, columnVisibility, rowSelection },
|
||||
columnResizeMode: "onChange",
|
||||
enableRowSelection: true,
|
||||
autoResetPageIndex: false,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={200}>
|
||||
<ReuiDataGrid
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage={
|
||||
filteredData.length === 0
|
||||
? "No fulfillment exceptions match your filters."
|
||||
: undefined
|
||||
}
|
||||
tableLayout={{
|
||||
columnsPinnable: true,
|
||||
columnsResizable: true,
|
||||
columnsMovable: true,
|
||||
columnsVisibility: true,
|
||||
headerSticky: true,
|
||||
dense: true,
|
||||
}}
|
||||
tableClassNames={{
|
||||
bodyRow: "[&>td]:h-16",
|
||||
}}
|
||||
>
|
||||
<Frame variant="default" spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FrameTitle className="text-balance">Exception Queue</FrameTitle>
|
||||
<FrameDescription className="text-xs text-pretty">
|
||||
{filteredData.length} of {FULFILLMENT_ROWS.length} fulfillment
|
||||
records
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toast.info("Create exception", {
|
||||
description:
|
||||
"Connect this button to your incident intake flow.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add exception
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
<FramePanel className="bg-card p-0! shadow-none!">
|
||||
<div className="px-4 py-3">
|
||||
<Toolbar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
selectedStatuses={selectedStatuses}
|
||||
onStatusChange={handleStatusChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
statusCounts={statusCounts}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</FramePanel>
|
||||
<FrameFooter>
|
||||
<DataGridPagination />
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
</ReuiDataGrid>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { type DateRange } from "react-day-picker"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Calendar } from "@evobgp/ui/components/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { CalendarIcon, DownloadIcon } from "lucide-react"
|
||||
|
||||
type PeriodKey = "last30" | "prev30"
|
||||
|
||||
type ReportDateRange = {
|
||||
from: Date
|
||||
to: Date
|
||||
}
|
||||
|
||||
type DateRangePreset = {
|
||||
id: string
|
||||
label: string
|
||||
period: PeriodKey
|
||||
range: ReportDateRange
|
||||
}
|
||||
|
||||
const reportRange = (
|
||||
fromMonth: number,
|
||||
fromDay: number,
|
||||
toMonth: number,
|
||||
toDay: number,
|
||||
year = 2026
|
||||
): ReportDateRange => ({
|
||||
from: new Date(year, fromMonth, fromDay),
|
||||
to: new Date(year, toMonth, toDay),
|
||||
})
|
||||
|
||||
const preset = (
|
||||
id: string,
|
||||
label: string,
|
||||
period: PeriodKey,
|
||||
range: ReportDateRange
|
||||
): DateRangePreset => ({ id, label, period, range })
|
||||
|
||||
const LAST_30_RANGE = reportRange(4, 12, 5, 10)
|
||||
const PREVIOUS_30_RANGE = reportRange(3, 12, 4, 11)
|
||||
|
||||
const REPORT_RANGE_PRESETS: DateRangePreset[] = [
|
||||
preset("today", "Today", "last30", reportRange(5, 10, 5, 10)),
|
||||
preset("yesterday", "Yesterday", "last30", reportRange(5, 9, 5, 9)),
|
||||
preset("last7", "Last 7 days", "last30", reportRange(5, 4, 5, 10)),
|
||||
preset("last30", "Last 30 days", "last30", LAST_30_RANGE),
|
||||
preset("monthToDate", "Month to date", "last30", reportRange(5, 1, 5, 10)),
|
||||
preset("lastMonth", "Last month", "last30", reportRange(4, 1, 4, 31)),
|
||||
preset("yearToDate", "Year to date", "last30", reportRange(0, 1, 5, 10)),
|
||||
preset("lastYear", "Last year", "prev30", reportRange(0, 1, 11, 31, 2025)),
|
||||
]
|
||||
|
||||
const MAX_REPORT_DATE = LAST_30_RANGE.to
|
||||
|
||||
function isSameRange(first: ReportDateRange, second: DateRange) {
|
||||
const secondFrom = second.from
|
||||
const secondTo = second.to ?? second.from
|
||||
|
||||
return (
|
||||
Boolean(secondFrom && secondTo) &&
|
||||
first.from.getTime() === secondFrom?.getTime() &&
|
||||
first.to.getTime() === secondTo?.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRange(
|
||||
range: DateRange | undefined,
|
||||
fallback: ReportDateRange
|
||||
): ReportDateRange {
|
||||
if (!range?.from) return fallback
|
||||
|
||||
const from = range.from
|
||||
const to = range.to ?? range.from
|
||||
|
||||
return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from }
|
||||
}
|
||||
|
||||
function formatReportRange(range: ReportDateRange) {
|
||||
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`
|
||||
}
|
||||
|
||||
function getPeriodForRange(range: ReportDateRange) {
|
||||
const matchingPreset = getMatchingPreset(range)
|
||||
|
||||
if (matchingPreset) return matchingPreset.period
|
||||
return range.to.getTime() <= PREVIOUS_30_RANGE.to.getTime()
|
||||
? "prev30"
|
||||
: "last30"
|
||||
}
|
||||
|
||||
function getMatchingPreset(range: DateRange | undefined) {
|
||||
if (!range?.from || !range.to) return undefined
|
||||
const normalizedRange = normalizeRange(range, LAST_30_RANGE)
|
||||
|
||||
return REPORT_RANGE_PRESETS.find((preset) =>
|
||||
isSameRange(preset.range, normalizedRange)
|
||||
)
|
||||
}
|
||||
|
||||
function ReportDateRangePicker({
|
||||
period,
|
||||
onPeriodChange,
|
||||
}: {
|
||||
period: PeriodKey
|
||||
onPeriodChange: (value: PeriodKey) => void
|
||||
}) {
|
||||
const initialRange = period === "prev30" ? PREVIOUS_30_RANGE : LAST_30_RANGE
|
||||
const [open, setOpen] = useState(false)
|
||||
const [committedRange, setCommittedRange] =
|
||||
useState<ReportDateRange>(initialRange)
|
||||
const [draftRange, setDraftRange] = useState<DateRange | undefined>(
|
||||
initialRange
|
||||
)
|
||||
|
||||
const selectedPresetId = getMatchingPreset(draftRange ?? committedRange)?.id
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen) {
|
||||
setDraftRange(committedRange)
|
||||
}
|
||||
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const nextRange = normalizeRange(draftRange, committedRange)
|
||||
|
||||
setCommittedRange(nextRange)
|
||||
onPeriodChange(getPeriodForRange(nextRange))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="group/pick-date w-[250px] max-w-full justify-between leading-none font-normal tabular-nums"
|
||||
>
|
||||
<span className="truncate">
|
||||
{formatReportRange(committedRange)}
|
||||
</span>
|
||||
<CalendarIcon className="text-muted-foreground/80 group-hover/pick-date:text-foreground shrink-0 transition-colors" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-auto p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col sm:grid sm:grid-cols-[10rem_1fr]">
|
||||
<div className="border-border flex flex-wrap gap-1 border-b p-2 sm:flex-col sm:border-r sm:border-b-0">
|
||||
{REPORT_RANGE_PRESETS.map((preset) => {
|
||||
const selected = selectedPresetId === preset.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "secondary" : "ghost"}
|
||||
className={
|
||||
selected
|
||||
? "justify-start"
|
||||
: "text-muted-foreground justify-start"
|
||||
}
|
||||
onClick={() => setDraftRange(preset.range)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={draftRange}
|
||||
onSelect={setDraftRange}
|
||||
numberOfMonths={2}
|
||||
defaultMonth={draftRange?.from ?? committedRange.from}
|
||||
disabled={{
|
||||
after: MAX_REPORT_DATE,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border flex items-center justify-between gap-2 border-t px-3 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDraftRange(LAST_30_RANGE)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDraftRange(committedRange)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleApply}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
// Header action controls reused from the solution-agents-8 report toolbar.
|
||||
export function NavbarActions() {
|
||||
const [periodKey, setPeriodKey] = useState<PeriodKey>("last30")
|
||||
|
||||
function handleExport() {
|
||||
toast.success("Export queued", {
|
||||
description: "Fulfillment command report is being prepared.",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ReportDateRangePicker period={periodKey} onPeriodChange={setPeriodKey} />
|
||||
|
||||
<Button size="sm" type="button" onClick={handleExport}>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span className="hidden sm:block">Export</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@evobgp/ui/components/breadcrumb"
|
||||
|
||||
// Navbar breadcrumb
|
||||
|
||||
export function NavbarBreadcrumb() {
|
||||
return (
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Operations</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">Fulfillment</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { TEAM_EXTRA_COUNT, TEAM_MEMBERS } from "./data"
|
||||
import { UserPlusIcon } from "lucide-react"
|
||||
|
||||
// Header presence controls with team avatars and invite action.
|
||||
|
||||
export function NavbarPresence() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleInvite = () => {
|
||||
if (!email.trim()) return
|
||||
setEmail("")
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<AvatarGroup>
|
||||
{TEAM_MEMBERS.map((member, index) => (
|
||||
<Avatar key={index} size="sm">
|
||||
<AvatarImage src={member.src} alt={member.name} />
|
||||
<AvatarFallback className="text-[9px]! font-medium">
|
||||
{member.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
<AvatarGroupCount className="text-[10px]! font-medium">
|
||||
+{TEAM_EXTRA_COUNT}
|
||||
</AvatarGroupCount>
|
||||
</AvatarGroup>
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Invite team member"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent sideOffset={7} align="end" className="w-72">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="text-foreground text-sm">Invite team member</h4>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleInvite()}
|
||||
/>
|
||||
<Button onClick={handleInvite} disabled={!email.trim()}>
|
||||
Send invite
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NavbarActions } from "./navbar-actions"
|
||||
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
|
||||
|
||||
// Navbar with breadcrumb and report range actions.
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<header
|
||||
className="flex min-h-9 w-full shrink-0 items-center justify-between gap-2 pb-1"
|
||||
aria-label="Fulfillment command header"
|
||||
>
|
||||
<NavbarBreadcrumb />
|
||||
|
||||
<NavbarActions />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Dashboard } from "./components/dashboard"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main className="bg-background min-h-svh w-full p-3 sm:p-4 lg:p-6">
|
||||
<Dashboard />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Area, AreaChart, ResponsiveContainer, Tooltip } from "recharts"
|
||||
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
|
||||
import { activeUsersData, customersData, revenueData } from "./data"
|
||||
|
||||
// Business metric cards
|
||||
|
||||
const businessCards = [
|
||||
{
|
||||
title: "Revenue",
|
||||
period: "reui.io, 28 days",
|
||||
value: "$6.2K",
|
||||
timestamp: "",
|
||||
data: revenueData,
|
||||
color: "var(--color-emerald-500)",
|
||||
gradientId: "revenueGradient",
|
||||
formatValue: (value: number) => `$${(value / 1000).toFixed(1)}K`,
|
||||
},
|
||||
{
|
||||
title: "Signups",
|
||||
period: "Last 28 days",
|
||||
value: "4,238",
|
||||
timestamp: "3h ago",
|
||||
data: customersData,
|
||||
color: "var(--color-blue-500)",
|
||||
gradientId: "customersGradient",
|
||||
formatValue: (value: number) => `${(value / 1000).toFixed(1)}K`,
|
||||
},
|
||||
{
|
||||
title: "Active Licenses",
|
||||
period: "ReUI Cloud, 28 days",
|
||||
value: "4,238",
|
||||
timestamp: "1h ago",
|
||||
data: activeUsersData,
|
||||
color: "var(--color-violet-500)",
|
||||
gradientId: "usersGradient",
|
||||
formatValue: (value: number) => `${(value / 1000).toFixed(1)}K`,
|
||||
},
|
||||
]
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<div className="@container w-full max-w-6xl">
|
||||
<div className="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
|
||||
{businessCards.map((card) => (
|
||||
<Card key={card.title}>
|
||||
<CardContent className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="text-sm font-semibold">{card.title}</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="flex items-end justify-between gap-2.5">
|
||||
{/* Value */}
|
||||
<div className="flex flex-col gap-px pb-2">
|
||||
<div className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{card.period}
|
||||
</div>
|
||||
<div className="text-foreground text-xl font-semibold tracking-tight">
|
||||
{card.value}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-16 w-full max-w-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={card.data}
|
||||
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={card.gradientId}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={card.color}
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={card.color}
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: card.color,
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: "2 2",
|
||||
}}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const value = payload[0].value as number
|
||||
return (
|
||||
<Card className="bg-popover text-popover-foreground pointer-events-none p-0 shadow-md">
|
||||
<CardContent className="flex min-w-24 flex-col gap-1 px-2 py-1.5">
|
||||
<span className="text-muted-foreground text-[10px] leading-none font-medium">
|
||||
{card.title}
|
||||
</span>
|
||||
<span className="text-popover-foreground text-xs leading-none font-semibold tabular-nums">
|
||||
{card.formatValue(value)}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={card.color}
|
||||
fill={`url(#${card.gradientId})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 4,
|
||||
fill: card.color,
|
||||
stroke: "white",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { type ComponentProps } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import { type ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { type ModuleRecord, type ModuleStatus } from "./data"
|
||||
import { CalendarDaysIcon, FileTextIcon, StarIcon, MoreHorizontalIcon, CopyIcon, ArchiveIcon } from "lucide-react"
|
||||
|
||||
export type ModuleRowAction = "open" | "favorite" | "duplicate" | "archive"
|
||||
|
||||
const moduleStatusVariant: Record<
|
||||
ModuleStatus,
|
||||
ComponentProps<typeof Badge>["variant"]
|
||||
> = {
|
||||
Planned: "info-outline",
|
||||
Backlog: "outline",
|
||||
"In Progress": "warning-outline",
|
||||
}
|
||||
|
||||
const moduleStatusDotClass: Record<ModuleStatus, string> = {
|
||||
Planned: "bg-sky-500 dark:bg-sky-400",
|
||||
Backlog: "bg-muted-foreground/50",
|
||||
"In Progress": "bg-amber-500 dark:bg-amber-400",
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<span
|
||||
className="bg-muted-foreground/45 size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function getProgressToneClass(value: number) {
|
||||
if (value >= 75) return "text-emerald-500 dark:text-emerald-400"
|
||||
if (value >= 40) return "text-amber-500 dark:text-amber-400"
|
||||
if (value > 0) return "text-sky-500 dark:text-sky-400"
|
||||
|
||||
return "text-muted-foreground/35"
|
||||
}
|
||||
|
||||
function getWindowDurationLabel(module: ModuleRecord) {
|
||||
const start = new Date(module.dateStart).getTime()
|
||||
const end = new Date(module.dateEnd).getTime()
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
const days = Math.max(1, Math.round((end - start) / dayMs))
|
||||
|
||||
return `${days}-day window`
|
||||
}
|
||||
|
||||
function getCompactDateRange(module: ModuleRecord) {
|
||||
return module.dateRange.replace(/, 2026/g, "")
|
||||
}
|
||||
|
||||
function ModuleProgress({ module }: { module: ModuleRecord }) {
|
||||
const value = module.progress
|
||||
const radius = 18
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const dashOffset = circumference - (value / 100) * circumference
|
||||
const progressClassName = getProgressToneClass(value)
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="relative size-10 shrink-0">
|
||||
<svg
|
||||
viewBox="0 0 44 44"
|
||||
className="absolute inset-0 size-10 -rotate-90"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-muted-foreground/20"
|
||||
strokeWidth="3.25"
|
||||
/>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className={cn("stroke-current", progressClassName)}
|
||||
strokeWidth="3.25"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-muted-foreground absolute inset-0 flex items-center justify-center text-[9px] leading-none font-medium tabular-nums">
|
||||
{value}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground text-sm font-medium tabular-nums">
|
||||
{value}% ready
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs tabular-nums">
|
||||
{module.tasksCompleted}/{module.tasksTotal} tasks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleNameCell({ module }: { module: ModuleRecord }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-foreground truncate text-sm leading-5 font-medium">
|
||||
{module.name}
|
||||
</span>
|
||||
<div className="text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="shrink-0">{module.kind}</span>
|
||||
<DotSeparator />
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<Avatar className="size-4 shrink-0">
|
||||
{module.owner.avatar ? (
|
||||
<AvatarImage src={module.owner.avatar} alt={module.owner.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{module.owner.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">{module.owner.name}</span>
|
||||
</span>
|
||||
<DotSeparator />
|
||||
<span className="truncate font-mono tracking-wide">
|
||||
{module.domain}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleDateCell({ module }: { module: ModuleRecord }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate text-sm font-medium tabular-nums">
|
||||
{getCompactDateRange(module)}
|
||||
</span>
|
||||
<span className="text-muted-foreground inline-flex min-w-0 items-center gap-1.5 truncate text-xs">
|
||||
<CalendarDaysIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{getWindowDurationLabel(module)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleStatusCell({ module }: { module: ModuleRecord }) {
|
||||
return (
|
||||
<Badge variant={moduleStatusVariant[module.status]}>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full!",
|
||||
moduleStatusDotClass[module.status]
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{module.status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleActions({
|
||||
module,
|
||||
onAction,
|
||||
}: {
|
||||
module: ModuleRecord
|
||||
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Open ${module.name}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onAction("open", module)
|
||||
}}
|
||||
>
|
||||
<FileTextIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`${module.favorite ? "Unfavorite" : "Favorite"} ${
|
||||
module.name
|
||||
}`}
|
||||
className={cn(module.favorite && "text-amber-500")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onAction("favorite", module)
|
||||
}}
|
||||
>
|
||||
<StarIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`More actions for ${module.name}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onAction("duplicate", module)}>
|
||||
<CopyIcon aria-hidden="true" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onAction("archive", module)}
|
||||
>
|
||||
<ArchiveIcon aria-hidden="true" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function createModuleGridColumns({
|
||||
onAction,
|
||||
}: {
|
||||
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
|
||||
}): ColumnDef<ModuleRecord>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "progress",
|
||||
id: "progress",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ModuleProgress module={row.original} />,
|
||||
size: 210,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Progress",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ModuleNameCell module={row.original} />,
|
||||
minSize: 300,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
autoSize: true,
|
||||
headerTitle: "Module",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "dateStart",
|
||||
id: "dateStart",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ModuleDateCell module={row.original} />,
|
||||
sortingFn: (rowA, rowB) =>
|
||||
new Date(rowA.original.dateStart).getTime() -
|
||||
new Date(rowB.original.dateStart).getTime(),
|
||||
size: 180,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Window",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ModuleStatusCell module={row.original} />,
|
||||
size: 126,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Status",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ModuleActions module={row.original} onAction={onAction} />
|
||||
),
|
||||
size: 104,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Actions",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { Chart } from "./chart"
|
||||
import { ModulesDataGridView } from "./data-grid-view"
|
||||
import { Navbar } from "./navbar"
|
||||
|
||||
/**
|
||||
* ReUI operations dashboard: navbar -> metric charts -> module grid.
|
||||
* The sections are copied from reviewed donor blocks.
|
||||
* Customize: swap the records and chart series in data.tsx first.
|
||||
*/
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<div className="bg-background text-foreground flex min-h-svh w-full flex-col">
|
||||
<Navbar />
|
||||
|
||||
<main
|
||||
className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 p-4 pt-6 sm:p-8"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
ReUI Operations Dashboard
|
||||
</h1>
|
||||
|
||||
{/* Metric Charts */}
|
||||
<Chart />
|
||||
|
||||
{/* Module Grid */}
|
||||
<ModulesDataGridView />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import {
|
||||
DataGridTable,
|
||||
DataGridTableHeader,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import { createModuleGridColumns, type ModuleRowAction } from "./columns"
|
||||
import {
|
||||
MODULE_RECORDS,
|
||||
MODULE_STATUS_OPTIONS,
|
||||
type ModuleRecord,
|
||||
type ModuleStatus,
|
||||
} from "./data"
|
||||
import { CircleCheckIcon, FlagIcon, ChevronRightIcon, PackageIcon, SearchIcon, XIcon, ArrowUpDownIcon, ChevronDownIcon, FilterIcon } from "lucide-react"
|
||||
|
||||
type ModuleSort = "name" | "dateStart" | "progress" | "status"
|
||||
|
||||
const sortLabels: Record<ModuleSort, string> = {
|
||||
name: "Name",
|
||||
dateStart: "Window",
|
||||
progress: "Progress",
|
||||
status: "Status",
|
||||
}
|
||||
|
||||
const EMPTY_MODULE_MESSAGE = "No ReUI modules match the selected filters."
|
||||
|
||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
||||
return [{ id: sortBy, desc: false }]
|
||||
}
|
||||
|
||||
function getModuleSearchBlob(module: ModuleRecord) {
|
||||
return [
|
||||
module.name,
|
||||
module.id,
|
||||
module.kind,
|
||||
module.domain,
|
||||
module.owner.name,
|
||||
module.owner.role,
|
||||
module.health,
|
||||
module.status,
|
||||
module.dateRange,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function ModulesDataGridView() {
|
||||
const [modules, setModules] = useState<ModuleRecord[]>(MODULE_RECORDS)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<ModuleStatus[]>([])
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [sortBy, setSortBy] = useState<ModuleSort>("name")
|
||||
const [sorting, setSorting] = useState<SortingState>(() =>
|
||||
buildSorting("name")
|
||||
)
|
||||
|
||||
const filteredModules = useMemo(() => {
|
||||
const normalizedSearchQuery = searchQuery.trim().toLowerCase()
|
||||
|
||||
return modules.filter((module) => {
|
||||
const matchesSearch =
|
||||
normalizedSearchQuery.length === 0 ||
|
||||
getModuleSearchBlob(module).includes(normalizedSearchQuery)
|
||||
const matchesStatus =
|
||||
selectedStatuses.length === 0 ||
|
||||
selectedStatuses.includes(module.status)
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
}, [modules, searchQuery, selectedStatuses])
|
||||
|
||||
const activeFilterCount = selectedStatuses.length
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
pageIndex: 0,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearchQuery(value)
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const handleStatusToggle = useCallback(
|
||||
(status: ModuleStatus, checked: boolean) => {
|
||||
setSelectedStatuses((current) => {
|
||||
if (checked) {
|
||||
return current.includes(status) ? current : [...current, status]
|
||||
}
|
||||
|
||||
return current.filter((item) => item !== status)
|
||||
})
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(value: string) => {
|
||||
const nextSort = value as ModuleSort
|
||||
setSortBy(nextSort)
|
||||
setSorting(buildSorting(nextSort))
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const handleModuleAction = useCallback(
|
||||
(action: ModuleRowAction, module: ModuleRecord) => {
|
||||
if (action === "favorite") {
|
||||
setModules((current) =>
|
||||
current.map((item) =>
|
||||
item.id === module.id
|
||||
? {
|
||||
...item,
|
||||
favorite: !item.favorite,
|
||||
}
|
||||
: item
|
||||
)
|
||||
)
|
||||
toast.success(module.favorite ? "Removed favorite" : "Module starred", {
|
||||
description: module.name,
|
||||
icon: (
|
||||
<CircleCheckIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "open") {
|
||||
toast.info("Open ReUI module", {
|
||||
description: `${module.name} (${module.kind})`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast.message(
|
||||
action === "duplicate" ? "Duplicate module" : "Archive module",
|
||||
{
|
||||
description: `Connect this action to your ${module.name} flow.`,
|
||||
}
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleAddModule = () => {
|
||||
toast.success("Add ReUI module", {
|
||||
description: "Open your module creation dialog.",
|
||||
icon: (
|
||||
<CircleCheckIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => createModuleGridColumns({ onAction: handleModuleAction }),
|
||||
[handleModuleAction]
|
||||
)
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: filteredModules,
|
||||
columns,
|
||||
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getRowId: (row) => row.id,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredModules.length}
|
||||
emptyMessage={EMPTY_MODULE_MESSAGE}
|
||||
tableLayout={{
|
||||
dense: true,
|
||||
rowBorder: true,
|
||||
headerSticky: false,
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
width: "fixed",
|
||||
}}
|
||||
tableClassNames={{
|
||||
bodyRow: "group/module-row [&>td]:h-16",
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full max-w-6xl flex-col">
|
||||
<div className="flex flex-col gap-3 border-b px-0 py-3 lg:min-h-14 lg:flex-row lg:items-center lg:gap-4 lg:py-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FlagIcon className="size-3.5 shrink-0 fill-amber-400 text-amber-400" aria-hidden="true" />
|
||||
<span className="text-muted-foreground truncate text-sm">ReUI</span>
|
||||
<ChevronRightIcon className="text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
|
||||
<PackageIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
||||
<h2 className="text-foreground truncate text-sm font-medium">
|
||||
Modules
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 lg:ml-auto lg:flex-nowrap">
|
||||
<InputGroup className="w-full min-w-40 sm:w-48">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchQuery}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
placeholder="Search..."
|
||||
aria-label="Search modules"
|
||||
/>
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label="Clear search"
|
||||
onClick={() => handleSearchChange("")}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline">
|
||||
<ArrowUpDownIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{sortLabels[sortBy]}
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
<DropdownMenuGroup>
|
||||
{(["name", "dateStart", "progress", "status"] as const).map(
|
||||
(value) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => handleSortChange(value)}
|
||||
>
|
||||
{sortLabels[value]}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline">
|
||||
<FilterIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Filters
|
||||
{activeFilterCount > 0 ? (
|
||||
<Badge variant="outline" radius="full">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
{MODULE_STATUS_OPTIONS.map((status) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={status}
|
||||
checked={selectedStatuses.includes(status)}
|
||||
closeOnClick={false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleStatusToggle(status, checked === true)
|
||||
}
|
||||
>
|
||||
{status}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{activeFilterCount > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
closeOnClick={false}
|
||||
onClick={() => {
|
||||
setSelectedStatuses([])
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
Reset filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button type="button" onClick={handleAddModule}>
|
||||
Add Module
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
) : (
|
||||
<>
|
||||
<DataGridScrollArea>
|
||||
<DataGridTableHeader />
|
||||
</DataGridScrollArea>
|
||||
<div className="text-muted-foreground flex min-h-48 w-full items-center justify-center px-4 text-center text-sm">
|
||||
{EMPTY_MODULE_MESSAGE}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t px-0 py-3">
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridPagination
|
||||
sizes={[10, 15, 20]}
|
||||
info="{from} - {to} of {count} modules"
|
||||
className="py-0"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
0 modules
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
export type ModuleStatus = "Planned" | "Backlog" | "In Progress"
|
||||
|
||||
export type ModuleKind = "System" | "Feature" | "Area"
|
||||
|
||||
export type ModuleHealth = "On Track" | "Watch" | "Blocked"
|
||||
|
||||
export interface ModuleOwner {
|
||||
name: string
|
||||
initials: string
|
||||
role: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface ModuleRecord {
|
||||
id: string
|
||||
name: string
|
||||
kind: ModuleKind
|
||||
owner: ModuleOwner
|
||||
domain: string
|
||||
progress: number
|
||||
tasksCompleted: number
|
||||
tasksTotal: number
|
||||
contributors: number
|
||||
blockers: number
|
||||
health: ModuleHealth
|
||||
dateStart: string
|
||||
dateEnd: string
|
||||
dateRange: string
|
||||
status: ModuleStatus
|
||||
favorite: boolean
|
||||
}
|
||||
|
||||
export const MODULE_STATUS_OPTIONS: ModuleStatus[] = [
|
||||
"Planned",
|
||||
"Backlog",
|
||||
"In Progress",
|
||||
]
|
||||
|
||||
const moduleOwners = {
|
||||
maya: {
|
||||
name: "Nora Vale",
|
||||
initials: "NV",
|
||||
role: "ReUI release lead",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
jonah: {
|
||||
name: "Jonah Lee",
|
||||
initials: "JL",
|
||||
role: "ReUI product ops",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
nina: {
|
||||
name: "Nina Santos",
|
||||
initials: "NS",
|
||||
role: "Docs owner",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
elijah: {
|
||||
name: "Elijah Morgan",
|
||||
initials: "EM",
|
||||
role: "License lead",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
priya: {
|
||||
name: "Priya Shah",
|
||||
initials: "PS",
|
||||
role: "Lifecycle PM",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
omar: {
|
||||
name: "Omar Haddad",
|
||||
initials: "OH",
|
||||
role: "Trust owner",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507591064344-4c6ce005b128?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
sofia: {
|
||||
name: "Sofia Romero",
|
||||
initials: "SR",
|
||||
role: "Content lead",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
kenji: {
|
||||
name: "Kenji Tan",
|
||||
initials: "KT",
|
||||
role: "Platform lead",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
lena: {
|
||||
name: "Lena Wade",
|
||||
initials: "LW",
|
||||
role: "Developer tools",
|
||||
},
|
||||
} satisfies Record<string, ModuleOwner>
|
||||
|
||||
export const MODULE_RECORDS: ModuleRecord[] = [
|
||||
{
|
||||
id: "core-workflow",
|
||||
name: "Registry Sync",
|
||||
kind: "System",
|
||||
owner: moduleOwners.maya,
|
||||
domain: "registry.reui.io",
|
||||
progress: 25,
|
||||
tasksCompleted: 8,
|
||||
tasksTotal: 32,
|
||||
contributors: 6,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-17",
|
||||
dateEnd: "2026-05-01",
|
||||
dateRange: "Apr 17 - May 01, 2026",
|
||||
status: "Planned",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "onboarding-flow",
|
||||
name: "Pro Onboarding",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.jonah,
|
||||
domain: "pro.reui.io",
|
||||
progress: 0,
|
||||
tasksCompleted: 0,
|
||||
tasksTotal: 18,
|
||||
contributors: 4,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-19",
|
||||
dateEnd: "2026-05-03",
|
||||
dateRange: "Apr 19 - May 03, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "workspace-setup",
|
||||
name: "Docs Portal",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.nina,
|
||||
domain: "docs.reui.io",
|
||||
progress: 0,
|
||||
tasksCompleted: 2,
|
||||
tasksTotal: 14,
|
||||
contributors: 3,
|
||||
blockers: 1,
|
||||
health: "Blocked",
|
||||
dateStart: "2026-04-21",
|
||||
dateEnd: "2026-05-05",
|
||||
dateRange: "Apr 21 - May 05, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "permission-matrix",
|
||||
name: "Access Matrix",
|
||||
kind: "System",
|
||||
owner: moduleOwners.maya,
|
||||
domain: "admin.reui.io",
|
||||
progress: 42,
|
||||
tasksCompleted: 11,
|
||||
tasksTotal: 26,
|
||||
contributors: 5,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-22",
|
||||
dateEnd: "2026-05-06",
|
||||
dateRange: "Apr 22 - May 06, 2026",
|
||||
status: "In Progress",
|
||||
favorite: true,
|
||||
},
|
||||
{
|
||||
id: "billing-rules",
|
||||
name: "License Billing",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.elijah,
|
||||
domain: "billing.reui.io",
|
||||
progress: 64,
|
||||
tasksCompleted: 21,
|
||||
tasksTotal: 33,
|
||||
contributors: 7,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-18",
|
||||
dateEnd: "2026-05-02",
|
||||
dateRange: "Apr 18 - May 02, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "notification-center",
|
||||
name: "Release Notes",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.priya,
|
||||
domain: "changelog.reui.io",
|
||||
progress: 18,
|
||||
tasksCompleted: 5,
|
||||
tasksTotal: 28,
|
||||
contributors: 4,
|
||||
blockers: 2,
|
||||
health: "Blocked",
|
||||
dateStart: "2026-04-23",
|
||||
dateEnd: "2026-05-09",
|
||||
dateRange: "Apr 23 - May 09, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "audit-trail",
|
||||
name: "Trust Audit",
|
||||
kind: "System",
|
||||
owner: moduleOwners.omar,
|
||||
domain: "trust.reui.io",
|
||||
progress: 76,
|
||||
tasksCompleted: 19,
|
||||
tasksTotal: 25,
|
||||
contributors: 5,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-15",
|
||||
dateEnd: "2026-04-30",
|
||||
dateRange: "Apr 15 - Apr 30, 2026",
|
||||
status: "In Progress",
|
||||
favorite: true,
|
||||
},
|
||||
{
|
||||
id: "template-library",
|
||||
name: "Block Library",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.sofia,
|
||||
domain: "blocks.reui.io",
|
||||
progress: 33,
|
||||
tasksCompleted: 10,
|
||||
tasksTotal: 30,
|
||||
contributors: 6,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-24",
|
||||
dateEnd: "2026-05-10",
|
||||
dateRange: "Apr 24 - May 10, 2026",
|
||||
status: "Planned",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "integration-hub",
|
||||
name: "Integration Hub",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.kenji,
|
||||
domain: "integrations.reui.io",
|
||||
progress: 58,
|
||||
tasksCompleted: 14,
|
||||
tasksTotal: 24,
|
||||
contributors: 8,
|
||||
blockers: 1,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-20",
|
||||
dateEnd: "2026-05-04",
|
||||
dateRange: "Apr 20 - May 04, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "api-console",
|
||||
name: "API Console",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.lena,
|
||||
domain: "api.reui.io",
|
||||
progress: 91,
|
||||
tasksCompleted: 29,
|
||||
tasksTotal: 32,
|
||||
contributors: 4,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-12",
|
||||
dateEnd: "2026-04-26",
|
||||
dateRange: "Apr 12 - Apr 26, 2026",
|
||||
status: "In Progress",
|
||||
favorite: true,
|
||||
},
|
||||
{
|
||||
id: "role-automation",
|
||||
name: "Role Automation",
|
||||
kind: "System",
|
||||
owner: moduleOwners.maya,
|
||||
domain: "admin.reui.io",
|
||||
progress: 12,
|
||||
tasksCompleted: 3,
|
||||
tasksTotal: 25,
|
||||
contributors: 3,
|
||||
blockers: 1,
|
||||
health: "Blocked",
|
||||
dateStart: "2026-04-25",
|
||||
dateEnd: "2026-05-12",
|
||||
dateRange: "Apr 25 - May 12, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "workspace-invites",
|
||||
name: "Team Invites",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.jonah,
|
||||
domain: "teams.reui.io",
|
||||
progress: 47,
|
||||
tasksCompleted: 15,
|
||||
tasksTotal: 32,
|
||||
contributors: 5,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-19",
|
||||
dateEnd: "2026-05-06",
|
||||
dateRange: "Apr 19 - May 06, 2026",
|
||||
status: "Planned",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "release-checklist",
|
||||
name: "Release Checklist",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.nina,
|
||||
domain: "release.reui.io",
|
||||
progress: 84,
|
||||
tasksCompleted: 26,
|
||||
tasksTotal: 31,
|
||||
contributors: 7,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-16",
|
||||
dateEnd: "2026-05-01",
|
||||
dateRange: "Apr 16 - May 01, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "reporting-digest",
|
||||
name: "Usage Digest",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.priya,
|
||||
domain: "reports.reui.io",
|
||||
progress: 5,
|
||||
tasksCompleted: 2,
|
||||
tasksTotal: 38,
|
||||
contributors: 3,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-28",
|
||||
dateEnd: "2026-05-16",
|
||||
dateRange: "Apr 28 - May 16, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "security-review",
|
||||
name: "Security Review",
|
||||
kind: "System",
|
||||
owner: moduleOwners.omar,
|
||||
domain: "trust.reui.io",
|
||||
progress: 69,
|
||||
tasksCompleted: 18,
|
||||
tasksTotal: 26,
|
||||
contributors: 6,
|
||||
blockers: 2,
|
||||
health: "Blocked",
|
||||
dateStart: "2026-04-18",
|
||||
dateEnd: "2026-05-07",
|
||||
dateRange: "Apr 18 - May 07, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "help-center",
|
||||
name: "Help Center",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.sofia,
|
||||
domain: "help.reui.io",
|
||||
progress: 39,
|
||||
tasksCompleted: 9,
|
||||
tasksTotal: 23,
|
||||
contributors: 4,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-23",
|
||||
dateEnd: "2026-05-11",
|
||||
dateRange: "Apr 23 - May 11, 2026",
|
||||
status: "Planned",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "data-retention",
|
||||
name: "Data Retention",
|
||||
kind: "System",
|
||||
owner: moduleOwners.kenji,
|
||||
domain: "privacy.reui.io",
|
||||
progress: 22,
|
||||
tasksCompleted: 7,
|
||||
tasksTotal: 32,
|
||||
contributors: 5,
|
||||
blockers: 1,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-27",
|
||||
dateEnd: "2026-05-14",
|
||||
dateRange: "Apr 27 - May 14, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const revenueData = [
|
||||
{ value: 1000 },
|
||||
{ value: 4500 },
|
||||
{ value: 2000 },
|
||||
{ value: 5200 },
|
||||
{ value: 1500 },
|
||||
{ value: 6100 },
|
||||
{ value: 3000 },
|
||||
{ value: 6800 },
|
||||
{ value: 2000 },
|
||||
{ value: 1000 },
|
||||
{ value: 4000 },
|
||||
{ value: 2000 },
|
||||
{ value: 3000 },
|
||||
{ value: 2000 },
|
||||
{ value: 6238 },
|
||||
]
|
||||
|
||||
export const customersData = [
|
||||
{ value: 2000 },
|
||||
{ value: 4500 },
|
||||
{ value: 2000 },
|
||||
{ value: 5200 },
|
||||
{ value: 1500 },
|
||||
{ value: 5100 },
|
||||
{ value: 2500 },
|
||||
{ value: 6800 },
|
||||
{ value: 1800 },
|
||||
{ value: 1000 },
|
||||
{ value: 3000 },
|
||||
{ value: 2000 },
|
||||
{ value: 2700 },
|
||||
{ value: 2000 },
|
||||
{ value: 4238 },
|
||||
]
|
||||
|
||||
export const activeUsersData = [
|
||||
{ value: 2000 },
|
||||
{ value: 3500 },
|
||||
{ value: 2000 },
|
||||
{ value: 5200 },
|
||||
{ value: 1200 },
|
||||
{ value: 4100 },
|
||||
{ value: 3500 },
|
||||
{ value: 5800 },
|
||||
{ value: 2000 },
|
||||
{ value: 800 },
|
||||
{ value: 3000 },
|
||||
{ value: 1000 },
|
||||
{ value: 4000 },
|
||||
{ value: 2000 },
|
||||
{ value: 4238 },
|
||||
]
|
||||
|
||||
export type TeamMember = {
|
||||
src: string
|
||||
name: string
|
||||
initials: string
|
||||
}
|
||||
|
||||
export const TEAM_MEMBERS: TeamMember[] = [
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
name: "Mira Stone",
|
||||
initials: "MS",
|
||||
},
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
|
||||
name: "Alex Johnson",
|
||||
initials: "AJ",
|
||||
},
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
||||
name: "Sarah Chen",
|
||||
initials: "SC",
|
||||
},
|
||||
]
|
||||
|
||||
export const TEAM_EXTRA_COUNT = 8
|
||||
@@ -0,0 +1,67 @@
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { PlusIcon, MoreHorizontalIcon, CopyIcon, Share2Icon, DownloadIcon, SettingsIcon } from "lucide-react"
|
||||
|
||||
// Navbar actions with a primary module action and overflow menu.
|
||||
|
||||
export function NavbarActions() {
|
||||
const handleAddModule = () => {
|
||||
toast.success("Add Module", {
|
||||
description: "Open your module creation dialog.",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button type="button" onClick={handleAddModule}>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
<span className="sr-only md:not-sr-only">Add Module</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" aria-label="More options" />
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent sideOffset={7} align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<CopyIcon aria-hidden="true" />
|
||||
Copy link
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<Share2Icon aria-hidden="true" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
Export
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@evobgp/ui/components/breadcrumb"
|
||||
import { HouseIcon, LayoutDashboardIcon } from "lucide-react"
|
||||
|
||||
// Navbar breadcrumb
|
||||
|
||||
export function NavbarBreadcrumb() {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
{/* List */}
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#" className="flex items-center md:gap-1.5">
|
||||
<HouseIcon className="text-muted-foreground size-3.5" aria-hidden="true" />
|
||||
<span className="hidden md:block">Home</span>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
||||
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
|
||||
<LayoutDashboardIcon className="text-muted-foreground size-3.5" aria-hidden="true" />
|
||||
<span className="hidden md:block">ReUI</span>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import { TEAM_EXTRA_COUNT, TEAM_MEMBERS } from "./data"
|
||||
import { UserPlusIcon } from "lucide-react"
|
||||
|
||||
// Navbar presence with team avatars and invite
|
||||
|
||||
export function NavbarPresence() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleInvite = () => {
|
||||
if (!email.trim()) return
|
||||
setEmail("")
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* List */}
|
||||
<AvatarGroup>
|
||||
{TEAM_MEMBERS.map((member, index) => (
|
||||
<Avatar key={index} size="sm">
|
||||
<AvatarImage src={member.src} alt={member.name} />
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{member.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
<AvatarGroupCount>+{TEAM_EXTRA_COUNT}</AvatarGroupCount>
|
||||
</AvatarGroup>
|
||||
|
||||
<Separator orientation="vertical" className="my-auto h-4" />
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={<Button variant="outline" aria-label="Invite team member" />}
|
||||
>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
<span className="hidden md:block">Invite</span>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent sideOffset={7} align="end" className="w-72">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="text-foreground text-sm">Invite team member</h4>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="name@reui.io"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleInvite()}
|
||||
/>
|
||||
<Button onClick={handleInvite} disabled={!email.trim()}>
|
||||
Send invite
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NavbarActions } from "./navbar-actions"
|
||||
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
|
||||
import { NavbarPresence } from "./navbar-presence"
|
||||
|
||||
// Navbar with breadcrumb, team presence, and actions
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<header className="border-border bg-background sticky top-0 z-20 flex h-12 w-full shrink-0 items-center justify-between gap-2 border-b px-4">
|
||||
{/* Left - breadcrumb */}
|
||||
<NavbarBreadcrumb />
|
||||
|
||||
{/* Right - team presence + actions */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<NavbarPresence />
|
||||
<NavbarActions />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Dashboard } from "./components/dashboard"
|
||||
|
||||
export function Page() {
|
||||
return <Dashboard />
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
// Deterministic per-dot value so the field reads as noise, not a flat grid.
|
||||
function grain(i: number, j: number) {
|
||||
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
|
||||
return n - Math.floor(n)
|
||||
}
|
||||
|
||||
/**
|
||||
* Static dot field adapted from card-5's reviewed background effect.
|
||||
* The dots resolve from the current text color, so the surface stays
|
||||
* neutral and theme-aware without hard-coded colors.
|
||||
*/
|
||||
export function CardDotField({ className }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const GAP = 3
|
||||
const DOT = 1.5
|
||||
const BASE = 0.03
|
||||
const PEAK = 0.2
|
||||
|
||||
const draw = () => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
canvas.width = Math.round(rect.width * dpr)
|
||||
canvas.height = Math.round(rect.height * dpr)
|
||||
|
||||
ctx.fillStyle = getComputedStyle(canvas).color || "rgb(115,115,115)"
|
||||
ctx.fillRect(0, 0, 1, 1)
|
||||
const px = ctx.getImageData(0, 0, 1, 1).data
|
||||
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
ctx.fillStyle = color
|
||||
|
||||
const cols = Math.ceil(rect.width / GAP) + 1
|
||||
const rows = Math.ceil(rect.height / GAP) + 1
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const x = i * GAP
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const q = grain(i, j)
|
||||
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
|
||||
let a = (BASE + (PEAK - BASE) * q * q) * amp
|
||||
if (a > 1) a = 1
|
||||
ctx.globalAlpha = a
|
||||
ctx.fillRect(x, j * GAP, DOT, DOT)
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => draw())
|
||||
resizeObserver.observe(canvas)
|
||||
|
||||
const themeObserver = new MutationObserver(() => draw())
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style"],
|
||||
})
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
themeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
|
||||
type TooltipItem = {
|
||||
value?: unknown
|
||||
name?: unknown
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function ChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean
|
||||
payload?: readonly TooltipItem[]
|
||||
label?: string | number
|
||||
}) {
|
||||
if (!active || !payload?.length) return null
|
||||
|
||||
return (
|
||||
<Card className="bg-popover text-popover-foreground pointer-events-none p-0 shadow-md">
|
||||
<CardContent className="min-w-28 space-y-1.5 px-2 py-1.5">
|
||||
<div className="text-muted-foreground text-[10px] leading-none">
|
||||
{label}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{payload.map((item, index) => (
|
||||
<div
|
||||
key={`${String(item.name)}-${index}`}
|
||||
className="flex items-center justify-between gap-3 text-xs leading-none"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-1.5 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{String(item.name)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{Number(item.value ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { type ComponentProps } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
export type MetricTone = "danger" | "success" | "warning" | "info"
|
||||
|
||||
export type MetricCard = {
|
||||
id: string
|
||||
title: string
|
||||
label: string
|
||||
value: string
|
||||
delta: string
|
||||
deltaVariant: ComponentProps<typeof Badge>["variant"]
|
||||
detail: string
|
||||
tone: MetricTone
|
||||
sparkline: number[]
|
||||
}
|
||||
|
||||
export const metricCards: MetricCard[] = [
|
||||
{
|
||||
id: "signal-risk",
|
||||
title: "Signal Risk",
|
||||
label: "Threat Index",
|
||||
value: "Elevated",
|
||||
delta: "+12.5%",
|
||||
deltaVariant: "destructive-light",
|
||||
detail: "5 cases",
|
||||
tone: "danger",
|
||||
sparkline: [18, 21, 15, 33, 29, 35, 28, 31, 19],
|
||||
},
|
||||
{
|
||||
id: "mesh-uptime",
|
||||
title: "Mesh Uptime",
|
||||
label: "Service Health",
|
||||
value: "99.98%",
|
||||
delta: "+0.2%",
|
||||
deltaVariant: "success-light",
|
||||
detail: "58 zones",
|
||||
tone: "success",
|
||||
sparkline: [42, 40, 42, 38, 39, 41, 40, 43, 46],
|
||||
},
|
||||
{
|
||||
id: "edge-traffic",
|
||||
title: "Edge Traffic",
|
||||
label: "Scrubbed Load",
|
||||
value: "4.8 GB/s",
|
||||
delta: "-3.1%",
|
||||
deltaVariant: "warning-light",
|
||||
detail: "clean flow",
|
||||
tone: "warning",
|
||||
sparkline: [21, 27, 28, 34, 32, 35, 33, 31, 34],
|
||||
},
|
||||
{
|
||||
id: "sensor-reach",
|
||||
title: "Sensor Reach",
|
||||
label: "Global Nodes",
|
||||
value: "18,420",
|
||||
delta: "+6.8%",
|
||||
deltaVariant: "success-light",
|
||||
detail: "93 regions",
|
||||
tone: "success",
|
||||
sparkline: [25, 23, 29, 28, 26, 31, 27, 30, 29],
|
||||
},
|
||||
]
|
||||
|
||||
export const threatVectors = [
|
||||
{ name: "Bot", blocked: 34, watched: 62 },
|
||||
{ name: "Phish", blocked: 30, watched: 74 },
|
||||
{ name: "DDoS", blocked: 52, watched: 33 },
|
||||
{ name: "Inject", blocked: 22, watched: 48 },
|
||||
{ name: "Auth", blocked: 43, watched: 68 },
|
||||
{ name: "Probe", blocked: 18, watched: 58 },
|
||||
{ name: "Exfil", blocked: 56, watched: 35 },
|
||||
{ name: "Beacon", blocked: 38, watched: 64 },
|
||||
{ name: "Day0", blocked: 14, watched: 28 },
|
||||
]
|
||||
|
||||
export const networkFlow = [
|
||||
{ month: "January", api: 1820, webhook: 1640 },
|
||||
{ month: "February", api: 2340, webhook: 2160 },
|
||||
{ month: "March", api: 1960, webhook: 1880 },
|
||||
{ month: "April", api: 2780, webhook: 2540 },
|
||||
{ month: "May", api: 2100, webhook: 1920 },
|
||||
{ month: "June", api: 3120, webhook: 2880 },
|
||||
{ month: "July", api: 2540, webhook: 2320 },
|
||||
{ month: "August", api: 3480, webhook: 3160 },
|
||||
{ month: "September", api: 2860, webhook: 2580 },
|
||||
{ month: "October", api: 2420, webhook: 2140 },
|
||||
{ month: "November", api: 3240, webhook: 2960 },
|
||||
{ month: "December", api: 2680, webhook: 2440 },
|
||||
]
|
||||
|
||||
export const networkFlowSummary = {
|
||||
change: "+12.8%",
|
||||
year: "2026",
|
||||
}
|
||||
|
||||
const loadSamples = [
|
||||
72, 68, 22, 18, 61, 71, 20, 26, 31, 70, 46, 88, 39, 25, 12, 33, 18, 28, 42,
|
||||
36, 61, 68, 74, 70, 91, 32, 82, 66, 52, 76, 48, 35, 70, 62, 57, 49, 37, 58,
|
||||
71, 7, 11, 69, 34, 28, 40, 61, 17, 55, 64, 19, 63, 67,
|
||||
] as const
|
||||
|
||||
export type LoadState = "nominal" | "warm" | "critical"
|
||||
|
||||
export type LoadPoint = {
|
||||
node: string
|
||||
load: number
|
||||
state: LoadState
|
||||
}
|
||||
|
||||
export const loadDistribution: LoadPoint[] = loadSamples.map((load, index) => ({
|
||||
node: `N${String(index).padStart(2, "0")}`,
|
||||
load,
|
||||
state: load >= 86 ? "critical" : load >= 76 ? "warm" : "nominal",
|
||||
}))
|
||||
|
||||
const loadStateTotals = loadDistribution.reduce<Record<LoadState, number>>(
|
||||
(totals, point) => ({
|
||||
...totals,
|
||||
[point.state]: totals[point.state] + 1,
|
||||
}),
|
||||
{
|
||||
nominal: 0,
|
||||
warm: 0,
|
||||
critical: 0,
|
||||
}
|
||||
)
|
||||
|
||||
export const clusterLoadSummary = {
|
||||
details: [
|
||||
{
|
||||
label: "Nodes",
|
||||
value: String(loadDistribution.length),
|
||||
variant: "secondary",
|
||||
},
|
||||
{
|
||||
label: "Warm",
|
||||
value: String(loadStateTotals.warm),
|
||||
variant: "warning-light",
|
||||
},
|
||||
{
|
||||
label: "Critical",
|
||||
value: String(loadStateTotals.critical),
|
||||
variant: "destructive-light",
|
||||
},
|
||||
],
|
||||
} satisfies {
|
||||
details: Array<{
|
||||
label: string
|
||||
value: string
|
||||
variant: ComponentProps<typeof Badge>["variant"]
|
||||
}>
|
||||
}
|
||||
|
||||
export const activeThreats = [
|
||||
{
|
||||
id: "00",
|
||||
label: "API Flood",
|
||||
source: "Edge WAF",
|
||||
state: "Mitigating",
|
||||
value: 4521,
|
||||
progress: 92,
|
||||
tone: "danger",
|
||||
},
|
||||
{
|
||||
id: "01",
|
||||
label: "Mail Spoof",
|
||||
source: "Mail Relay",
|
||||
state: "Reviewing",
|
||||
value: 3102,
|
||||
progress: 64,
|
||||
tone: "warning",
|
||||
},
|
||||
{
|
||||
id: "02",
|
||||
label: "Cloud Probe",
|
||||
source: "Cloud API",
|
||||
state: "Queued",
|
||||
value: 1250,
|
||||
progress: 26,
|
||||
tone: "info",
|
||||
},
|
||||
{
|
||||
id: "03",
|
||||
label: "Mesh Beacon",
|
||||
source: "Int Node",
|
||||
state: "Watching",
|
||||
value: 420,
|
||||
progress: 9,
|
||||
tone: "success",
|
||||
},
|
||||
] satisfies Array<{
|
||||
id: string
|
||||
label: string
|
||||
source: string
|
||||
state: string
|
||||
value: number
|
||||
progress: number
|
||||
tone: MetricTone
|
||||
}>
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
|
||||
import { ChartTooltip } from "./chart-tooltip"
|
||||
import { activeThreats, clusterLoadSummary, loadDistribution } from "./data"
|
||||
import { PanelCorners, PanelHeading } from "./panel-heading"
|
||||
import { loadStateColor, toneStyles } from "./tone-styles"
|
||||
|
||||
const chartGridProps = {
|
||||
vertical: false,
|
||||
stroke: "var(--border)",
|
||||
strokeDasharray: "3 3",
|
||||
strokeOpacity: 0.75,
|
||||
}
|
||||
|
||||
export function LoadPanel() {
|
||||
return (
|
||||
<Card className="relative h-full overflow-hidden p-0">
|
||||
<PanelCorners />
|
||||
<CardContent className="flex h-full flex-col p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-sm leading-4 font-semibold">Cluster Load</h2>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-3 text-xs leading-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-zinc-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Normal
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-amber-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Warm
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-red-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Critical
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
{clusterLoadSummary.details.map((detail) => (
|
||||
<Badge
|
||||
key={detail.label}
|
||||
variant={detail.variant}
|
||||
radius="full"
|
||||
className="h-6 gap-1.5 px-2 text-xs"
|
||||
>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{detail.value}
|
||||
</span>
|
||||
<span>{detail.label}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto h-60 pt-5">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={loadDistribution}
|
||||
margin={{ top: 10, right: 0, bottom: 0, left: 0 }}
|
||||
barCategoryGap={2}
|
||||
>
|
||||
<CartesianGrid {...chartGridProps} />
|
||||
<XAxis
|
||||
dataKey="node"
|
||||
axisLine={false}
|
||||
interval={25}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis hide domain={[0, 100]} />
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--muted)" }}
|
||||
content={({ active, payload, label }) => (
|
||||
<ChartTooltip
|
||||
active={active}
|
||||
payload={payload}
|
||||
label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="load"
|
||||
name="Load"
|
||||
radius={[5, 5, 5, 5]}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{loadDistribution.map((entry) => (
|
||||
<Cell
|
||||
key={entry.node}
|
||||
fill={loadStateColor[entry.state]}
|
||||
opacity={entry.state === "nominal" ? 0.62 : 1}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActiveThreatsPanel() {
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<PanelCorners />
|
||||
<CardContent className="space-y-5 p-4">
|
||||
<PanelHeading title="Active Lanes" description="Threat Queue" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{activeThreats.map((threat) => {
|
||||
const tone = toneStyles[threat.tone]
|
||||
|
||||
return (
|
||||
<div key={threat.id} className="space-y-2.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn("size-2 shrink-0 rounded-full", tone.dot)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">
|
||||
[{threat.id}] {threat.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span>{threat.source}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
||||
/>
|
||||
<span>{threat.state}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm font-semibold tabular-nums">
|
||||
{threat.value.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted h-1.5 min-w-0 flex-1 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn("h-full rounded-full", tone.bar)}
|
||||
style={{ width: `${threat.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground w-8 text-right text-[10px] leading-none tabular-nums">
|
||||
{threat.progress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
|
||||
import { CardDotField } from "./card-dot-field"
|
||||
import { type MetricCard } from "./data"
|
||||
import { PanelCorners } from "./panel-heading"
|
||||
import { toneStyles } from "./tone-styles"
|
||||
|
||||
function Sparkline({
|
||||
values,
|
||||
color,
|
||||
}: {
|
||||
values: readonly number[]
|
||||
color: string
|
||||
}) {
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
const spread = Math.max(1, max - min)
|
||||
const points = values
|
||||
.map((value, index) => {
|
||||
const x = (index / (values.length - 1)) * 72
|
||||
const y = 28 - ((value - min) / spread) * 22
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 72 32"
|
||||
className="h-9 w-24 shrink-0 opacity-95"
|
||||
role="img"
|
||||
aria-label="Metric trend"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="miter"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MetricTile({ metric }: { metric: MetricCard }) {
|
||||
const tone = toneStyles[metric.tone]
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
||||
<PanelCorners />
|
||||
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate text-sm leading-4 font-semibold">
|
||||
{metric.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs leading-4">
|
||||
{metric.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="min-w-0 space-y-2.5">
|
||||
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight">
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={metric.deltaVariant} radius="full">
|
||||
{metric.delta}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{metric.detail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline values={metric.sparkline} color={tone.stroke} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useState } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { type DateRange } from "react-day-picker"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Calendar } from "@evobgp/ui/components/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { CalendarIcon, ShieldCheckIcon, DownloadIcon } from "lucide-react"
|
||||
|
||||
type PeriodKey = "last30" | "prev30"
|
||||
|
||||
type ReportDateRange = {
|
||||
from: Date
|
||||
to: Date
|
||||
}
|
||||
|
||||
type DateRangePreset = {
|
||||
id: string
|
||||
label: string
|
||||
period: PeriodKey
|
||||
range: ReportDateRange
|
||||
}
|
||||
|
||||
const reportRange = (
|
||||
fromMonth: number,
|
||||
fromDay: number,
|
||||
toMonth: number,
|
||||
toDay: number,
|
||||
year = 2026
|
||||
): ReportDateRange => ({
|
||||
from: new Date(year, fromMonth, fromDay),
|
||||
to: new Date(year, toMonth, toDay),
|
||||
})
|
||||
|
||||
const preset = (
|
||||
id: string,
|
||||
label: string,
|
||||
period: PeriodKey,
|
||||
range: ReportDateRange
|
||||
): DateRangePreset => ({ id, label, period, range })
|
||||
|
||||
const LAST_30_RANGE = reportRange(4, 12, 5, 10)
|
||||
const PREVIOUS_30_RANGE = reportRange(3, 12, 4, 11)
|
||||
|
||||
const REPORT_RANGE_PRESETS: DateRangePreset[] = [
|
||||
preset("today", "Today", "last30", reportRange(5, 10, 5, 10)),
|
||||
preset("yesterday", "Yesterday", "last30", reportRange(5, 9, 5, 9)),
|
||||
preset("last7", "Last 7 days", "last30", reportRange(5, 4, 5, 10)),
|
||||
preset("last30", "Last 30 days", "last30", LAST_30_RANGE),
|
||||
preset("monthToDate", "Month to date", "last30", reportRange(5, 1, 5, 10)),
|
||||
preset("lastMonth", "Last month", "last30", reportRange(4, 1, 4, 31)),
|
||||
preset("yearToDate", "Year to date", "last30", reportRange(0, 1, 5, 10)),
|
||||
preset("lastYear", "Last year", "prev30", reportRange(0, 1, 11, 31, 2025)),
|
||||
]
|
||||
|
||||
const MAX_REPORT_DATE = LAST_30_RANGE.to
|
||||
|
||||
function isSameRange(first: ReportDateRange, second: DateRange) {
|
||||
const secondFrom = second.from
|
||||
const secondTo = second.to ?? second.from
|
||||
|
||||
return (
|
||||
Boolean(secondFrom && secondTo) &&
|
||||
first.from.getTime() === secondFrom?.getTime() &&
|
||||
first.to.getTime() === secondTo?.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRange(
|
||||
range: DateRange | undefined,
|
||||
fallback: ReportDateRange
|
||||
): ReportDateRange {
|
||||
if (!range?.from) return fallback
|
||||
|
||||
const from = range.from
|
||||
const to = range.to ?? range.from
|
||||
|
||||
return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from }
|
||||
}
|
||||
|
||||
function formatReportRange(range: ReportDateRange) {
|
||||
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`
|
||||
}
|
||||
|
||||
function getPeriodForRange(range: ReportDateRange) {
|
||||
const matchingPreset = getMatchingPreset(range)
|
||||
|
||||
if (matchingPreset) return matchingPreset.period
|
||||
return range.to.getTime() <= PREVIOUS_30_RANGE.to.getTime()
|
||||
? "prev30"
|
||||
: "last30"
|
||||
}
|
||||
|
||||
function getMatchingPreset(range: DateRange | undefined) {
|
||||
if (!range?.from || !range.to) return undefined
|
||||
const normalizedRange = normalizeRange(range, LAST_30_RANGE)
|
||||
|
||||
return REPORT_RANGE_PRESETS.find((preset) =>
|
||||
isSameRange(preset.range, normalizedRange)
|
||||
)
|
||||
}
|
||||
|
||||
function ReportDateRangePicker({
|
||||
period,
|
||||
onPeriodChange,
|
||||
}: {
|
||||
period: PeriodKey
|
||||
onPeriodChange: (value: PeriodKey) => void
|
||||
}) {
|
||||
const initialRange = period === "prev30" ? PREVIOUS_30_RANGE : LAST_30_RANGE
|
||||
const [open, setOpen] = useState(false)
|
||||
const [committedRange, setCommittedRange] =
|
||||
useState<ReportDateRange>(initialRange)
|
||||
const [draftRange, setDraftRange] = useState<DateRange | undefined>(
|
||||
initialRange
|
||||
)
|
||||
|
||||
const selectedPresetId = getMatchingPreset(draftRange ?? committedRange)?.id
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen) {
|
||||
setDraftRange(committedRange)
|
||||
}
|
||||
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const nextRange = normalizeRange(draftRange, committedRange)
|
||||
|
||||
setCommittedRange(nextRange)
|
||||
onPeriodChange(getPeriodForRange(nextRange))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="group/pick-date w-[250px] max-w-full justify-between leading-none font-normal tabular-nums"
|
||||
>
|
||||
<span className="truncate">
|
||||
{formatReportRange(committedRange)}
|
||||
</span>
|
||||
<CalendarIcon className="text-muted-foreground/80 group-hover/pick-date:text-foreground shrink-0 transition-colors" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-auto p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col sm:grid sm:grid-cols-[10rem_1fr]">
|
||||
<div className="border-border flex flex-wrap gap-1 border-b p-2 sm:flex-col sm:border-r sm:border-b-0">
|
||||
{REPORT_RANGE_PRESETS.map((preset) => {
|
||||
const selected = selectedPresetId === preset.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "secondary" : "ghost"}
|
||||
className={
|
||||
selected
|
||||
? "justify-start"
|
||||
: "text-muted-foreground justify-start"
|
||||
}
|
||||
onClick={() => setDraftRange(preset.range)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={draftRange}
|
||||
onSelect={setDraftRange}
|
||||
numberOfMonths={2}
|
||||
defaultMonth={draftRange?.from ?? committedRange.from}
|
||||
disabled={{
|
||||
after: MAX_REPORT_DATE,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border flex items-center justify-between gap-2 border-t px-3 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDraftRange(LAST_30_RANGE)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDraftRange(committedRange)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleApply}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavbarActions() {
|
||||
const [periodKey, setPeriodKey] = useState<PeriodKey>("last30")
|
||||
|
||||
function handleExport() {
|
||||
toast.success("Export queued", {
|
||||
description: "Security telemetry report is being prepared.",
|
||||
icon: (
|
||||
<ShieldCheckIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ReportDateRangePicker period={periodKey} onPeriodChange={setPeriodKey} />
|
||||
|
||||
<Button size="sm" type="button" onClick={handleExport}>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span className="hidden sm:block">Export</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@evobgp/ui/components/breadcrumb"
|
||||
|
||||
export function NavbarBreadcrumb() {
|
||||
return (
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Security</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">Telemetry</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NavbarActions } from "./navbar-actions"
|
||||
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<header
|
||||
className="flex min-h-9 w-full shrink-0 items-center justify-between gap-2 pb-1"
|
||||
aria-label="Security telemetry header"
|
||||
>
|
||||
<NavbarBreadcrumb />
|
||||
|
||||
<NavbarActions />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@evobgp/ui/components/chart"
|
||||
|
||||
import { networkFlow, networkFlowSummary, threatVectors } from "./data"
|
||||
import { PanelCorners, PanelHeading } from "./panel-heading"
|
||||
|
||||
const threatChartConfig = {
|
||||
blocked: {
|
||||
label: "Blocked",
|
||||
color: "var(--color-blue-600)",
|
||||
},
|
||||
watched: {
|
||||
label: "Watched",
|
||||
color: "var(--color-sky-300)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
const flowChartConfig = {
|
||||
api: {
|
||||
label: "API Calls",
|
||||
color: "var(--color-yellow-500)",
|
||||
},
|
||||
webhook: {
|
||||
label: "Webhooks",
|
||||
color: "var(--color-emerald-500)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
const chartGridProps = {
|
||||
vertical: false,
|
||||
stroke: "var(--border)",
|
||||
strokeDasharray: "3 3",
|
||||
strokeOpacity: 0.75,
|
||||
}
|
||||
|
||||
function getChartColor(config: ChartConfig, name: string | number) {
|
||||
return config[String(name)]?.color
|
||||
}
|
||||
|
||||
function getChartLabel(config: ChartConfig, name: string | number) {
|
||||
return config[String(name)]?.label ?? String(name)
|
||||
}
|
||||
|
||||
function formatTooltipItem(
|
||||
config: ChartConfig,
|
||||
value: unknown,
|
||||
name: string | number
|
||||
) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: getChartColor(config, name) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{getChartLabel(config, name)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{Number(value ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CrosshatchPattern({
|
||||
config,
|
||||
idPrefix,
|
||||
}: {
|
||||
config: ChartConfig
|
||||
idPrefix: string
|
||||
}) {
|
||||
const entries = Object.entries(config).filter(([, value]) => value.color)
|
||||
|
||||
return (
|
||||
<>
|
||||
{entries.map(([key, { color }]) => (
|
||||
<pattern
|
||||
key={key}
|
||||
id={`${idPrefix}-${key}`}
|
||||
x="0"
|
||||
y="0"
|
||||
width="8"
|
||||
height="8"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M0,8 L8,0" stroke={color} strokeWidth="0.8" opacity="0.4" />
|
||||
<path d="M0,0 L8,8" stroke={color} strokeWidth="0.8" opacity="0.2" />
|
||||
</pattern>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThreatVectorsPanel() {
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<PanelCorners />
|
||||
<CardContent className="flex flex-col gap-5 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<PanelHeading title="Threat Vectors" description="Blocked Signals" />
|
||||
<div className="text-muted-foreground flex shrink-0 flex-wrap items-center gap-4 text-xs sm:justify-end">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-blue-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Blocked
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-sky-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Watched
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChartContainer
|
||||
config={threatChartConfig}
|
||||
className="h-56 w-full min-w-0"
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={threatVectors}
|
||||
margin={{ top: 8, right: 4, bottom: 0, left: 4 }}
|
||||
barGap={3}
|
||||
barCategoryGap={8}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="dashboard5-threat-blocked"
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="8"
|
||||
height="8"
|
||||
>
|
||||
<rect
|
||||
width="8"
|
||||
height="8"
|
||||
fill="var(--color-blocked)"
|
||||
opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M0,8 L8,0 M4,12 L12,4 M-4,4 L4,-4"
|
||||
stroke="var(--color-blocked)"
|
||||
strokeWidth="1.5"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<path
|
||||
d="M2,10 L10,2 M6,14 L14,6 M-2,6 L6,-2"
|
||||
stroke="var(--color-blocked)"
|
||||
strokeWidth="1"
|
||||
opacity="0.25"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<CartesianGrid {...chartGridProps} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis hide domain={[0, 78]} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="dot"
|
||||
className="min-w-36 gap-2"
|
||||
labelFormatter={(value) => (
|
||||
<div className="border-border/50 mb-0.5 border-b pb-2">
|
||||
<span className="text-xs font-medium">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
formatter={(value, name) =>
|
||||
formatTooltipItem(threatChartConfig, value, name)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="blocked"
|
||||
fill="url(#dashboard5-threat-blocked)"
|
||||
stroke="var(--color-blocked)"
|
||||
strokeWidth={1}
|
||||
radius={[5, 5, 5, 5]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="watched"
|
||||
fill="var(--color-watched)"
|
||||
fillOpacity={0.86}
|
||||
stroke="var(--color-watched)"
|
||||
strokeWidth={1}
|
||||
radius={[5, 5, 5, 5]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function NetworkFlowPanel() {
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<PanelCorners />
|
||||
<CardContent className="flex flex-col gap-5 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<PanelHeading title="Network Flow" description="API and webhooks" />
|
||||
<Badge variant="success-light" radius="full">
|
||||
{networkFlowSummary.change}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<ChartContainer
|
||||
config={flowChartConfig}
|
||||
className="h-56 w-full min-w-0"
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={networkFlow}
|
||||
margin={{ top: 20, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<CartesianGrid {...chartGridProps} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => String(value).slice(0, 3)}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="dot"
|
||||
className="min-w-40 gap-2.5"
|
||||
labelFormatter={(value) => (
|
||||
<div className="border-border/50 mb-0.5 border-b pb-2">
|
||||
<span className="text-xs font-medium">
|
||||
{value} {networkFlowSummary.year}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
formatter={(value, name) =>
|
||||
formatTooltipItem(flowChartConfig, value, name)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<defs>
|
||||
<CrosshatchPattern
|
||||
config={flowChartConfig}
|
||||
idPrefix="dashboard5-flow-crosshatch"
|
||||
/>
|
||||
</defs>
|
||||
<Area
|
||||
dataKey="webhook"
|
||||
type="natural"
|
||||
fill="url(#dashboard5-flow-crosshatch-webhook)"
|
||||
fillOpacity={0.5}
|
||||
stroke="var(--color-webhook)"
|
||||
stackId="a"
|
||||
strokeWidth={1.25}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
dataKey="api"
|
||||
type="natural"
|
||||
fill="url(#dashboard5-flow-crosshatch-api)"
|
||||
fillOpacity={0.5}
|
||||
stroke="var(--color-api)"
|
||||
stackId="a"
|
||||
strokeWidth={1.25}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function PanelCorners() {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="border-foreground/65 absolute top-0 left-0 size-2 border-t border-l"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="border-foreground/65 absolute right-0 bottom-0 size-2 border-r border-b"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PanelHeading({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-sm leading-4 font-semibold">{title}</h2>
|
||||
<p className="text-muted-foreground text-xs leading-4">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { metricCards } from "./data"
|
||||
import { ActiveThreatsPanel, LoadPanel } from "./load-panels"
|
||||
import { MetricTile } from "./metric-tile"
|
||||
import { Navbar } from "./navbar"
|
||||
import { NetworkFlowPanel, ThreatVectorsPanel } from "./network-panels"
|
||||
|
||||
/**
|
||||
* Dense edge security dashboard inspired by a telemetry wall.
|
||||
* The main entry only owns section order; records and panel details stay
|
||||
* in focused local files so the block remains easy to adapt.
|
||||
*/
|
||||
export function SecurityDashboard() {
|
||||
return (
|
||||
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Edge Security Telemetry
|
||||
</h1>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<section
|
||||
aria-label="Security Summary"
|
||||
className="grid grid-cols-1 gap-4 @3xl:grid-cols-2 @6xl:grid-cols-4"
|
||||
>
|
||||
{metricCards.map((metric) => (
|
||||
<MetricTile key={metric.id} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Security Flow"
|
||||
className="grid grid-cols-1 gap-4 @5xl:grid-cols-2"
|
||||
>
|
||||
<ThreatVectorsPanel />
|
||||
<NetworkFlowPanel />
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Security Load"
|
||||
className="grid grid-cols-1 gap-4 @5xl:grid-cols-[minmax(0,2fr)_minmax(300px,1fr)]"
|
||||
>
|
||||
<LoadPanel />
|
||||
<ActiveThreatsPanel />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type LoadState, type MetricTone } from "./data"
|
||||
|
||||
export const toneStyles: Record<
|
||||
MetricTone,
|
||||
{
|
||||
dot: string
|
||||
stroke: string
|
||||
bar: string
|
||||
}
|
||||
> = {
|
||||
danger: {
|
||||
dot: "bg-red-500",
|
||||
stroke: "var(--color-red-500)",
|
||||
bar: "bg-red-500",
|
||||
},
|
||||
success: {
|
||||
dot: "bg-emerald-500",
|
||||
stroke: "var(--color-emerald-500)",
|
||||
bar: "bg-emerald-500",
|
||||
},
|
||||
warning: {
|
||||
dot: "bg-amber-500",
|
||||
stroke: "var(--color-amber-500)",
|
||||
bar: "bg-amber-500",
|
||||
},
|
||||
info: {
|
||||
dot: "bg-blue-500",
|
||||
stroke: "var(--color-blue-500)",
|
||||
bar: "bg-blue-500",
|
||||
},
|
||||
}
|
||||
|
||||
export const loadStateColor: Record<LoadState, string> = {
|
||||
nominal: "var(--color-zinc-300)",
|
||||
warm: "var(--color-amber-500)",
|
||||
critical: "var(--color-red-500)",
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SecurityDashboard } from "./components/security-dashboard"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="bg-background min-h-svh w-full p-3 sm:p-4 lg:p-6"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<SecurityDashboard />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Headphones, CircleCheckIcon, SmileIcon } from "lucide-react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface CardData {
|
||||
icon: ReactNode
|
||||
iconBg: string
|
||||
value: string | number
|
||||
label: string
|
||||
info: ReactNode
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const cards: CardData[] = [
|
||||
{
|
||||
icon: (
|
||||
<Headphones aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-blue-600 dark:text-blue-400",
|
||||
value: 320,
|
||||
label: "Support Tickets",
|
||||
info: <Badge variant="info-light">12 Open, 308 Closed</Badge>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<CircleCheckIcon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-emerald-600 dark:text-emerald-400",
|
||||
value: "98%",
|
||||
label: "Resolved",
|
||||
info: <Badge variant="success-light">+2.1% this month</Badge>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<SmileIcon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-amber-600 dark:text-amber-400",
|
||||
value: "4.8",
|
||||
label: "Satisfaction Rate",
|
||||
info: <Badge variant="warning-light">Avg. (out of 5)</Badge>,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
|
||||
import { cards } from "./data"
|
||||
|
||||
export function Stats() {
|
||||
return (
|
||||
<div className="@container w-full grow">
|
||||
{/* Grid */}
|
||||
<div className="mx-auto grid max-w-5xl grow grid-cols-1 gap-5 @3xl:grid-cols-3">
|
||||
{cards.map((card, i) => (
|
||||
<Frame key={i}>
|
||||
<FramePanel className="flex flex-col items-start gap-6">
|
||||
<Item
|
||||
className={cn(
|
||||
"border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4",
|
||||
card.iconBg
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-2xl leading-none font-bold">
|
||||
{card.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{card.info}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Stats } from "./components/stats"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-10 md:p-20">
|
||||
<Stats />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
export type ActivityDotBadge = {
|
||||
label: string
|
||||
dotClass: string
|
||||
}
|
||||
|
||||
export type ActivityLightBadge = {
|
||||
label: string
|
||||
variant:
|
||||
| "primary-light"
|
||||
| "success-light"
|
||||
| "warning-light"
|
||||
| "info-light"
|
||||
| "destructive-light"
|
||||
}
|
||||
|
||||
export type FinanceActivity = {
|
||||
id: number
|
||||
user: string
|
||||
avatar: string
|
||||
action: string
|
||||
target: string
|
||||
detail: string
|
||||
badges: [ActivityDotBadge] | [ActivityDotBadge, ActivityLightBadge]
|
||||
attachment?: {
|
||||
name: string
|
||||
size: string
|
||||
}
|
||||
participants?: {
|
||||
src: string
|
||||
fallback: string
|
||||
}[]
|
||||
participantCount?: number
|
||||
actions?: {
|
||||
label: string
|
||||
variant?: "default" | "outline"
|
||||
}[]
|
||||
date: string
|
||||
dateTime: string
|
||||
}
|
||||
|
||||
export const financeActivities: FinanceActivity[] = [
|
||||
{
|
||||
id: 1,
|
||||
user: "Nadia Flores",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
action: "approved payout batch",
|
||||
target: "ACH-4182",
|
||||
detail: "$142,800 routed to 38 merchant accounts",
|
||||
badges: [
|
||||
{ label: "Payout", dotClass: "bg-emerald-500" },
|
||||
{ label: "Same Day", variant: "success-light" },
|
||||
],
|
||||
date: "5 minutes ago",
|
||||
dateTime: "2026-05-06T09:45:00+05:00",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user: "Theo Ramsey",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
action: "flagged review on",
|
||||
target: "Transfer TX-9041",
|
||||
detail: "Velocity threshold exceeded for new payee",
|
||||
badges: [
|
||||
{ label: "Risk", dotClass: "bg-amber-500" },
|
||||
{ label: "High", variant: "warning-light" },
|
||||
],
|
||||
actions: [{ label: "Review" }, { label: "Clear", variant: "outline" }],
|
||||
date: "18 minutes ago",
|
||||
dateTime: "2026-05-06T09:32:00+05:00",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
user: "Iris Chen",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=96&h=96&dpr=2&q=80",
|
||||
action: "reconciled ledger entry",
|
||||
target: "LDG-7749",
|
||||
detail: "Subscription invoice matched to bank settlement",
|
||||
badges: [{ label: "Ledger", dotClass: "bg-sky-500" }],
|
||||
attachment: {
|
||||
name: "settlement-match.csv",
|
||||
size: "48kb",
|
||||
},
|
||||
date: "42 minutes ago",
|
||||
dateTime: "2026-05-06T09:08:00+05:00",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
user: "Marcus Bell",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
||||
action: "raised limit for",
|
||||
target: "Northstar Workspace",
|
||||
detail: "Monthly card volume increased to $850K",
|
||||
badges: [{ label: "Limit Raised", dotClass: "bg-violet-500" }],
|
||||
participants: [
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
||||
fallback: "SC",
|
||||
},
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
|
||||
fallback: "MR",
|
||||
},
|
||||
],
|
||||
participantCount: 2,
|
||||
date: "1 hour ago",
|
||||
dateTime: "2026-05-06T08:50:00+05:00",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
user: "Amara Okafor",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544723795-3fb6469f5b39?w=96&h=96&dpr=2&q=80",
|
||||
action: "sent invoice reminder",
|
||||
target: "INV-2098",
|
||||
detail: "Net 7 renewal balance due tomorrow",
|
||||
badges: [
|
||||
{ label: "Invoice", dotClass: "bg-orange-500" },
|
||||
{ label: "Due Tomorrow", variant: "destructive-light" },
|
||||
],
|
||||
attachment: {
|
||||
name: "invoice-2098.pdf",
|
||||
size: "76kb",
|
||||
},
|
||||
date: "2 hours ago",
|
||||
dateTime: "2026-05-06T07:50:00+05:00",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
} from "@/components/reui/timeline"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { ButtonGroup } from "@evobgp/ui/components/button-group"
|
||||
import {
|
||||
financeActivities,
|
||||
type ActivityDotBadge,
|
||||
type FinanceActivity,
|
||||
} from "./data"
|
||||
import { PaperclipIcon, DownloadIcon } from "lucide-react"
|
||||
|
||||
function getInitials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
}
|
||||
|
||||
function ActivityTarget({ target }: { target: string }) {
|
||||
const idMatch = target.match(/^(.*?)([A-Z]+-\d+)$/)
|
||||
|
||||
if (!idMatch) {
|
||||
return <span className="font-medium">{target}</span>
|
||||
}
|
||||
|
||||
const [, prefix, id] = idMatch
|
||||
|
||||
return (
|
||||
<>
|
||||
{prefix}
|
||||
<a
|
||||
href="#"
|
||||
className="text-foreground hover:text-primary font-medium underline underline-offset-2"
|
||||
>
|
||||
{id}
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityAttachment({
|
||||
attachment,
|
||||
}: {
|
||||
attachment: NonNullable<FinanceActivity["attachment"]>
|
||||
}) {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button type="button" variant="outline" size="xs">
|
||||
<PaperclipIcon aria-hidden="true" />
|
||||
<span className="truncate">{attachment.name}</span>
|
||||
<span className="opacity-60">({attachment.size})</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
aria-label={`Download ${attachment.name}`}
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ParticipantGroup({
|
||||
participants,
|
||||
count,
|
||||
}: {
|
||||
participants: NonNullable<FinanceActivity["participants"]>
|
||||
count?: number
|
||||
}) {
|
||||
return (
|
||||
<AvatarGroup className="-space-x-1">
|
||||
{participants.map((participant) => (
|
||||
<Avatar key={participant.src} className="size-5">
|
||||
<AvatarImage src={participant.src} alt={participant.fallback} />
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{participant.fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
{count && count > 0 ? (
|
||||
<AvatarGroupCount className="size-5 text-[9px] leading-none">
|
||||
+{count}
|
||||
</AvatarGroupCount>
|
||||
) : null}
|
||||
</AvatarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function DotBadge({ badge }: { badge: ActivityDotBadge }) {
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
<span
|
||||
className={cn("size-1.5 shrink-0 rounded-full", badge.dotClass)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityStatusRow({ activity }: { activity: FinanceActivity }) {
|
||||
const [primaryBadge, secondaryBadge] = activity.badges
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<TimelineDate dateTime={activity.dateTime} className="mt-0 mb-0">
|
||||
{activity.date}
|
||||
</TimelineDate>
|
||||
<DotBadge badge={primaryBadge} />
|
||||
{secondaryBadge ? (
|
||||
<Badge variant={secondaryBadge.variant}>{secondaryBadge.label}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FinanceActivityTimeline() {
|
||||
return (
|
||||
<section
|
||||
className="w-full max-w-md"
|
||||
aria-labelledby="finance-activity-title"
|
||||
>
|
||||
<div className="mb-5 space-y-0.5">
|
||||
<h1 id="finance-activity-title" className="text-base font-semibold">
|
||||
Finance Activity
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-5">
|
||||
Payouts, risk, invoices, and ledger updates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Timeline defaultValue={financeActivities.length}>
|
||||
{financeActivities.map((activity) => (
|
||||
<TimelineItem
|
||||
key={activity.id}
|
||||
step={activity.id}
|
||||
className="group-data-[orientation=vertical]/timeline:ms-10 group-data-[orientation=vertical]/timeline:not-last:pb-4"
|
||||
>
|
||||
<TimelineHeader>
|
||||
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:top-2 group-data-[orientation=vertical]/timeline:-left-8 group-data-[orientation=vertical]/timeline:h-[calc(100%-2.25rem)] group-data-[orientation=vertical]/timeline:translate-y-6" />
|
||||
<TimelineIndicator className="size-7 overflow-hidden rounded-full border-none group-data-[orientation=vertical]/timeline:-left-8">
|
||||
<Avatar className="size-7">
|
||||
<AvatarImage src={activity.avatar} alt={activity.user} />
|
||||
<AvatarFallback className="text-[10px] font-medium">
|
||||
{getInitials(activity.user)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="min-w-0 pb-1">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm leading-5">
|
||||
<span className="text-foreground hover:text-primary font-medium">
|
||||
{activity.user}
|
||||
</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{activity.action}
|
||||
</span>{" "}
|
||||
<ActivityTarget target={activity.target} />
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-4">
|
||||
{activity.detail}
|
||||
</p>
|
||||
{(activity.attachment ||
|
||||
activity.actions?.length ||
|
||||
activity.participants?.length) && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{activity.attachment ? (
|
||||
<ActivityAttachment attachment={activity.attachment} />
|
||||
) : null}
|
||||
{activity.participants?.length ? (
|
||||
<ParticipantGroup
|
||||
participants={activity.participants}
|
||||
count={activity.participantCount}
|
||||
/>
|
||||
) : null}
|
||||
{activity.actions?.map((action) => (
|
||||
<Button
|
||||
key={action.label}
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={
|
||||
action.variant === "outline" ? "outline" : "default"
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ActivityStatusRow activity={activity} />
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { FinanceActivityTimeline } from "./components/finance-activity-timeline"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-6 sm:p-8 md:p-12">
|
||||
<FinanceActivityTimeline />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
|
||||
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { recentPlatformActivity } from '@/lib/metrics'
|
||||
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
const KIND_META = {
|
||||
job: { icon: Info, className: 'text-info' },
|
||||
revision: { icon: CheckCircle, className: 'text-success' },
|
||||
network: { icon: AlertTriangle, className: 'text-warning' },
|
||||
} as const
|
||||
|
||||
function statusBadgeVariant(status: string) {
|
||||
const s = status.toLowerCase()
|
||||
if (['ok', 'success', 'completed', 'done'].includes(s)) return 'success-light' as const
|
||||
if (['running', 'queued', 'pending'].includes(s)) return 'info-light' as const
|
||||
if (['warning', 'mismatch'].includes(s)) return 'warning-light' as const
|
||||
if (['failed', 'error', 'cancelled'].includes(s)) return 'destructive-light' as const
|
||||
return 'outline' as const
|
||||
}
|
||||
|
||||
export function DashboardActivityTimeline({
|
||||
jobs,
|
||||
revisions,
|
||||
peers,
|
||||
speakers,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
revisions: RevisionRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const items = recentPlatformActivity(jobs, revisions, peers, speakers, 6)
|
||||
|
||||
return (
|
||||
<DashboardFramePanel
|
||||
title="Недавняя активность"
|
||||
description="Задачи, ревизии и сетевые события"
|
||||
className="h-full"
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-sm">Нет недавних событий</p>
|
||||
) : (
|
||||
<div className="px-4 py-4">
|
||||
<Timeline defaultValue={items.length}>
|
||||
{items.map((item, index) => {
|
||||
const meta = KIND_META[item.kind]
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<TimelineItem
|
||||
key={item.id}
|
||||
step={index + 1}
|
||||
className="group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-4"
|
||||
>
|
||||
<TimelineHeader>
|
||||
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:top-2 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem)] group-data-[orientation=vertical]/timeline:translate-y-5" />
|
||||
<TimelineIndicator className="border-none bg-transparent group-data-[orientation=vertical]/timeline:-left-6">
|
||||
<span
|
||||
className={cn(
|
||||
'bg-muted/70 flex size-7 items-center justify-center rounded-full',
|
||||
meta.className,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
</span>
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="min-w-0 pb-1 text-foreground">
|
||||
<TimelineTitle className="text-sm leading-snug font-normal">
|
||||
{item.message}
|
||||
</TimelineTitle>
|
||||
<div className="mt-2">
|
||||
<Badge variant={statusBadgeVariant(item.status)} size="sm">
|
||||
{item.statusLabel ?? item.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
})}
|
||||
</Timeline>
|
||||
</div>
|
||||
)}
|
||||
</DashboardFramePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export function DashboardFramePanel({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: {
|
||||
title?: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
}) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
|
||||
return (
|
||||
<Frame stacked dense className={cn('h-full w-full', className)}>
|
||||
{hasHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3 border-b border-(--frame-panel-border-color)">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
<FramePanel className={cn('flex flex-col p-0!', contentClassName)}>{children}</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
ListChecks,
|
||||
Network,
|
||||
ServerCog,
|
||||
Share2,
|
||||
} from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
|
||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type KpiCard = {
|
||||
icon: ReactNode
|
||||
iconClass: string
|
||||
value: string
|
||||
label: string
|
||||
badge: ReactNode
|
||||
}
|
||||
|
||||
function buildKpis({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}): KpiCard[] {
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const network = aggregateNetworkMetrics(peers, speakers)
|
||||
const peersEnabled = network.peersEnabled
|
||||
const bgpPct =
|
||||
peersEnabled > 0 ? Math.round((network.peersEstablished / peersEnabled) * 100) : null
|
||||
const running = runningJobCount(jobs)
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
||||
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
||||
|
||||
return [
|
||||
{
|
||||
icon: <Boxes aria-hidden />,
|
||||
iconClass: 'text-primary',
|
||||
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
||||
label: 'Модули активны',
|
||||
badge: (
|
||||
<Badge variant="primary-light" size="sm">
|
||||
{loading ? '…' : `${modules.length} всего`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Network aria-hidden />,
|
||||
iconClass: 'text-info',
|
||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||
label: 'BGP готовность',
|
||||
badge: (
|
||||
<Badge
|
||||
variant={
|
||||
bgpPct !== null && bgpPct >= 90
|
||||
? 'success-light'
|
||||
: bgpPct !== null && bgpPct < 70
|
||||
? 'warning-light'
|
||||
: 'outline'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading || bgpPct === null
|
||||
? 'нет включённых пиров'
|
||||
: `${network.peersEstablished} установлено`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClass: 'text-success',
|
||||
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
||||
label: 'Пиры Established',
|
||||
badge: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <ServerCog aria-hidden />,
|
||||
iconClass: 'text-warning',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
label: 'Спикеры online',
|
||||
badge: (
|
||||
<Badge
|
||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'live-снимок'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <ListChecks aria-hidden />,
|
||||
iconClass: 'text-focus',
|
||||
value: loading ? '—' : String(running),
|
||||
label: 'Активные задачи',
|
||||
badge: (
|
||||
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||
{loading ? '…' : `${jobs.length} в выборке`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <AlertTriangle aria-hidden />,
|
||||
iconClass: 'text-destructive',
|
||||
value: loading ? '—' : String(riskCount),
|
||||
label: 'Риски',
|
||||
badge: (
|
||||
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||
{loading
|
||||
? '…'
|
||||
: riskCount > 0
|
||||
? `${failedJobs} задач · ${network.peersMismatch} расхождений`
|
||||
: 'в норме'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function DashboardKpiGrid({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const cards = buildKpis({ modules, peers, speakers, jobs, loading })
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{cards.map((card) => (
|
||||
<Frame key={card.label}>
|
||||
<FramePanel className="flex flex-col items-start gap-4">
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
card.iconClass,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">
|
||||
{card.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm font-medium">{card.label}</div>
|
||||
</div>
|
||||
{card.badge}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
ArrowUpDownIcon,
|
||||
BoxesIcon,
|
||||
ChevronDownIcon,
|
||||
FilterIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import {
|
||||
DataGridTable,
|
||||
DataGridTableHeader,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||
import type { ModuleRow } from '@/types/api'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
type ModuleSort = 'name' | 'type' | 'priority' | 'last_refreshed_at'
|
||||
type EnabledFilter = 'all' | 'enabled' | 'disabled'
|
||||
|
||||
const sortLabels: Record<ModuleSort, string> = {
|
||||
name: 'Название',
|
||||
type: 'Тип',
|
||||
priority: 'Приоритет',
|
||||
last_refreshed_at: 'Обновлено',
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
|
||||
|
||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
||||
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }]
|
||||
}
|
||||
|
||||
export function DashboardModulesGrid({
|
||||
modules,
|
||||
isLoading = false,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>('all')
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [sortBy, setSortBy] = useState<ModuleSort>('name')
|
||||
const [sorting, setSorting] = useState<SortingState>(() => buildSorting('name'))
|
||||
|
||||
const filteredModules = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
return modules.filter((module) => {
|
||||
const matchesSearch =
|
||||
q.length === 0 ||
|
||||
`${module.name} ${module.type} ${moduleTypeRu(module.type)}`.toLowerCase().includes(q)
|
||||
const matchesEnabled =
|
||||
enabledFilter === 'all' ||
|
||||
(enabledFilter === 'enabled' ? module.enabled !== false : module.enabled === false)
|
||||
return matchesSearch && matchesEnabled
|
||||
})
|
||||
}, [modules, searchQuery, enabledFilter])
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) => ({ ...current, pageIndex: 0 }))
|
||||
}, [])
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(value: ModuleSort) => {
|
||||
setSortBy(value)
|
||||
setSorting(buildSorting(value))
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<ModuleRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
id: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
|
||||
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" />
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
id: 'type',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Тип' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
id: 'priority',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
|
||||
),
|
||||
meta: { headerTitle: 'Приоритет' },
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
accessorFn: (row) => (row.enabled !== false ? 'enabled' : 'disabled'),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => <ModeBadge enabled={row.original.enabled !== false} />,
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
accessorFn: (row) => row.last_refreshed_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.last_refreshed_at
|
||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
sortingFn: (a, b) =>
|
||||
(a.original.last_refreshed_at ?? '').localeCompare(b.original.last_refreshed_at ?? ''),
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredModules,
|
||||
columns,
|
||||
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
|
||||
state: { pagination, sorting },
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getRowId: (row) => row.id,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
const activeFilterCount = enabledFilter === 'all' ? 0 : 1
|
||||
|
||||
return (
|
||||
<DashboardFramePanel
|
||||
title="Модули"
|
||||
description="Поиск, сортировка и быстрый переход к настройке"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
||||
<PlusIcon />
|
||||
Создать
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredModules.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={EMPTY_MESSAGE}
|
||||
tableLayout={{
|
||||
dense: true,
|
||||
rowBorder: true,
|
||||
headerSticky: false,
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
width: 'fixed',
|
||||
}}
|
||||
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
|
||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<InputGroup className="w-full min-w-40 sm:max-w-xs">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchQuery}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value)
|
||||
resetPagination()
|
||||
}}
|
||||
placeholder="Поиск модулей…"
|
||||
aria-label="Поиск модулей"
|
||||
/>
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label="Очистить поиск"
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<ArrowUpDownIcon data-icon="inline-start" aria-hidden />
|
||||
{sortLabels[sortBy]}
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
<DropdownMenuGroup>
|
||||
{(Object.keys(sortLabels) as ModuleSort[]).map((value) => (
|
||||
<DropdownMenuItem key={value} onClick={() => handleSortChange(value)}>
|
||||
{sortLabels[value]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<FilterIcon data-icon="inline-start" aria-hidden />
|
||||
Фильтры
|
||||
{activeFilterCount > 0 ? (
|
||||
<Badge variant="outline" radius="full">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Состояние</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={enabledFilter === 'enabled'}
|
||||
closeOnClick={false}
|
||||
onCheckedChange={(checked) => {
|
||||
setEnabledFilter(checked ? 'enabled' : 'all')
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
Только включённые
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={enabledFilter === 'disabled'}
|
||||
closeOnClick={false}
|
||||
onCheckedChange={(checked) => {
|
||||
setEnabledFilter(checked ? 'disabled' : 'all')
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
Только выключенные
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuGroup>
|
||||
{activeFilterCount > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
closeOnClick={false}
|
||||
onClick={() => {
|
||||
setEnabledFilter('all')
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
Сбросить фильтры
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
) : (
|
||||
<>
|
||||
<DataGridScrollArea>
|
||||
<DataGridTableHeader />
|
||||
</DataGridScrollArea>
|
||||
<div className="text-muted-foreground flex min-h-40 items-center justify-center px-4 text-center text-sm">
|
||||
{EMPTY_MESSAGE}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t px-4 py-3">
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridPagination
|
||||
sizes={[10, 15, 20]}
|
||||
info="{from}–{to} из {count}"
|
||||
className="py-0"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center text-sm">0 модулей</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DataGrid>
|
||||
</DashboardFramePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { ChartBarStrip } from '@/components/analytics/chart-bar-strip'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
capacityUtilization,
|
||||
peerCapacityBars,
|
||||
speakerCapacityBars,
|
||||
} from '@/lib/metrics'
|
||||
import { runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type CapacityMode = 'peers' | 'speakers'
|
||||
|
||||
export function DashboardNetworkHealth({
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<CapacityMode>('peers')
|
||||
|
||||
const bars = useMemo(
|
||||
() => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)),
|
||||
[mode, peers, speakers],
|
||||
)
|
||||
const utilization = capacityUtilization(bars)
|
||||
const queued = runningJobCount(jobs)
|
||||
|
||||
const established =
|
||||
mode === 'peers'
|
||||
? peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length
|
||||
: speakers.filter((s) => s.live?.agent_ok).length
|
||||
const total =
|
||||
mode === 'peers' ? peers.filter((p) => p.enabled !== false).length : speakers.length
|
||||
|
||||
return (
|
||||
<DashboardFramePanel
|
||||
title="Загрузка BGP"
|
||||
description="Утилизация сессий по пирам и спикерам"
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'peers', label: 'Пиры' },
|
||||
{ value: 'speakers', label: 'Спикеры' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 px-4 py-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-3xl font-semibold tracking-tight tabular-nums">
|
||||
{loading ? '—' : `${utilization}%`}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{loading ? '…' : `${established} / ${total} ${mode === 'peers' ? 'установлено' : 'в сети'}`}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" radius="full" className="h-7 gap-1.5 px-2.5 text-xs">
|
||||
<span className="font-semibold tabular-nums">{loading ? '—' : queued}</span>
|
||||
<span>активных задач</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex h-36 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartBarStrip bars={bars} />
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground flex flex-wrap gap-3 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="bg-chart-3 size-2 rounded-full" aria-hidden />
|
||||
Ниже порога
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="bg-chart-2 size-2 rounded-full" aria-hidden />
|
||||
Установлено / online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardFramePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, ModuleRow } from '@/types/api'
|
||||
|
||||
type FlowMode = 'jobs' | 'modules'
|
||||
|
||||
export function DashboardOperationsBreakdown({
|
||||
jobs,
|
||||
modules,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
modules: ModuleRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<FlowMode>('jobs')
|
||||
|
||||
const slices = useMemo(
|
||||
() => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)),
|
||||
[mode, jobs, modules],
|
||||
)
|
||||
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули'
|
||||
|
||||
return (
|
||||
<DashboardFramePanel
|
||||
title="Поток операций"
|
||||
description="Распределение задач и типов модулей"
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'jobs', label: 'Задачи' },
|
||||
{ value: 'modules', label: 'Модули' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="px-4 py-4">
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric
|
||||
slices={slices}
|
||||
centerLabel={centerLabel}
|
||||
centerValue={total}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DashboardFramePanel>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
export function DashboardQuickActions() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
|
||||
<Plus className="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}>
|
||||
<Tags className="size-4" />
|
||||
Добавить BGP-сообщество
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'overview' }} />}>
|
||||
<Network className="size-4" />
|
||||
Сеть
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'peers' }} />}>
|
||||
<Share2 className="size-4" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
render={<Link to="/operations" search={{ tab: 'revisions' }} />}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Деплой
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/monitoring" search={{ tab: 'system' }} />}>
|
||||
<Gauge className="size-4" />
|
||||
Мониторинг
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
type QuickLink = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
description: string
|
||||
to: string
|
||||
search?: Record<string, string>
|
||||
}
|
||||
|
||||
const LINKS: QuickLink[] = [
|
||||
{
|
||||
icon: <Plus aria-hidden />,
|
||||
label: 'Создать модуль',
|
||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||
to: '/modules/new',
|
||||
},
|
||||
{
|
||||
icon: <Tags aria-hidden />,
|
||||
label: 'BGP-сообщества',
|
||||
description: 'Справочник communities для политик экспорта.',
|
||||
to: '/directories',
|
||||
},
|
||||
{
|
||||
icon: <Network aria-hidden />,
|
||||
label: 'Сеть',
|
||||
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
||||
to: '/network',
|
||||
search: { tab: 'overview' },
|
||||
},
|
||||
{
|
||||
icon: <Share2 aria-hidden />,
|
||||
label: 'Добавить пира',
|
||||
description: 'Настройка BGP-соседа и шаблонов сессии.',
|
||||
to: '/network',
|
||||
search: { tab: 'peers' },
|
||||
},
|
||||
{
|
||||
icon: <Play aria-hidden />,
|
||||
label: 'Деплой',
|
||||
description: 'Ревизии конфигурации и применение на нодах.',
|
||||
to: '/operations',
|
||||
search: { tab: 'revisions' },
|
||||
},
|
||||
{
|
||||
icon: <Gauge aria-hidden />,
|
||||
label: 'Мониторинг',
|
||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||
to: '/monitoring',
|
||||
search: { tab: 'system' },
|
||||
},
|
||||
]
|
||||
|
||||
export function DashboardQuickLinks() {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{LINKS.map((link) => (
|
||||
<Frame key={link.label} spacing="sm">
|
||||
<FrameHeader className="px-1! py-1!">
|
||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||
{link.icon}
|
||||
<span className="text-foreground text-sm font-medium">{link.label}</span>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="space-y-3.5">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">{link.description}</p>
|
||||
<Link
|
||||
to={link.to}
|
||||
search={link.search}
|
||||
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
Перейти →
|
||||
</Link>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,7 +31,10 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
options,
|
||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||
const filterValue = column?.getFilterValue()
|
||||
const selectedValues = new Set(
|
||||
Array.isArray(filterValue) ? (filterValue as string[]) : []
|
||||
)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
@@ -53,16 +56,13 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
className="px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
<Badge variant="secondary" className="px-1 font-normal">
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
@@ -72,7 +72,7 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
className="px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
@@ -102,28 +102,39 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<div className="p-1">
|
||||
{filteredOptions.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
const facetCount = facets?.get(option.value)
|
||||
const toggleOption = () => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isSelected}
|
||||
onClick={toggleOption}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
toggleOption()
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"rounded-md relative flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
"border-primary rounded-sm flex h-4 w-4 items-center justify-center border",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
@@ -132,12 +143,12 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
|
||||
<option.icon className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
{facetCount !== undefined && (
|
||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
{facetCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -150,8 +161,16 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<div className="bg-border -mx-1 my-1 h-px" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => column?.setFilterValue(undefined)}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
column?.setFilterValue(undefined)
|
||||
}
|
||||
}}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
>
|
||||
Clear filters
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ interface DataGridColumnHeaderProps<
|
||||
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
|
||||
pinnable?: boolean
|
||||
filter?: ReactNode
|
||||
visibility?: boolean
|
||||
@@ -47,7 +48,10 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||
|
||||
const columnOrder = table.getState().columnOrder
|
||||
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
|
||||
const columnVisibilityKey =
|
||||
props.tableLayout?.columnsVisibility && visibility
|
||||
? JSON.stringify(table.getState().columnVisibility)
|
||||
: ""
|
||||
const isSorted = column.getIsSorted()
|
||||
const isPinned = column.getIsPinned()
|
||||
const canSort = column.getCanSort()
|
||||
@@ -74,18 +78,18 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
)
|
||||
|
||||
const headerButtonClassName = cn(
|
||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
|
||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg",
|
||||
className
|
||||
)
|
||||
|
||||
const sortIcon =
|
||||
canSort &&
|
||||
(isSorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3.25" />
|
||||
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
|
||||
) : isSorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.25" />
|
||||
<ArrowUpIcon className="size-3.25" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" />
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
|
||||
))
|
||||
|
||||
const hasControls =
|
||||
@@ -276,7 +280,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
|
||||
if (hasControls) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-between gap-1.5">
|
||||
<div className="-ms-2 flex h-full items-center justify-between gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
@@ -299,7 +303,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="-me-1 size-7 rounded-md"
|
||||
className="rounded-lg -me-1 size-7"
|
||||
onClick={() => column.pin(false)}
|
||||
aria-label={`Unpin ${resolvedTitle} column`}
|
||||
title={`Unpin ${resolvedTitle} column`}
|
||||
@@ -313,7 +317,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
|
||||
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<div className="-ms-2 flex h-full items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
|
||||
@@ -4,8 +4,12 @@ import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
SelectMenu,
|
||||
} from "@/components/select-field"
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
@@ -31,11 +35,8 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
|
||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesLabel: "Show",
|
||||
sizesDescription: "per page",
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
more: false,
|
||||
info: "{from} - {to} of {count}",
|
||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||
rowsPerPageLabel: "Rows per page",
|
||||
@@ -46,24 +47,24 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
|
||||
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
||||
|
||||
const btnBaseClasses = "size-7 p-0 text-sm"
|
||||
const btnBaseClasses = "p-0 text-sm"
|
||||
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
|
||||
const pageIndex = table.getState().pagination.pageIndex
|
||||
const pageSize = table.getState().pagination.pageSize
|
||||
const from = pageIndex * pageSize + 1
|
||||
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
|
||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||
const pageCount = table.getPageCount()
|
||||
|
||||
// Replace placeholders in paginationInfo
|
||||
const paginationInfo = mergedProps?.info
|
||||
const paginationInfo = mergedProps.info
|
||||
? mergedProps.info
|
||||
.replace("{from}", from.toString())
|
||||
.replace("{to}", to.toString())
|
||||
.replace("{count}", recordCount.toString())
|
||||
.replaceAll("{from}", from.toString())
|
||||
.replaceAll("{to}", to.toString())
|
||||
.replaceAll("{count}", recordCount.toString())
|
||||
: `${from} - ${to} of ${recordCount}`
|
||||
|
||||
// Pagination limit logic
|
||||
const paginationMoreLimit = mergedProps?.moreLimit || 5
|
||||
const paginationMoreLimit = mergedProps.moreLimit || 5
|
||||
|
||||
// Determine the start and end of the pagination group
|
||||
const currentGroupStart =
|
||||
@@ -137,47 +138,48 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
data-slot="data-grid-pagination"
|
||||
className={cn(
|
||||
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
|
||||
mergedProps?.className
|
||||
mergedProps.className
|
||||
)}
|
||||
>
|
||||
<div className="order-2 flex flex-wrap items-center gap-2 pb-2.5 sm:order-1 sm:pb-0">
|
||||
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.sizesSkeleton
|
||||
mergedProps.sizesSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{mergedProps.rowsPerPageLabel}
|
||||
</div>
|
||||
<SelectMenu
|
||||
items={
|
||||
mergedProps?.sizes?.map((size: number) => ({
|
||||
value: `${size}`,
|
||||
label: `${size}`,
|
||||
})) ?? []
|
||||
}
|
||||
<Select
|
||||
value={`${pageSize}`}
|
||||
triggerClassName="min-w-20 w-auto tabular-nums"
|
||||
size="sm"
|
||||
side="top"
|
||||
contentClassName="min-w-20"
|
||||
onValueChange={(value) => {
|
||||
if (!value) return
|
||||
table.setPageSize(Number(value))
|
||||
const newPageSize = Number(value)
|
||||
table.setPageSize(newPageSize)
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="w-16" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" className="min-w-18">
|
||||
{mergedProps.sizes?.map((size: number) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.infoSkeleton
|
||||
mergedProps.infoSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
|
||||
<div className="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">
|
||||
{paginationInfo}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="order-1 flex items-center space-x-1 sm:order-2">
|
||||
<div className="order-1 flex items-center space-x-1">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -25,6 +25,11 @@ const INITIAL_METRICS = {
|
||||
trackHeight: 0,
|
||||
} as const
|
||||
|
||||
const SCROLLBAR_CLASSNAME =
|
||||
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
|
||||
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
|
||||
|
||||
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
|
||||
|
||||
type ScrollbarMetrics = {
|
||||
@@ -365,11 +370,11 @@ function DataGridScrollArea({
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="horizontal"
|
||||
orientation="horizontal"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
className={SCROLLBAR_CLASSNAME}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
className={SCROLLBAR_THUMB_CLASSNAME}
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
@@ -379,11 +384,11 @@ function DataGridScrollArea({
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="vertical"
|
||||
orientation="vertical"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
className={SCROLLBAR_CLASSNAME}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
className={SCROLLBAR_THUMB_CLASSNAME}
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
|
||||
@@ -71,10 +71,10 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -87,11 +87,11 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||
"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
|
||||
/>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -119,15 +119,10 @@ function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
||||
|
||||
return (
|
||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
||||
<DataGridTableBodyRow
|
||||
row={row}
|
||||
dndRef={setNodeRef}
|
||||
dndStyle={style}
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
|
||||
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
|
||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
@@ -227,7 +222,7 @@ function DataGridTableDndRows<TData>({
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const { column } = header
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
@@ -111,11 +112,11 @@ function DataGridTableDndHeader<TData>({
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<span className="grow truncate">
|
||||
<div className="grow">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
</div>
|
||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
@@ -184,33 +185,40 @@ function DataGridTableDnd<TData>({
|
||||
}, [isDraggingColumn])
|
||||
|
||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
||||
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
|
||||
if (!draggingNodeRect || !containerRef.current) {
|
||||
return { ...transform, y: 0 }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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={[restrictToTableBounds]}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingColumn(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingColumn(false)
|
||||
@@ -233,7 +241,7 @@ function DataGridTableDnd<TData>({
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
@@ -277,19 +285,16 @@ function DataGridTableDnd<TData>({
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<DataGridTableBodyRow row={row}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<TData, unknown>) => {
|
||||
return (
|
||||
<SortableContext
|
||||
key={cell.id}
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<DataGridTableDndCell cell={cell} />
|
||||
</SortableContext>
|
||||
)
|
||||
})}
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<TData, unknown>) => (
|
||||
<DataGridTableDndCell cell={cell} key={cell.id} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && (
|
||||
<DataGridTableBodyRowExpandded row={row} />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
CSSProperties,
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
@@ -19,10 +21,12 @@ import {
|
||||
DataGridTableRenderedRow,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
getDataGridTableMergedHeaderGroups,
|
||||
getDataGridTableRowSections,
|
||||
getPinningStyles,
|
||||
hasDataGridTableRightPinnedColumns,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { DATA_GRID_MESSAGES_RU } from "@/lib/data-grid-defaults"
|
||||
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
|
||||
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
|
||||
import {
|
||||
useVirtualizer,
|
||||
VirtualItem,
|
||||
@@ -69,7 +73,6 @@ interface DataGridTableVirtualProps<TData> {
|
||||
|
||||
interface VirtualBodyProps<TData> {
|
||||
table: Table<TData>
|
||||
columnCount: number
|
||||
topRows: Row<TData>[]
|
||||
centerRows: Row<TData>[]
|
||||
bottomRows: Row<TData>[]
|
||||
@@ -84,49 +87,140 @@ interface VirtualBodyProps<TData> {
|
||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||
}
|
||||
|
||||
function DataGridTableVirtualSpacer({
|
||||
columnCount,
|
||||
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,
|
||||
}: {
|
||||
columnCount: number
|
||||
table: Table<TData>
|
||||
height: number
|
||||
}) {
|
||||
if (height <= 0) return null
|
||||
|
||||
return (
|
||||
<tr aria-hidden="true">
|
||||
<td colSpan={columnCount} style={{ height, padding: 0 }} />
|
||||
</tr>
|
||||
<DataGridTableVirtualUtilityRow
|
||||
table={table}
|
||||
ariaHidden
|
||||
centerCellClassName="p-0"
|
||||
centerCellStyle={{ height, padding: 0 }}
|
||||
>
|
||||
{null}
|
||||
</DataGridTableVirtualUtilityRow>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualStatusRow({
|
||||
function DataGridTableVirtualStatusRow<TData>({
|
||||
table,
|
||||
children,
|
||||
className,
|
||||
columnCount,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
children: ReactNode
|
||||
className?: string
|
||||
columnCount: number
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columnCount}
|
||||
className={cn(
|
||||
"text-muted-foreground py-4 text-center text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
</tr>
|
||||
<DataGridTableVirtualUtilityRow
|
||||
table={table}
|
||||
centerCellClassName={cn(
|
||||
"text-muted-foreground py-4 text-center text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</DataGridTableVirtualUtilityRow>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualBody<TData>({
|
||||
table,
|
||||
columnCount,
|
||||
topRows,
|
||||
centerRows,
|
||||
bottomRows,
|
||||
@@ -179,7 +273,7 @@ function DataGridTableVirtualBody<TData>({
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-start"
|
||||
columnCount={columnCount}
|
||||
table={table}
|
||||
height={leadingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
@@ -203,7 +297,7 @@ function DataGridTableVirtualBody<TData>({
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-end"
|
||||
columnCount={columnCount}
|
||||
table={table}
|
||||
height={trailingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
@@ -216,10 +310,7 @@ function DataGridTableVirtualBody<TData>({
|
||||
|
||||
if (showFetchingRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-loading"
|
||||
columnCount={columnCount}
|
||||
>
|
||||
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-4 opacity-60" />
|
||||
{loadingMoreMessage}
|
||||
@@ -232,7 +323,7 @@ function DataGridTableVirtualBody<TData>({
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-complete"
|
||||
columnCount={columnCount}
|
||||
table={table}
|
||||
className="py-3 text-xs"
|
||||
>
|
||||
{allRowsLoadedMessage}
|
||||
@@ -280,13 +371,12 @@ function DataGridTableVirtual<TData>({
|
||||
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 columnCount =
|
||||
table.getVisibleFlatColumns().length +
|
||||
(props.tableLayout?.columnsResizable ? 1 : 0)
|
||||
const isInfiniteMode = typeof onFetchMore === "function"
|
||||
const [viewportElements, setViewportElements] =
|
||||
useState<DataGridTableVirtualScrollElements>({
|
||||
@@ -305,9 +395,9 @@ function DataGridTableVirtual<TData>({
|
||||
|
||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||
const loadingMoreMessage =
|
||||
props.fetchingMoreMessage || props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||
const allRowsLoadedMessage =
|
||||
props.allRowsLoadedMessage || DATA_GRID_MESSAGES_RU.allRecordsLoadedMessage
|
||||
props.allRowsLoadedMessage || "All records loaded"
|
||||
|
||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setViewportElements({
|
||||
@@ -371,10 +461,7 @@ function DataGridTableVirtual<TData>({
|
||||
isVirtualizationEnabled && customMeasureElement
|
||||
? virtualizer.measureElement
|
||||
: undefined
|
||||
const resolvedFetchMoreOffset = useMemo(
|
||||
() => Math.max(0, fetchMoreOffset),
|
||||
[fetchMoreOffset]
|
||||
)
|
||||
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -416,29 +503,21 @@ function DataGridTableVirtual<TData>({
|
||||
<DataGridTableBase>
|
||||
{renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, hIndex) => {
|
||||
{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={hIndex}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
@@ -446,8 +525,36 @@ function DataGridTableVirtual<TData>({
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -459,7 +566,6 @@ function DataGridTableVirtual<TData>({
|
||||
<DataGridTableBody>
|
||||
<MemoizedVirtualBody
|
||||
table={table}
|
||||
columnCount={columnCount}
|
||||
topRows={topRows}
|
||||
centerRows={centerRows}
|
||||
bottomRows={bottomRows}
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
TouchEvent as ReactTouchEvent,
|
||||
Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
Column,
|
||||
flexRender,
|
||||
Header,
|
||||
HeaderGroup,
|
||||
Row,
|
||||
Table,
|
||||
} from "@tanstack/react-table"
|
||||
@@ -28,15 +28,12 @@ import { cva } from "class-variance-authority"
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||
import { Spinner } from "@evobgp/ui/components/spinner"
|
||||
import { DATA_GRID_MESSAGES_RU } from "@/lib/data-grid-defaults"
|
||||
|
||||
const headerCellSpacingVariants = cva("", {
|
||||
variants: {
|
||||
size: {
|
||||
dense:
|
||||
"px-2 h-8",
|
||||
default:
|
||||
"px-3",
|
||||
dense: "px-2 h-8",
|
||||
default: "px-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -47,10 +44,8 @@ const headerCellSpacingVariants = cva("", {
|
||||
const bodyCellSpacingVariants = cva("", {
|
||||
variants: {
|
||||
size: {
|
||||
dense:
|
||||
"px-2 py-1.5",
|
||||
default:
|
||||
"px-3 py-2",
|
||||
dense: "px-2 py-1.5",
|
||||
default: "px-3 py-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -61,10 +56,8 @@ const bodyCellSpacingVariants = cva("", {
|
||||
const footerCellSpacingVariants = cva("", {
|
||||
variants: {
|
||||
size: {
|
||||
dense:
|
||||
"px-2 py-1.5",
|
||||
default:
|
||||
"px-3 py-2",
|
||||
dense: "px-2 py-1.5",
|
||||
default: "px-3 py-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -78,9 +71,12 @@ function getPinningStyles<TData>(column: Column<TData>): CSSProperties {
|
||||
return {
|
||||
left: isPinned === "left" ? `${column.getStart("left")}px` : undefined,
|
||||
right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined,
|
||||
position: isPinned ? "sticky" : "relative",
|
||||
position: isPinned ? "sticky" : undefined,
|
||||
transform: isPinned ? "translateZ(0)" : undefined,
|
||||
contain: isPinned ? "paint" : undefined,
|
||||
width: column.getSize(),
|
||||
zIndex: isPinned ? 1 : 0,
|
||||
zIndex: isPinned ? 30 : undefined,
|
||||
backgroundClip: isPinned ? "padding-box" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,6 +334,55 @@ function getDataGridTableResolvedRows<TData>(
|
||||
return resolvedRows
|
||||
}
|
||||
|
||||
function getDataGridTableOrderedVisibleColumns<TData>(table: Table<TData>) {
|
||||
return [
|
||||
...table.getLeftVisibleLeafColumns(),
|
||||
...table.getCenterVisibleLeafColumns(),
|
||||
...table.getRightVisibleLeafColumns(),
|
||||
] as Column<TData>[]
|
||||
}
|
||||
|
||||
function getDataGridTableOrderedVisibleCells<TData>(row: Row<TData>) {
|
||||
return [
|
||||
...row.getLeftVisibleCells(),
|
||||
...row.getCenterVisibleCells(),
|
||||
...row.getRightVisibleCells(),
|
||||
] as Cell<TData, unknown>[]
|
||||
}
|
||||
|
||||
function getDataGridTableMergedHeaderGroups<TData>(table: Table<TData>) {
|
||||
const leftHeaderGroups = table.getLeftHeaderGroups()
|
||||
const centerHeaderGroups = table.getCenterHeaderGroups()
|
||||
const rightHeaderGroups = table.getRightHeaderGroups()
|
||||
const headerGroupCount = Math.max(
|
||||
leftHeaderGroups.length,
|
||||
centerHeaderGroups.length,
|
||||
rightHeaderGroups.length
|
||||
)
|
||||
|
||||
return Array.from({ length: headerGroupCount }, (_, index) => {
|
||||
const leftGroup = leftHeaderGroups[index]
|
||||
const centerGroup = centerHeaderGroups[index]
|
||||
const rightGroup = rightHeaderGroups[index]
|
||||
|
||||
return {
|
||||
id:
|
||||
[leftGroup?.id, centerGroup?.id, rightGroup?.id]
|
||||
.filter(Boolean)
|
||||
.join(":") || `header-group-${index}`,
|
||||
headers: [
|
||||
...(leftGroup?.headers ?? []),
|
||||
...(centerGroup?.headers ?? []),
|
||||
...(rightGroup?.headers ?? []),
|
||||
] as Header<TData, unknown>[],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function hasDataGridTableRightPinnedColumns<TData>(table: Table<TData>) {
|
||||
return (table.getState().columnPinning.right?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
function DataGridTableFillCol() {
|
||||
const { props } = useDataGrid()
|
||||
|
||||
@@ -391,14 +436,17 @@ function DataGridTableFillFootCell() {
|
||||
aria-hidden="true"
|
||||
data-slot="data-grid-table-fill-foot-cell"
|
||||
style={{ width: "var(--data-grid-fill-size, 0px)" }}
|
||||
className="border-t p-0"
|
||||
className="p-0"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableBase({ children }: { children: ReactNode }) {
|
||||
const { props, table } = useDataGrid()
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
|
||||
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
||||
const rightVisibleColumns = table.getRightVisibleLeafColumns()
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
/**
|
||||
* Compute column widths as CSS custom properties once upfront (memoized).
|
||||
@@ -427,7 +475,7 @@ function DataGridTableBase({ children }: { children: ReactNode }) {
|
||||
<table
|
||||
data-slot="data-grid-table"
|
||||
className={cn(
|
||||
"text-foreground text-sm caption-bottom text-left align-middle font-normal rtl:text-right",
|
||||
"text-foreground caption-bottom text-left align-middle text-sm font-normal rtl:text-right",
|
||||
props.tableLayout?.columnsResizable ? "min-w-0" : "w-full min-w-full",
|
||||
props.tableLayout?.width === "auto" ? "table-auto" : "table-fixed",
|
||||
!props.tableLayout?.columnsResizable && "",
|
||||
@@ -445,7 +493,7 @@ function DataGridTableBase({ children }: { children: ReactNode }) {
|
||||
}
|
||||
>
|
||||
<colgroup>
|
||||
{visibleColumns.map((column) => (
|
||||
{[...leftVisibleColumns, ...centerVisibleColumns].map((column) => (
|
||||
<col
|
||||
key={column.id}
|
||||
style={
|
||||
@@ -457,7 +505,20 @@ function DataGridTableBase({ children }: { children: ReactNode }) {
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<DataGridTableFillCol />
|
||||
{hasRightPinnedColumns ? <DataGridTableFillCol /> : null}
|
||||
{rightVisibleColumns.map((column) => (
|
||||
<col
|
||||
key={column.id}
|
||||
style={
|
||||
props.tableLayout?.columnsResizable
|
||||
? { width: `calc(var(--col-${column.id}-size) * 1px)` }
|
||||
: props.tableLayout?.width === "fixed"
|
||||
? { width: column.getSize() }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{!hasRightPinnedColumns ? <DataGridTableFillCol /> : null}
|
||||
</colgroup>
|
||||
{children}
|
||||
</table>
|
||||
@@ -476,6 +537,7 @@ function DataGridTableViewport({
|
||||
style?: CSSProperties
|
||||
}) {
|
||||
const { props, table } = useDataGrid()
|
||||
const didApplyAutoSizeColumnRef = useRef<string | null>(null)
|
||||
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
)
|
||||
@@ -483,16 +545,34 @@ function DataGridTableViewport({
|
||||
const handleViewportRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
setViewportElement(node)
|
||||
|
||||
if (props.tableLayout?.columnsResizable && node) {
|
||||
const scrollViewport =
|
||||
(node.closest(
|
||||
'[data-slot="scroll-area-viewport"]'
|
||||
) as HTMLElement | null) ?? node.parentElement
|
||||
const measurementTarget = scrollViewport ?? node
|
||||
|
||||
setContainerWidth(measurementTarget.clientWidth)
|
||||
} else if (!node) {
|
||||
setContainerWidth(0)
|
||||
}
|
||||
|
||||
assignRef(viewportRef, node)
|
||||
},
|
||||
[viewportRef]
|
||||
[props.tableLayout?.columnsResizable, viewportRef]
|
||||
)
|
||||
const fillWidth =
|
||||
props.tableLayout?.columnsResizable && containerWidth > 0
|
||||
? Math.max(0, containerWidth - table.getTotalSize())
|
||||
: 0
|
||||
const autoSizeColumnId = table
|
||||
.getVisibleLeafColumns()
|
||||
.find(
|
||||
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
||||
)?.id
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!viewportElement || !props.tableLayout?.columnsResizable) {
|
||||
setContainerWidth(0)
|
||||
return
|
||||
@@ -520,6 +600,22 @@ function DataGridTableViewport({
|
||||
}
|
||||
}, [props.tableLayout?.columnsResizable, viewportElement])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!props.tableLayout?.columnsResizable) return
|
||||
if (!autoSizeColumnId || fillWidth <= 0) return
|
||||
if (didApplyAutoSizeColumnRef.current === autoSizeColumnId) return
|
||||
|
||||
const autoSizeColumn = table.getColumn(autoSizeColumnId)
|
||||
if (!autoSizeColumn) return
|
||||
|
||||
didApplyAutoSizeColumnRef.current = autoSizeColumnId
|
||||
table.setColumnSizing((old) => ({
|
||||
...old,
|
||||
[autoSizeColumnId]:
|
||||
(old[autoSizeColumnId] ?? autoSizeColumn.getSize()) + fillWidth,
|
||||
}))
|
||||
}, [autoSizeColumnId, fillWidth, props.tableLayout?.columnsResizable, table])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid-table-viewport"
|
||||
@@ -556,20 +652,18 @@ function DataGridTableHead({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableHeadRow<TData>({
|
||||
function DataGridTableHeadRow({
|
||||
children,
|
||||
headerGroup,
|
||||
rowId,
|
||||
}: {
|
||||
children: ReactNode
|
||||
headerGroup: HeaderGroup<TData>
|
||||
rowId: string
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={headerGroup.id}
|
||||
className={cn(
|
||||
"bg-muted/40",
|
||||
props.tableLayout?.headerBorder && "[&>th]:border-b",
|
||||
props.tableLayout?.cellBorder && "*:last:border-e-0",
|
||||
props.tableLayout?.stripped && "bg-transparent",
|
||||
@@ -578,7 +672,6 @@ function DataGridTableHeadRow<TData>({
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<DataGridTableFillHeadCell />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -598,9 +691,13 @@ function DataGridTableHeadRowCell<TData>({
|
||||
|
||||
const { column } = header
|
||||
const isPinned = column.getIsPinned()
|
||||
const isFirstLeftPinned =
|
||||
isPinned === "left" && column.getIsFirstColumn("left")
|
||||
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
|
||||
const isFirstRightPinned =
|
||||
isPinned === "right" && column.getIsFirstColumn("right")
|
||||
const isLastRightPinned =
|
||||
isPinned === "right" && column.getIsLastColumn("right")
|
||||
const isLastVisibleColumn =
|
||||
column.getIndex() ===
|
||||
header.getContext().table.getVisibleLeafColumns().length - 1
|
||||
@@ -610,7 +707,6 @@ function DataGridTableHeadRowCell<TData>({
|
||||
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
ref={dndRef}
|
||||
style={{
|
||||
...(props.tableLayout?.width === "fixed" &&
|
||||
@@ -626,23 +722,31 @@ function DataGridTableHeadRowCell<TData>({
|
||||
...(dndStyle ? dndStyle : null),
|
||||
}}
|
||||
data-pinned={isPinned || undefined}
|
||||
data-outer-pinned-col={
|
||||
isFirstLeftPinned ? "left" : isLastRightPinned ? "right" : undefined
|
||||
}
|
||||
data-last-col={
|
||||
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"text-secondary-foreground/80 h-9 relative text-left align-middle font-normal rtl:text-right [&:has([role=checkbox])]:pe-0",
|
||||
"text-foreground relative h-10 text-left align-middle font-medium rtl:text-right [&:has([role=checkbox])]:pe-0",
|
||||
headerCellSpacing,
|
||||
props.tableLayout?.headerBackground && "bg-muted",
|
||||
props.tableLayout?.cellBorder && "border-e",
|
||||
props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() &&
|
||||
"overflow-visible",
|
||||
(isPinned ? "overflow-hidden" : "overflow-visible"),
|
||||
props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() &&
|
||||
isLastVisibleColumn &&
|
||||
"pe-8",
|
||||
props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
"[&[data-pinned][data-last-col]]:border-border data-pinned:bg-muted/90 data-pinned:backdrop-blur-xs [&:not([data-pinned]):has(+[data-pinned])_div.cursor-col-resize:last-child]:opacity-0 [&[data-last-col=left]_div.cursor-col-resize:last-child]:opacity-0 [&[data-pinned=left][data-last-col=left]]:border-e! [&[data-pinned=right]:last-child_div.cursor-col-resize:last-child]:opacity-0 [&[data-pinned=right][data-last-col=right]]:border-s!",
|
||||
cn(
|
||||
"data-pinned:bg-muted data-outer-pinned-col:bg-clip-padding data-pinned:isolate",
|
||||
"[&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right]:last-child_div.cursor-col-resize:last-child]:opacity-0 [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]",
|
||||
"[&:not([data-pinned]):has(+[data-pinned])_div.cursor-col-resize:last-child]:opacity-0 [&[data-last-col=left]_div.cursor-col-resize:last-child]:opacity-0"
|
||||
),
|
||||
header.column.columnDef.meta?.headerClassName,
|
||||
column.getIndex() === 0 ||
|
||||
column.getIndex() === header.headerGroup.headers.length - 1
|
||||
@@ -662,6 +766,7 @@ function DataGridTableHeadRowCellResize<TData>({
|
||||
}) {
|
||||
const { props, table } = useDataGrid()
|
||||
const { column } = header
|
||||
const isPinned = column.getIsPinned()
|
||||
const isLastVisibleColumn =
|
||||
column.getIndex() ===
|
||||
header.getContext().table.getVisibleLeafColumns().length - 1
|
||||
@@ -703,7 +808,9 @@ function DataGridTableHeadRowCellResize<TData>({
|
||||
"absolute top-0 h-full cursor-col-resize user-select-none touch-none z-10 flex",
|
||||
isLastVisibleColumn
|
||||
? "end-0 w-5 justify-end before:hidden"
|
||||
: "-end-2 w-5 justify-center before:absolute before:inset-y-0 before:w-px before:-translate-x-px before:bg-border",
|
||||
: isPinned
|
||||
? "end-0 w-5 justify-end before:hidden"
|
||||
: "-end-2 w-5 justify-center before:absolute before:inset-y-0 before:w-px before:-translate-x-px before:bg-border",
|
||||
column.getIsResizing() &&
|
||||
(isResizeModeOnEnd
|
||||
? "opacity-100"
|
||||
@@ -766,7 +873,7 @@ function DataGridTableResizeIndicator({
|
||||
>
|
||||
<div className="bg-primary/85 absolute inset-y-0 left-0 w-px -translate-x-1/2" />
|
||||
<div
|
||||
className="bg-primary absolute top-0 left-0 -translate-x-1/2 rounded-b-sm shadow-xs"
|
||||
className="bg-primary rounded-b-sm absolute top-0 left-0 -translate-x-1/2 shadow-xs"
|
||||
style={{
|
||||
width: 5,
|
||||
height: Math.max(headerHeight, 6),
|
||||
@@ -777,7 +884,13 @@ function DataGridTableResizeIndicator({
|
||||
}
|
||||
|
||||
function DataGridTableRowSpacer() {
|
||||
return <tbody aria-hidden="true" className="h-2"></tbody>
|
||||
return (
|
||||
<tbody
|
||||
aria-hidden="true"
|
||||
className="h-2"
|
||||
data-slot="data-grid-table-body-spacer"
|
||||
></tbody>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableBody({ children }: { children: ReactNode }) {
|
||||
@@ -785,8 +898,8 @@ function DataGridTableBody({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<tbody
|
||||
data-slot="data-grid-table-body"
|
||||
className={cn(
|
||||
"[&_tr:last-child]:border-0",
|
||||
props.tableLayout?.rowRounded &&
|
||||
"[&_td:first-child]:rounded-l-lg",
|
||||
props.tableLayout?.rowRounded &&
|
||||
@@ -802,7 +915,10 @@ function DataGridTableBody({ children }: { children: ReactNode }) {
|
||||
function DataGridTableFoot({ children }: { children: ReactNode }) {
|
||||
const { props } = useDataGrid()
|
||||
return (
|
||||
<tfoot className={cn("border-t", props.tableClassNames?.footer)}>
|
||||
<tfoot
|
||||
data-slot="data-grid-table-foot"
|
||||
className={cn(props.tableClassNames?.footer)}
|
||||
>
|
||||
{children}
|
||||
</tfoot>
|
||||
)
|
||||
@@ -810,10 +926,14 @@ function DataGridTableFoot({ children }: { children: ReactNode }) {
|
||||
|
||||
function DataGridTableFootRow({ children }: { children: ReactNode }) {
|
||||
const { props } = useDataGrid()
|
||||
const footRowBottomBorderClasses = "[&:not(:last-child)>td]:border-b"
|
||||
|
||||
return (
|
||||
<tr
|
||||
data-slot="data-grid-table-foot-row"
|
||||
className={cn(
|
||||
"bg-muted/40 dark:bg-background",
|
||||
props.tableLayout?.footerBackground && "bg-muted/40 dark:bg-background",
|
||||
props.tableLayout?.rowBorder && footRowBottomBorderClasses,
|
||||
props.tableLayout?.cellBorder && "*:last:border-e-0"
|
||||
)}
|
||||
>
|
||||
@@ -840,8 +960,9 @@ function DataGridTableFootRowCell({
|
||||
<td
|
||||
colSpan={colSpan}
|
||||
className={cn(
|
||||
"text-secondary-foreground/80 border-t align-middle font-medium",
|
||||
"text-secondary-foreground/80 align-middle font-medium",
|
||||
spacing,
|
||||
props.tableLayout?.footerBackground && "bg-muted/40 dark:bg-background",
|
||||
props.tableLayout?.cellBorder && "border-e",
|
||||
className
|
||||
)}
|
||||
@@ -870,7 +991,6 @@ function DataGridTableBodyRowSkeleton({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<DataGridTableFillBodyCell />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -904,7 +1024,7 @@ function DataGridTableBodyRowSkeletonCell<TData>({
|
||||
column.columnDef.meta?.cellClassName,
|
||||
props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
'[&[data-pinned][data-last-col]]:border-border data-pinned:bg-background/90 data-pinned:backdrop-blur-xs" [&[data-pinned=left][data-last-col=left]]:border-e! [&[data-pinned=right][data-last-col=right]]:border-s!',
|
||||
"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)]",
|
||||
column.getIndex() === 0 ||
|
||||
column.getIndex() === table.getVisibleFlatColumns().length - 1
|
||||
? props.tableClassNames?.edgeCell
|
||||
@@ -934,6 +1054,9 @@ function DataGridTableBodyRow<TData>({
|
||||
const { props, table } = useDataGrid()
|
||||
const isRowPinned = row.getIsPinned()
|
||||
|
||||
const bodyRowBottomBorderClasses =
|
||||
"[&:not(:last-child)>td]:border-b [tbody:has(+tfoot)_&:last-child>td]:border-b [*:has(>[data-slot=data-grid]+[data-slot=data-grid-pagination])_[data-slot=data-grid]_&:last-child>td]:border-b"
|
||||
|
||||
return (
|
||||
<tr
|
||||
ref={(node) => {
|
||||
@@ -954,8 +1077,9 @@ function DataGridTableBodyRow<TData>({
|
||||
props.onRowClick && "cursor-pointer",
|
||||
!props.tableLayout?.stripped &&
|
||||
props.tableLayout?.rowBorder &&
|
||||
"border-border border-b [&:not(:last-child)>td]:border-b",
|
||||
props.tableLayout?.cellBorder && "*:last:border-e-0",
|
||||
bodyRowBottomBorderClasses,
|
||||
props.tableLayout?.cellBorder &&
|
||||
`*:last:border-e-0 ${bodyRowBottomBorderClasses}`,
|
||||
props.tableLayout?.stripped &&
|
||||
"odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent",
|
||||
table.options.enableRowSelection && "*:first:relative",
|
||||
@@ -969,23 +1093,22 @@ function DataGridTableBodyRow<TData>({
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<DataGridTableFillBodyCell />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableBodyRowExpandded<TData>({ row }: { row: Row<TData> }) {
|
||||
const { props, table } = useDataGrid()
|
||||
const bodyRowBottomBorderClasses =
|
||||
"[&:not(:last-child)>td]:border-b [tbody:has(+tfoot)_&:last-child>td]:border-b [*:has(>[data-slot=data-grid]+[data-slot=data-grid-pagination])_[data-slot=data-grid]_&:last-child>td]:border-b"
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
props.tableLayout?.rowBorder && "[&:not(:last-child)>td]:border-b"
|
||||
)}
|
||||
className={cn(props.tableLayout?.rowBorder && bodyRowBottomBorderClasses)}
|
||||
>
|
||||
<td
|
||||
colSpan={
|
||||
row.getVisibleCells().length +
|
||||
getDataGridTableOrderedVisibleCells(row).length +
|
||||
(props.tableLayout?.columnsResizable ? 1 : 0)
|
||||
}
|
||||
>
|
||||
@@ -1022,9 +1145,7 @@ function DataGridTableBodyRowCell<TData>({
|
||||
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
ref={dndRef}
|
||||
{...(props.tableLayout?.columnsDraggable && !isPinned ? { cell } : {})}
|
||||
style={{
|
||||
...(props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
@@ -1048,7 +1169,11 @@ function DataGridTableBodyRowCell<TData>({
|
||||
cell.column.columnDef.meta?.cellClassName,
|
||||
props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
'[&[data-pinned][data-last-col]]:border-border data-pinned:bg-background/90 data-pinned:backdrop-blur-xs" [&[data-pinned=left][data-last-col=left]]:border-e! [&[data-pinned=right][data-last-col=right]]:border-s!',
|
||||
cn(
|
||||
"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)]"
|
||||
),
|
||||
column.getIndex() === 0 ||
|
||||
column.getIndex() === row.getVisibleCells().length - 1
|
||||
? props.tableClassNames?.edgeCell
|
||||
@@ -1069,6 +1194,12 @@ function DataGridTableRenderedRow<TData>({
|
||||
pinnedBoundary?: DataGridTablePinnedBoundary
|
||||
rowRef?: React.Ref<HTMLTableRowElement>
|
||||
}) {
|
||||
const { props, table } = useDataGrid()
|
||||
const leftVisibleCells = row.getLeftVisibleCells()
|
||||
const centerVisibleCells = row.getCenterVisibleCells()
|
||||
const rightVisibleCells = row.getRightVisibleCells()
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<DataGridTableBodyRow
|
||||
@@ -1076,11 +1207,24 @@ function DataGridTableRenderedRow<TData>({
|
||||
pinnedBoundary={pinnedBoundary}
|
||||
rowRef={rowRef}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
|
||||
{[...leftVisibleCells, ...centerVisibleCells].map(
|
||||
(cell: Cell<TData, unknown>) => (
|
||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
{rightVisibleCells.map((cell: Cell<TData, unknown>) => (
|
||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
))}
|
||||
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
||||
</Fragment>
|
||||
@@ -1090,16 +1234,16 @@ function DataGridTableRenderedRow<TData>({
|
||||
function DataGridTableEmpty() {
|
||||
const { table, props } = useDataGrid()
|
||||
const visibleColumnCount =
|
||||
table.getVisibleLeafColumns().length +
|
||||
getDataGridTableOrderedVisibleColumns(table).length +
|
||||
(props.tableLayout?.columnsResizable ? 1 : 0)
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={Math.max(visibleColumnCount, 1)}
|
||||
className="text-muted-foreground text-sm py-6 text-center"
|
||||
className="text-muted-foreground py-6 text-center text-sm"
|
||||
>
|
||||
{props.emptyMessage || DATA_GRID_MESSAGES_RU.emptyMessage}
|
||||
{props.emptyMessage || "No data available"}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -1110,9 +1254,9 @@ function DataGridTableLoader() {
|
||||
|
||||
return (
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="text-muted-foreground bg-card rounded-lg text-sm flex items-center gap-2 border px-4 py-2 leading-none font-medium">
|
||||
<div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium">
|
||||
<Spinner className="size-5 opacity-60" />
|
||||
{props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||
{props.loadingMessage || "Loading..."}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1124,7 +1268,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isPinned ? DATA_GRID_MESSAGES_RU.unpinRowLabel : DATA_GRID_MESSAGES_RU.pinRowLabel}
|
||||
aria-label={isPinned ? "Unpin row" : "Pin row"}
|
||||
onClick={() => {
|
||||
if (isPinned) {
|
||||
row.pin(false)
|
||||
@@ -1133,7 +1277,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground inline-flex size-7 items-center justify-center rounded-md transition-colors",
|
||||
"text-muted-foreground hover:text-foreground rounded-lg inline-flex size-7 items-center justify-center transition-colors",
|
||||
isPinned && "text-primary hover:text-primary/80"
|
||||
)}
|
||||
>
|
||||
@@ -1180,7 +1324,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label={DATA_GRID_MESSAGES_RU.selectRowLabel}
|
||||
aria-label="Select row"
|
||||
className="align-[inherit]"
|
||||
/>
|
||||
</>
|
||||
@@ -1199,7 +1343,7 @@ function DataGridTableRowSelectAll() {
|
||||
indeterminate={isSomeSelected && !isAllSelected}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label={DATA_GRID_MESSAGES_RU.selectAllLabel}
|
||||
aria-label="Select all"
|
||||
className="align-[inherit]"
|
||||
/>
|
||||
)
|
||||
@@ -1210,15 +1354,31 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
|
||||
const pagination = table.getState().pagination
|
||||
|
||||
if (isLoading && props.loadingMode === "skeleton" && pagination?.pageSize) {
|
||||
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
|
||||
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
||||
const rightVisibleColumns = table.getRightVisibleLeafColumns()
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => (
|
||||
<DataGridTableBodyRowSkeletonCell column={column} key={colIndex}>
|
||||
{[...leftVisibleColumns, ...centerVisibleColumns].map((column) => (
|
||||
<DataGridTableBodyRowSkeletonCell column={column} key={column.id}>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
))}
|
||||
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
{rightVisibleColumns.map((column) => (
|
||||
<DataGridTableBodyRowSkeletonCell column={column} key={column.id}>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
))}
|
||||
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))}
|
||||
</>
|
||||
@@ -1250,7 +1410,7 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||
{props.loadingMessage || "Loading..."}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1289,35 +1449,29 @@ const MemoizedDataGridTableBodyRows = memo(
|
||||
|
||||
function DataGridTableHeader<TData>() {
|
||||
const { table, props } = useDataGrid()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
return (
|
||||
<DataGridTableViewport>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
{mergedHeaderGroups.map((headerGroup) => {
|
||||
return (
|
||||
<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={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
@@ -1325,9 +1479,37 @@ function DataGridTableHeader<TData>() {
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
{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>
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
@@ -1342,36 +1524,36 @@ function DataGridTable<TData>({
|
||||
renderHeader?: boolean
|
||||
}) {
|
||||
const { table, props } = useDataGrid()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
return (
|
||||
<DataGridTableViewport>
|
||||
<DataGridTableBase>
|
||||
{renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
{mergedHeaderGroups.map((headerGroup) => {
|
||||
return (
|
||||
<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={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
<DataGridTableHeadRowCell
|
||||
header={header}
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
@@ -1379,9 +1561,40 @@ function DataGridTable<TData>({
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -1413,6 +1626,8 @@ export {
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableFootRow,
|
||||
DataGridTableFootRowCell,
|
||||
@@ -1427,8 +1642,11 @@ export {
|
||||
DataGridTableRowSelectAll,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
getDataGridTableMergedHeaderGroups,
|
||||
getPinningStyles,
|
||||
getDataGridTableResolvedRows,
|
||||
getDataGridTableRowSections,
|
||||
hasDataGridTableRightPinnedColumns,
|
||||
}
|
||||
|
||||
export type { DataGridTablePinnedBoundary }
|
||||
@@ -17,6 +17,7 @@ declare module "@tanstack/react-table" {
|
||||
cellClassName?: string
|
||||
skeleton?: ReactNode
|
||||
expandedContent?: (row: TData) => ReactNode
|
||||
autoSize?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +82,7 @@ export interface DataGridProps<TData extends object> {
|
||||
rowRounded?: boolean
|
||||
stripped?: boolean
|
||||
headerBackground?: boolean
|
||||
footerBackground?: boolean
|
||||
headerBorder?: boolean
|
||||
headerSticky?: boolean
|
||||
width?: "auto" | "fixed"
|
||||
@@ -106,7 +108,7 @@ export interface DataGridProps<TData extends object> {
|
||||
}
|
||||
|
||||
const DataGridContext = createContext<
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
DataGridContextProps<any> | undefined
|
||||
>(undefined)
|
||||
|
||||
@@ -192,7 +194,8 @@ function DataGrid<TData extends object>({
|
||||
rowRounded: false,
|
||||
stripped: false,
|
||||
headerSticky: false,
|
||||
headerBackground: true,
|
||||
headerBackground: false,
|
||||
footerBackground: false,
|
||||
headerBorder: true,
|
||||
width: "fixed",
|
||||
columnsVisibility: false,
|
||||
@@ -253,12 +256,7 @@ function DataGridContainer({
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid"
|
||||
className={cn(
|
||||
"w-full overflow-hidden",
|
||||
border &&
|
||||
"border-border rounded-lg border",
|
||||
className
|
||||
)}
|
||||
className={cn("w-full overflow-hidden", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { createContext, useCallback, useContext, useState } from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
// Types
|
||||
type TimelineContextValue = {
|
||||
activeStep: number
|
||||
setActiveStep: (step: number) => void
|
||||
}
|
||||
|
||||
// Context
|
||||
const TimelineContext = createContext<TimelineContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const useTimeline = () => {
|
||||
const context = useContext(TimelineContext)
|
||||
if (!context) {
|
||||
throw new Error("useTimeline must be used within a Timeline")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Components
|
||||
interface TimelineProps extends useRender.ComponentProps<"div"> {
|
||||
defaultValue?: number
|
||||
value?: number
|
||||
onValueChange?: (value: number) => void
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
|
||||
function Timeline({
|
||||
defaultValue = 1,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = "vertical",
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineProps) {
|
||||
const [activeStep, setInternalStep] = useState(defaultValue)
|
||||
|
||||
const setActiveStep = useCallback(
|
||||
(step: number) => {
|
||||
if (value === undefined) {
|
||||
setInternalStep(step)
|
||||
}
|
||||
onValueChange?.(step)
|
||||
},
|
||||
[value, onValueChange]
|
||||
)
|
||||
|
||||
const currentStep = value ?? activeStep
|
||||
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
),
|
||||
"data-orientation": orientation,
|
||||
"data-slot": "timeline",
|
||||
children,
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineContext.Provider
|
||||
value={{ activeStep: currentStep, setActiveStep }}
|
||||
>
|
||||
{useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})}
|
||||
</TimelineContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// TimelineContent
|
||||
function TimelineContent({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
className: cn("text-muted-foreground text-sm", className),
|
||||
"data-slot": "timeline-content",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineDate
|
||||
type TimelineDateProps = useRender.ComponentProps<"time">
|
||||
|
||||
function TimelineDate({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineDateProps) {
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-date",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "time",
|
||||
render,
|
||||
props: mergeProps<"time">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineHeader
|
||||
function TimelineHeader({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
className: cn(className),
|
||||
"data-slot": "timeline-header",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineIndicator
|
||||
type TimelineIndicatorProps = useRender.ComponentProps<"div">
|
||||
|
||||
function TimelineIndicator({
|
||||
className,
|
||||
children,
|
||||
render,
|
||||
...props
|
||||
}: TimelineIndicatorProps) {
|
||||
const defaultProps = {
|
||||
"aria-hidden": true,
|
||||
className: cn(
|
||||
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-indicator",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineItem
|
||||
interface TimelineItemProps extends useRender.ComponentProps<"div"> {
|
||||
step: number
|
||||
}
|
||||
|
||||
function TimelineItem({
|
||||
step,
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineItemProps) {
|
||||
const { activeStep } = useTimeline()
|
||||
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary",
|
||||
className
|
||||
),
|
||||
"data-completed": step <= activeStep || undefined,
|
||||
"data-slot": "timeline-item",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineSeparator
|
||||
function TimelineSeparator({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
"aria-hidden": true,
|
||||
className: cn(
|
||||
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-separator",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineTitle
|
||||
function TimelineTitle({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"h3">) {
|
||||
const defaultProps = {
|
||||
className: cn("font-medium text-sm", className),
|
||||
"data-slot": "timeline-title",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "h3",
|
||||
render,
|
||||
props: mergeProps<"h3">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { panelCardInsetClassName } from '@/components/panel-card'
|
||||
import { SectionCards } from './section-cards'
|
||||
@@ -19,55 +17,43 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||
|
||||
export function AnalyticsDashboardSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3 lg:items-start">
|
||||
<Card className="gap-0">
|
||||
<CardContent className="space-y-5 py-5">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-2 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="gap-0">
|
||||
<CardContent className="space-y-5 py-5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-36 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="gap-0">
|
||||
<CardContent className="space-y-5 py-5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-44 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-12">
|
||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-8" />
|
||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-4" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<Card className="gap-0">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className={cn('flex gap-2 border-b', panelCardInsetClassName)}>
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div className={cn('flex gap-2 border-b', panelCardInsetClassName)} key={`r-${r}`}>
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl ring-1 ring-foreground/10">
|
||||
<div className="flex flex-col">
|
||||
<div className={`flex gap-2 border-b ${panelCardInsetClassName}`}>
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div className={`flex gap-2 border-b ${panelCardInsetClassName}`} key={`r-${r}`}>
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,16 +4,15 @@ import { RefreshCw } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
import {
|
||||
DashboardNetworkCapacityCard,
|
||||
DashboardOperationsFlowCard,
|
||||
DashboardPlatformCard,
|
||||
} from '@/components/analytics'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { DashboardQuickActions } from '@/components/dashboard/dashboard-quick-actions'
|
||||
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { DashboardKpiGrid } from '@/components/dashboard/dashboard-kpi-grid'
|
||||
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
||||
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
||||
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
|
||||
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
|
||||
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
|
||||
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
@@ -89,51 +88,68 @@ function DashboardComponent() {
|
||||
{initialLoading ? (
|
||||
<AnalyticsDashboardSkeleton />
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-3 lg:items-start">
|
||||
<DashboardPlatformCard
|
||||
<>
|
||||
<DashboardKpiGrid
|
||||
modules={modules}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
jobs={jobs}
|
||||
revisions={revisions}
|
||||
/>
|
||||
<DashboardNetworkCapacityCard peers={peers} speakers={speakers} jobs={jobs} />
|
||||
<DashboardOperationsFlowCard jobs={jobs} modules={modules} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-12 xl:items-start">
|
||||
<div className="xl:col-span-8">
|
||||
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
||||
</div>
|
||||
<div className="xl:col-span-4">
|
||||
<DashboardActivityTimeline
|
||||
jobs={jobs}
|
||||
revisions={revisions}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
|
||||
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<DataGridCard
|
||||
<DashboardFramePanel
|
||||
title="Недавние задачи"
|
||||
description="Последние фоновые операции"
|
||||
className="h-full"
|
||||
>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-3 h-24 w-auto" />
|
||||
<Skeleton className="m-4 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
|
||||
)}
|
||||
</DataGridCard>
|
||||
</DashboardFramePanel>
|
||||
|
||||
<DataGridCard
|
||||
<DashboardFramePanel
|
||||
title="Последние ревизии"
|
||||
description="История конфигураций"
|
||||
className="h-full"
|
||||
>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-3 h-24 w-auto" />
|
||||
<Skeleton className="m-4 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
||||
)}
|
||||
</DataGridCard>
|
||||
</DashboardFramePanel>
|
||||
</div>
|
||||
|
||||
<PanelCard
|
||||
title="Быстрые действия"
|
||||
description="Частые переходы к настройке и деплою"
|
||||
footer={<DashboardQuickActions />}
|
||||
footerClassName="gap-2 px-5 py-4"
|
||||
/>
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-sm font-semibold">Быстрые действия</h2>
|
||||
<p className="text-muted-foreground text-sm">Частые переходы к настройке и деплою</p>
|
||||
</div>
|
||||
<DashboardQuickLinks />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
"include": ["src", "vite.config.ts"],
|
||||
"exclude": ["src/components/blocks"]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import type { TooltipValueType } from "recharts"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn(
|
||||
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent",
|
||||
outline: "border-border",
|
||||
muted: "border-transparent bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "gap-2.5 px-3 py-2.5",
|
||||
sm: "gap-2.5 px-3 py-2.5",
|
||||
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(itemVariants({ variant, size, className })),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "item",
|
||||
variant,
|
||||
size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
children,
|
||||
value,
|
||||
...props
|
||||
}: ProgressPrimitive.Root.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
data-slot="progress"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ProgressTrack>
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Track
|
||||
className={cn(
|
||||
"relative flex h-1 w-full items-center overflow-hidden rounded-full bg-muted",
|
||||
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-track"
|
||||
@@ -23,39 +43,12 @@ function ProgressIndicator({
|
||||
return (
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn("h-full rounded-full bg-primary transition-all", className)}
|
||||
className={cn("h-full bg-primary transition-all", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
children,
|
||||
value,
|
||||
...props
|
||||
}: ProgressPrimitive.Root.Props) {
|
||||
const hasCustomTrack = React.Children.toArray(children).some(
|
||||
(child) => React.isValidElement(child) && child.type === ProgressTrack,
|
||||
)
|
||||
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
data-slot="progress"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hasCustomTrack ? (
|
||||
<ProgressTrack>
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
) : null}
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Label
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
@@ -13,8 +15,7 @@ function Tabs({
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2",
|
||||
orientation === "horizontal" ? "flex-col" : "flex-row",
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -23,7 +24,7 @@ function Tabs({
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:h-auto data-[variant=line]:rounded-none data-[variant=line]:border-b data-[variant=line]:border-border data-[variant=line]:bg-transparent data-[variant=line]:p-0",
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -57,11 +58,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:h-auto group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:rounded-none group-data-[variant=line]/tabs-list:border-0 group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:px-0 group-data-[variant=line]/tabs-list:pb-3 group-data-[variant=line]/tabs-list:pt-1 group-data-[variant=line]/tabs-list:shadow-none",
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:group-data-[variant=line]/tabs-list:after:bottom-0 group-data-[orientation=horizontal]/tabs:group-data-[variant=line]/tabs-list:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100 group-data-[variant=line]/tabs-list:data-active:after:z-10",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user