Compare commits

...
1 Commits
Author SHA1 Message Date
Denozordec 39f1295438 refactor: update configuration and enhance skeleton components for improved UI consistency
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m5s
CI / go (push) Successful in 1m5s
CI / bird2 (push) Successful in 19s
CI / release (push) Successful in 4m22s
Modified .npmrc to set a new store directory. Updated eslint configuration to ignore additional paths. Adjusted tsconfig to exclude specific components and refined the SectionCardsSkeleton and AnalyticsDashboardSkeleton for better layout and loading states. Removed the deprecated DashboardQuickActions component to streamline the codebase.
2026-07-09 18:23:53 +07:00
78 changed files with 8226 additions and 449 deletions
+1 -1
View File
@@ -1 +1 @@
engine-strict=true
store-dir=C:\Users\shats\AppData\Local\pnpm-test-store\store\v10
+1 -1
View File
@@ -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>
+256
View File
@@ -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,
}
+30 -44
View File
@@ -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>
)
}
+42 -26
View File
@@ -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>
)
}
+2 -1
View File
@@ -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
-2
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
-2
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
-2
View File
@@ -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"
+201
View File
@@ -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,
}
+2
View File
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import { cn } from "@evobgp/ui/lib/utils"
+23 -30
View File
@@ -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
+2
View File
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
+6 -6
View File
@@ -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}