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
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.
This commit is contained in:
@@ -1 +1 @@
|
||||
engine-strict=true
|
||||
store-dir=C:\Users\shats\AppData\Local\pnpm-test-store\store\v10
|
||||
|
||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import globals from 'globals'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
|
||||
{ ignores: ['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { CardItem } from "./card-item"
|
||||
import { CARDS } from "./data"
|
||||
|
||||
export function CardGrid() {
|
||||
return (
|
||||
<div className="@container w-full">
|
||||
{/* Grid */}
|
||||
<div className="grid gap-5 @2xl:grid-cols-3">
|
||||
{CARDS.map((card) => (
|
||||
<CardItem key={card.label} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
} from "@/components/reui/frame"
|
||||
import { ICard } from "./data"
|
||||
import { LinkIcon } from "lucide-react"
|
||||
|
||||
export function CardItem({ card }: { card: ICard }) {
|
||||
return (
|
||||
<Frame spacing="sm">
|
||||
{/* Header */}
|
||||
<FrameHeader className="px-1! py-1!">
|
||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||
{card.icon}
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
{/* Content */}
|
||||
<FramePanel className="space-y-3.5">
|
||||
<p className="text-xs leading-relaxed">{card.description}</p>
|
||||
<a
|
||||
href="#"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
<LinkIcon aria-hidden="true" className="size-2.5 shrink-0" />
|
||||
{card.link}
|
||||
</a>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { PackageIcon, TrendingUp, MapPinIcon } from "lucide-react"
|
||||
|
||||
export interface ICard {
|
||||
label: string
|
||||
icon: ReactNode
|
||||
description: string
|
||||
link: string
|
||||
}
|
||||
|
||||
export const CARDS: ICard[] = [
|
||||
{
|
||||
label: "Binance",
|
||||
icon: (
|
||||
<PackageIcon aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Track trading volumes, liquidity shifts, and price movements for informed decisions",
|
||||
link: "https://www.binance.com/en/markets/over..",
|
||||
},
|
||||
{
|
||||
label: "Revenue",
|
||||
icon: (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Get instant insights into earnings and cash flow performance.",
|
||||
link: "https://nexo.io/earn/crypto-detailed-portfol..",
|
||||
},
|
||||
{
|
||||
label: "Shipments",
|
||||
icon: (
|
||||
<MapPinIcon aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Stay on top of deliveries and track shipment statuses efficiently.",
|
||||
link: "https://www.educare.io/platform/analytics/e..",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { CardGrid } from "./components/card-grid"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full max-w-5xl items-center justify-center p-6">
|
||||
<CardGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Cell, Pie, PieChart } from "recharts"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@evobgp/ui/components/chart"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
allocationMemberCount,
|
||||
allocationMembers,
|
||||
allocationPeriods,
|
||||
inflowChartConfig,
|
||||
inflowPeriods,
|
||||
SEGMENT_COUNT,
|
||||
type AllocationPeriod,
|
||||
type InflowFund,
|
||||
type InflowPeriod,
|
||||
} from "./data"
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
const segments = Array.from({ length: SEGMENT_COUNT }, (_, index) => index)
|
||||
|
||||
const CHART_REVEAL_STYLE = `
|
||||
@keyframes dashboard-1-flow-reveal-up {
|
||||
from {
|
||||
clip-path: inset(100% 0 0 0);
|
||||
opacity: 0.75;
|
||||
}
|
||||
to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-1-flow-reveal-up {
|
||||
animation: dashboard-1-flow-reveal-up 680ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-1-flow-reveal-up {
|
||||
animation: none;
|
||||
clip-path: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
function AllocationMeter({ period }: { period: AllocationPeriod }) {
|
||||
return (
|
||||
<div
|
||||
aria-label={`${period.label} capacity allocation is ${period.allocation}`}
|
||||
className="flex h-7 w-full items-stretch justify-between"
|
||||
role="img"
|
||||
>
|
||||
{segments.map((segment) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
key={segment}
|
||||
className={cn(
|
||||
"h-full w-1 shrink-0 rounded-full",
|
||||
segment < period.filledSegments ? "bg-success" : "bg-muted"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberStack() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<AvatarGroup className="-space-x-2">
|
||||
{allocationMembers.map((member) => (
|
||||
<Avatar key={member.name} className="size-6">
|
||||
{member.avatar ? (
|
||||
<AvatarImage src={member.avatar} alt={member.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="bg-background text-xs font-medium">
|
||||
{member.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{allocationMemberCount} Members
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AllocationChart() {
|
||||
return (
|
||||
<Frame className="@container h-full w-full">
|
||||
<FramePanel>
|
||||
<Tabs
|
||||
defaultValue={allocationPeriods[0].value}
|
||||
className="h-full w-full min-w-0 gap-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h2 className="text-sm font-medium">Capacity Allocation</h2>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground/70 hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex shrink-0 rounded-full p-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label="Capacity Allocation info"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="max-w-56 px-2.5 py-1.5 text-xs leading-5"
|
||||
>
|
||||
Fulfillment capacity by selected period.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<TabsList>
|
||||
{allocationPeriods.map((period) => (
|
||||
<TabsTrigger key={period.value} value={period.value}>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{allocationPeriods.map((period) => (
|
||||
<TabsContent
|
||||
key={period.value}
|
||||
value={period.value}
|
||||
className="mt-0"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Metric */}
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="text-[26px] font-medium">
|
||||
{period.allocation}
|
||||
</span>
|
||||
<span className="text-success text-xs font-medium">
|
||||
{period.delta}
|
||||
</span>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
{period.comparison}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<AllocationMeter period={period} />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
|
||||
<p>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
Queued Orders:
|
||||
</span>{" "}
|
||||
<span className="text-sm font-medium">
|
||||
{period.exposure}
|
||||
</span>
|
||||
</p>
|
||||
<MemberStack />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
type DonutSlice =
|
||||
| InflowFund
|
||||
| {
|
||||
key: "reserve"
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
function getDonutData(period: InflowPeriod) {
|
||||
const trackedShare = period.funds.reduce(
|
||||
(total, fund) => total + fund.share,
|
||||
0
|
||||
)
|
||||
const reserveShare = Math.max(100 - trackedShare, 0)
|
||||
|
||||
return [
|
||||
...period.funds,
|
||||
{
|
||||
key: "reserve",
|
||||
name: "Reserve Capacity",
|
||||
amount: "",
|
||||
share: reserveShare,
|
||||
color: "var(--muted)",
|
||||
fill: "var(--color-reserve)",
|
||||
},
|
||||
] satisfies DonutSlice[]
|
||||
}
|
||||
|
||||
function ChartTooltipFormatter(item: unknown) {
|
||||
const fund = item as DonutSlice
|
||||
const value = fund.key === "reserve" ? `${fund.share}%` : fund.amount
|
||||
|
||||
return (
|
||||
<div className="flex min-w-40 items-center justify-between gap-6">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground truncate">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-foreground font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoTooltip() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="About Decision Flow"
|
||||
className="text-muted-foreground/70 -my-1"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<InfoIcon aria-hidden="true" className="text-sm" data-icon="inline-start" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<p>Tracked decisions entering fulfillment lanes.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowDonut({ period }: { period: InflowPeriod }) {
|
||||
const chartData = getDonutData(period)
|
||||
|
||||
return (
|
||||
<div className="dashboard-1-flow-reveal-up relative size-[8.25rem] shrink-0">
|
||||
<ChartContainer
|
||||
aria-label={`Decision Flow: ${period.total} total for ${period.label}`}
|
||||
className="aspect-square size-[8.25rem]"
|
||||
config={inflowChartConfig}
|
||||
initialDimension={{ width: 132, height: 132 }}
|
||||
>
|
||||
<PieChart margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
wrapperStyle={{ zIndex: 30 }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
hideIndicator
|
||||
formatter={(_value, _name, item) =>
|
||||
ChartTooltipFormatter(item.payload)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="share"
|
||||
endAngle={-230}
|
||||
innerRadius={47}
|
||||
isAnimationActive={false}
|
||||
nameKey="name"
|
||||
outerRadius={62}
|
||||
paddingAngle={1}
|
||||
cornerRadius={3}
|
||||
startAngle={130}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{chartData.map((item) => (
|
||||
<Cell key={item.key} fill={item.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="bg-background/90 border-border/70 flex size-[5.25rem] flex-col items-center justify-center rounded-full border border-dashed">
|
||||
<span className="text-muted-foreground/70 text-xs">Flow</span>
|
||||
<span className="mt-0.5 text-sm font-semibold">{period.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowLegend({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<ul className="flex min-w-0 flex-1 flex-col">
|
||||
{period.funds.map((fund, index) => (
|
||||
<li key={fund.key}>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{fund.amount}</span>
|
||||
<span className="text-muted-foreground/70 w-8 text-right text-xs">
|
||||
{fund.share}%
|
||||
</span>
|
||||
</div>
|
||||
{index < period.funds.length - 1 ? (
|
||||
<Separator className="w-auto" />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowPeriodPanel({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<div className="grid gap-6 @sm:grid-cols-[8.25rem_minmax(0,1fr)] @sm:items-center">
|
||||
<InflowDonut period={period} />
|
||||
<InflowLegend period={period} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowChart() {
|
||||
return (
|
||||
<TooltipProvider delay={150}>
|
||||
<style>{CHART_REVEAL_STYLE}</style>
|
||||
<Frame className="@container h-full w-full">
|
||||
<FramePanel className="ps-3.5! pe-5! pt-5! pb-3.5!">
|
||||
<Tabs defaultValue="week" className="gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-0.5 ps-1.5">
|
||||
<h2 className="text-sm font-medium">Decision Flow</h2>
|
||||
<InfoTooltip />
|
||||
</div>
|
||||
|
||||
<TabsList className="w-full @sm:w-auto">
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsTrigger key={period.value} value={period.value}>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsContent
|
||||
key={period.value}
|
||||
value={period.value}
|
||||
className="mt-0"
|
||||
>
|
||||
<InflowPeriodPanel period={period} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<div className="@container grid h-full w-full min-w-0 auto-rows-fr gap-3">
|
||||
<AllocationChart />
|
||||
<InflowChart />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
|
||||
import { FULFILLMENT_CARDS, type FulfillmentCard } from "./data"
|
||||
|
||||
function CardItem({ card }: { card: FulfillmentCard }) {
|
||||
return (
|
||||
<FramePanel>
|
||||
{/* Heading */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Item
|
||||
className={cn(
|
||||
"p-0",
|
||||
"border-background flex size-10 items-center justify-center border-2 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white",
|
||||
card.iconBg
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.typeLabel}
|
||||
</p>
|
||||
<h3 className="text-sm leading-tight font-medium">{card.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-1.5">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.metricLabel}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xl font-medium tracking-tight">
|
||||
{card.balance}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
card.change.positive ? "text-teal-600" : "text-rose-600"
|
||||
)}
|
||||
>
|
||||
{card.change.percent} ({card.change.amount})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
{/* Grid */}
|
||||
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
|
||||
{FULFILLMENT_CARDS.map((card) => (
|
||||
<CardItem key={card.title} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameFooter,
|
||||
FramePanel,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Progress } from "@evobgp/ui/components/progress"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
PERFORMANCE_RANGE_OPTIONS,
|
||||
SHIFT_ACTIVITY,
|
||||
SHIFT_PERFORMANCE,
|
||||
SHIFT_PIPELINE_PROGRESS,
|
||||
} from "./data"
|
||||
import { TrendingUp, TrendingDown, CircleCheckIcon } from "lucide-react"
|
||||
|
||||
export function InvestorCard() {
|
||||
return (
|
||||
<Frame className="h-full w-full">
|
||||
{/* Content */}
|
||||
<FramePanel>
|
||||
<div className="mb-6 flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h3 className="text-base font-semibold">Shift Performance</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select defaultValue="today" items={PERFORMANCE_RANGE_OPTIONS}>
|
||||
<SelectTrigger className="h-8! w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
align="start"
|
||||
alignItemWithTrigger={false}
|
||||
className="w-28"
|
||||
>
|
||||
{PERFORMANCE_RANGE_OPTIONS.map((range) => (
|
||||
<SelectItem key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SHIFT_PERFORMANCE.map((item) => (
|
||||
<div
|
||||
className="flex flex-col items-start justify-start"
|
||||
key={item.label}
|
||||
>
|
||||
<div className="text-foreground text-xl font-bold">
|
||||
{item.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
{item.label}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 text-xs font-semibold [&_svg]:h-3 [&_svg]:w-3",
|
||||
item.trend === "positive"
|
||||
? "text-emerald-500"
|
||||
: "text-destructive"
|
||||
)}
|
||||
>
|
||||
{item.trend === "positive" ? (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
) : (
|
||||
<TrendingDown aria-hidden="true" />
|
||||
)}
|
||||
{item.delta}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
Pipeline Progress
|
||||
</span>
|
||||
<span className="text-foreground text-xs font-semibold">
|
||||
{SHIFT_PIPELINE_PROGRESS}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={SHIFT_PIPELINE_PROGRESS}
|
||||
className="h-1! **:data-[slot=progress-track]:h-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="text-foreground mb-2.5 text-sm font-medium">
|
||||
Recent Activity
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{SHIFT_ACTIVITY.map((activity) => (
|
||||
<li
|
||||
key={activity.id}
|
||||
className="flex items-center justify-between gap-2.5 text-sm"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<CircleCheckIcon className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
activity.tone === "success" && "text-emerald-500",
|
||||
activity.tone === "info" && "text-sky-500",
|
||||
activity.tone === "warning" && "text-amber-500"
|
||||
)} aria-hidden="true" />
|
||||
<span className="text-foreground truncate text-xs">
|
||||
{activity.title}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
activity.tone === "success"
|
||||
? "success-light"
|
||||
: activity.tone === "info"
|
||||
? "info-light"
|
||||
: "warning-light"
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
{activity.status}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
{/* Footer */}
|
||||
<FrameFooter className="flex-row items-center gap-2.5 p-2!">
|
||||
<Button variant="outline" className="flex-1">
|
||||
Schedule
|
||||
</Button>
|
||||
<Button className="flex-1">Full Report</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Chart as CapacityChart } from "./capacity-chart"
|
||||
import { Chart as ChartCards } from "./chart-cards"
|
||||
import { InvestorCard as CommanderCard } from "./commander-card"
|
||||
import { ExceptionGrid } from "./exception-grid"
|
||||
import { Navbar } from "./navbar"
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-2">
|
||||
<Navbar />
|
||||
|
||||
<section aria-label="Fulfillment metrics">
|
||||
<ChartCards />
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Fulfillment operations"
|
||||
className="grid min-w-0 items-stretch gap-3 @5xl:grid-cols-2"
|
||||
>
|
||||
<div className="flex min-w-0">
|
||||
<CommanderCard />
|
||||
</div>
|
||||
<div className="flex min-w-0">
|
||||
<CapacityChart />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="Fulfillment exception queue">
|
||||
<ExceptionGrid />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { type BadgeProps } from "@/components/reui/badge"
|
||||
|
||||
import { type ChartConfig } from "@evobgp/ui/components/chart"
|
||||
import { PackageIcon, TruckIcon, TriangleAlertIcon, BotIcon } from "lucide-react"
|
||||
|
||||
export type FulfillmentStatus = "On Time" | "At Risk" | "Delayed" | "Blocked"
|
||||
export type AutomationLevel = "Autopilot" | "Copilot" | "Manual"
|
||||
|
||||
export interface TeamMember {
|
||||
name: string
|
||||
initials: string
|
||||
avatar: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface FulfillmentException {
|
||||
id: string
|
||||
reference: string
|
||||
customer: string
|
||||
email: string
|
||||
avatar: string
|
||||
initials: string
|
||||
lane: string
|
||||
facility: string
|
||||
stage: string
|
||||
promise: string
|
||||
slaMinutes: number
|
||||
automation: AutomationLevel
|
||||
owner: string
|
||||
units: number
|
||||
value: number
|
||||
risk: string
|
||||
status: FulfillmentStatus
|
||||
}
|
||||
|
||||
export const STATUS_ORDER: FulfillmentStatus[] = [
|
||||
"On Time",
|
||||
"At Risk",
|
||||
"Delayed",
|
||||
"Blocked",
|
||||
]
|
||||
|
||||
export const STATUS_BADGE_VARIANT: Record<
|
||||
FulfillmentStatus,
|
||||
BadgeProps["variant"]
|
||||
> = {
|
||||
"On Time": "success-outline",
|
||||
"At Risk": "warning-outline",
|
||||
Delayed: "info-outline",
|
||||
Blocked: "destructive-outline",
|
||||
}
|
||||
|
||||
export const AUTOMATION_BADGE_VARIANT: Record<
|
||||
AutomationLevel,
|
||||
BadgeProps["variant"]
|
||||
> = {
|
||||
Autopilot: "success-light",
|
||||
Copilot: "info-light",
|
||||
Manual: "warning-light",
|
||||
}
|
||||
|
||||
export const NAV_MEMBERS: TeamMember[] = [
|
||||
{
|
||||
name: "Maya Singh",
|
||||
initials: "MS",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
role: "Fulfillment lead",
|
||||
},
|
||||
{
|
||||
name: "Leo Martins",
|
||||
initials: "LM",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
role: "Automation owner",
|
||||
},
|
||||
{
|
||||
name: "Nora Albright",
|
||||
initials: "NA",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
||||
role: "Capacity planner",
|
||||
},
|
||||
]
|
||||
|
||||
export const TEAM_MEMBERS = NAV_MEMBERS.map((member) => ({
|
||||
src: member.avatar,
|
||||
initials: member.initials,
|
||||
name: member.name,
|
||||
}))
|
||||
|
||||
export const TEAM_EXTRA_COUNT = 11
|
||||
|
||||
export interface FulfillmentCardChange {
|
||||
positive: boolean
|
||||
percent: string
|
||||
amount: string
|
||||
}
|
||||
|
||||
export interface FulfillmentCard {
|
||||
typeLabel: string
|
||||
title: string
|
||||
metricLabel: string
|
||||
balance: string
|
||||
change: FulfillmentCardChange
|
||||
icon: ReactNode
|
||||
iconBg: string
|
||||
}
|
||||
|
||||
export const FULFILLMENT_CARDS: FulfillmentCard[] = [
|
||||
{
|
||||
typeLabel: "Outbound",
|
||||
title: "Orders Ready",
|
||||
metricLabel: "Ready Volume",
|
||||
balance: "18,420",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+11.8%",
|
||||
amount: "1,946",
|
||||
},
|
||||
iconBg: "bg-neutral-950",
|
||||
icon: (
|
||||
<PackageIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Promise",
|
||||
title: "Same-Day SLA",
|
||||
metricLabel: "Service Level",
|
||||
balance: "94.8%",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+1.2 pts",
|
||||
amount: "shift",
|
||||
},
|
||||
iconBg: "bg-indigo-600",
|
||||
icon: (
|
||||
<TruckIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Inventory",
|
||||
title: "Stock Risk",
|
||||
metricLabel: "Blocked SKUs",
|
||||
balance: "31",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "13 fewer",
|
||||
amount: "since 06:00",
|
||||
},
|
||||
iconBg: "bg-amber-400",
|
||||
icon: (
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Policy",
|
||||
title: "AI Autopilot",
|
||||
metricLabel: "Auto Resolved",
|
||||
balance: "71.6%",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+8.4 pts",
|
||||
amount: "policy",
|
||||
},
|
||||
iconBg: "bg-cyan-600",
|
||||
icon: (
|
||||
<BotIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export type AllocationPeriod = {
|
||||
value: "week" | "month" | "year"
|
||||
label: string
|
||||
allocation: string
|
||||
delta: string
|
||||
comparison: string
|
||||
exposure: string
|
||||
filledSegments: number
|
||||
}
|
||||
|
||||
export type AllocationMember = {
|
||||
name: string
|
||||
initials: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export const SEGMENT_COUNT = 56
|
||||
export const allocationMemberCount = 6
|
||||
|
||||
export const allocationPeriods: AllocationPeriod[] = [
|
||||
{
|
||||
value: "week",
|
||||
label: "Week",
|
||||
allocation: "86%",
|
||||
delta: "+5.8%",
|
||||
comparison: "vs labor plan",
|
||||
exposure: "3,840 orders",
|
||||
filledSegments: 48,
|
||||
},
|
||||
{
|
||||
value: "month",
|
||||
label: "Month",
|
||||
allocation: "79%",
|
||||
delta: "+2.4%",
|
||||
comparison: "vs prior month",
|
||||
exposure: "18 priority lanes",
|
||||
filledSegments: 44,
|
||||
},
|
||||
{
|
||||
value: "year",
|
||||
label: "Year",
|
||||
allocation: "74%",
|
||||
delta: "+9.2%",
|
||||
comparison: "automation lift",
|
||||
exposure: "6 facilities",
|
||||
filledSegments: 41,
|
||||
},
|
||||
]
|
||||
|
||||
export const allocationMembers: AllocationMember[] = TEAM_MEMBERS.map(
|
||||
(member) => ({
|
||||
name: member.name,
|
||||
initials: member.initials,
|
||||
avatar: member.src,
|
||||
})
|
||||
)
|
||||
|
||||
export type PerformanceTrend = "positive" | "negative"
|
||||
export type ActivityTone = "success" | "info" | "warning"
|
||||
|
||||
export interface PerformanceMetric {
|
||||
label: string
|
||||
value: string
|
||||
trend: PerformanceTrend
|
||||
delta: string
|
||||
}
|
||||
|
||||
export interface ShiftActivity {
|
||||
id: string
|
||||
title: string
|
||||
time: string
|
||||
status: string
|
||||
tone: ActivityTone
|
||||
}
|
||||
|
||||
export const PERFORMANCE_RANGE_OPTIONS = [
|
||||
{ label: "Today", value: "today" },
|
||||
{ label: "Week", value: "week" },
|
||||
{ label: "Month", value: "month" },
|
||||
]
|
||||
|
||||
export const SHIFT_PERFORMANCE: PerformanceMetric[] = [
|
||||
{
|
||||
label: "Orders Cleared",
|
||||
value: "18.4k",
|
||||
trend: "positive",
|
||||
delta: "+11.8%",
|
||||
},
|
||||
{
|
||||
label: "SLA Recovery",
|
||||
value: "94.8%",
|
||||
trend: "positive",
|
||||
delta: "+1.2 pts",
|
||||
},
|
||||
{
|
||||
label: "Risk Exposure",
|
||||
value: "$128k",
|
||||
trend: "negative",
|
||||
delta: "-9.4%",
|
||||
},
|
||||
]
|
||||
|
||||
export const SHIFT_PIPELINE_PROGRESS = 76
|
||||
|
||||
export const SHIFT_ACTIVITY: ShiftActivity[] = [
|
||||
{
|
||||
id: "wave-release",
|
||||
title: "Released priority wave to dock B",
|
||||
time: "4 min ago",
|
||||
status: "Cleared",
|
||||
tone: "success",
|
||||
},
|
||||
{
|
||||
id: "carrier-reprice",
|
||||
title: "Carrier mix repriced for zone 6",
|
||||
time: "12 min ago",
|
||||
status: "Review",
|
||||
tone: "info",
|
||||
},
|
||||
{
|
||||
id: "inventory-hold",
|
||||
title: "Inventory hold isolated to 3 SKUs",
|
||||
time: "23 min ago",
|
||||
status: "Watch",
|
||||
tone: "warning",
|
||||
},
|
||||
]
|
||||
|
||||
export type InflowFundKey = "autopilot" | "copilot" | "manual" | "reserve"
|
||||
|
||||
export interface InflowFund {
|
||||
key: Exclude<InflowFundKey, "reserve">
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
export interface InflowPeriod {
|
||||
value: "week" | "month" | "year"
|
||||
label: string
|
||||
total: string
|
||||
headline: string
|
||||
description: string
|
||||
delta: string
|
||||
funds: InflowFund[]
|
||||
}
|
||||
|
||||
const inflowAutopilotColor = "oklch(0.62 0.19 149)"
|
||||
const inflowCopilotColor = "oklch(0.58 0.18 257)"
|
||||
const inflowManualColor = "oklch(0.72 0.16 78)"
|
||||
|
||||
export const inflowChartConfig = {
|
||||
flow: {
|
||||
label: "Flow",
|
||||
},
|
||||
autopilot: {
|
||||
label: "Autopilot",
|
||||
color: inflowAutopilotColor,
|
||||
},
|
||||
copilot: {
|
||||
label: "Copilot",
|
||||
color: inflowCopilotColor,
|
||||
},
|
||||
manual: {
|
||||
label: "Manual",
|
||||
color: inflowManualColor,
|
||||
},
|
||||
reserve: {
|
||||
label: "Reserve",
|
||||
color: "oklch(0.7 0.04 260)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const inflowPeriods: InflowPeriod[] = [
|
||||
{
|
||||
value: "week",
|
||||
label: "Week",
|
||||
total: "18.4k",
|
||||
headline: "Exception Flow",
|
||||
description: "Orders entering decision lanes",
|
||||
delta: "+6.2%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "9.1k",
|
||||
share: 49.5,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "5.2k",
|
||||
share: 28.3,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "2.8k",
|
||||
share: 15.2,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "month",
|
||||
label: "Month",
|
||||
total: "76.8k",
|
||||
headline: "Resolved Flow",
|
||||
description: "Completed decisions this month",
|
||||
delta: "+14.8%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "41.6k",
|
||||
share: 54.2,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "20.3k",
|
||||
share: 26.4,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "9.8k",
|
||||
share: 12.8,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "year",
|
||||
label: "Year",
|
||||
total: "812k",
|
||||
headline: "Network Flow",
|
||||
description: "Decisions across six facilities",
|
||||
delta: "+21.5%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "428k",
|
||||
share: 52.7,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "224k",
|
||||
share: 27.6,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "103k",
|
||||
share: 12.7,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const FULFILLMENT_ROWS: FulfillmentException[] = [
|
||||
{
|
||||
id: "row-1001",
|
||||
reference: "NSC-84721",
|
||||
customer: "Avery Outdoor",
|
||||
email: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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 protected]"
|
||||
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="[email protected]"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleInvite()}
|
||||
/>
|
||||
<Button onClick={handleInvite} disabled={!email.trim()}>
|
||||
Send invite
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NavbarActions } from "./navbar-actions"
|
||||
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
|
||||
import { NavbarPresence } from "./navbar-presence"
|
||||
|
||||
// Navbar with breadcrumb, team presence, and actions
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<header className="border-border bg-background sticky top-0 z-20 flex h-12 w-full shrink-0 items-center justify-between gap-2 border-b px-4">
|
||||
{/* Left - breadcrumb */}
|
||||
<NavbarBreadcrumb />
|
||||
|
||||
{/* Right - team presence + actions */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<NavbarPresence />
|
||||
<NavbarActions />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Dashboard } from "./components/dashboard"
|
||||
|
||||
export function Page() {
|
||||
return <Dashboard />
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
// Deterministic per-dot value so the field reads as noise, not a flat grid.
|
||||
function grain(i: number, j: number) {
|
||||
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
|
||||
return n - Math.floor(n)
|
||||
}
|
||||
|
||||
/**
|
||||
* Static dot field adapted from card-5's reviewed background effect.
|
||||
* The dots resolve from the current text color, so the surface stays
|
||||
* neutral and theme-aware without hard-coded colors.
|
||||
*/
|
||||
export function CardDotField({ className }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const GAP = 3
|
||||
const DOT = 1.5
|
||||
const BASE = 0.03
|
||||
const PEAK = 0.2
|
||||
|
||||
const draw = () => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
canvas.width = Math.round(rect.width * dpr)
|
||||
canvas.height = Math.round(rect.height * dpr)
|
||||
|
||||
ctx.fillStyle = getComputedStyle(canvas).color || "rgb(115,115,115)"
|
||||
ctx.fillRect(0, 0, 1, 1)
|
||||
const px = ctx.getImageData(0, 0, 1, 1).data
|
||||
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
ctx.fillStyle = color
|
||||
|
||||
const cols = Math.ceil(rect.width / GAP) + 1
|
||||
const rows = Math.ceil(rect.height / GAP) + 1
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const x = i * GAP
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const q = grain(i, j)
|
||||
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
|
||||
let a = (BASE + (PEAK - BASE) * q * q) * amp
|
||||
if (a > 1) a = 1
|
||||
ctx.globalAlpha = a
|
||||
ctx.fillRect(x, j * GAP, DOT, DOT)
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => draw())
|
||||
resizeObserver.observe(canvas)
|
||||
|
||||
const themeObserver = new MutationObserver(() => draw())
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style"],
|
||||
})
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
themeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
|
||||
type TooltipItem = {
|
||||
value?: unknown
|
||||
name?: unknown
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function ChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean
|
||||
payload?: readonly TooltipItem[]
|
||||
label?: string | number
|
||||
}) {
|
||||
if (!active || !payload?.length) return null
|
||||
|
||||
return (
|
||||
<Card className="bg-popover text-popover-foreground pointer-events-none p-0 shadow-md">
|
||||
<CardContent className="min-w-28 space-y-1.5 px-2 py-1.5">
|
||||
<div className="text-muted-foreground text-[10px] leading-none">
|
||||
{label}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{payload.map((item, index) => (
|
||||
<div
|
||||
key={`${String(item.name)}-${index}`}
|
||||
className="flex items-center justify-between gap-3 text-xs leading-none"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-1.5 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{String(item.name)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{Number(item.value ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { type ComponentProps } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
export type MetricTone = "danger" | "success" | "warning" | "info"
|
||||
|
||||
export type MetricCard = {
|
||||
id: string
|
||||
title: string
|
||||
label: string
|
||||
value: string
|
||||
delta: string
|
||||
deltaVariant: ComponentProps<typeof Badge>["variant"]
|
||||
detail: string
|
||||
tone: MetricTone
|
||||
sparkline: number[]
|
||||
}
|
||||
|
||||
export const metricCards: MetricCard[] = [
|
||||
{
|
||||
id: "signal-risk",
|
||||
title: "Signal Risk",
|
||||
label: "Threat Index",
|
||||
value: "Elevated",
|
||||
delta: "+12.5%",
|
||||
deltaVariant: "destructive-light",
|
||||
detail: "5 cases",
|
||||
tone: "danger",
|
||||
sparkline: [18, 21, 15, 33, 29, 35, 28, 31, 19],
|
||||
},
|
||||
{
|
||||
id: "mesh-uptime",
|
||||
title: "Mesh Uptime",
|
||||
label: "Service Health",
|
||||
value: "99.98%",
|
||||
delta: "+0.2%",
|
||||
deltaVariant: "success-light",
|
||||
detail: "58 zones",
|
||||
tone: "success",
|
||||
sparkline: [42, 40, 42, 38, 39, 41, 40, 43, 46],
|
||||
},
|
||||
{
|
||||
id: "edge-traffic",
|
||||
title: "Edge Traffic",
|
||||
label: "Scrubbed Load",
|
||||
value: "4.8 GB/s",
|
||||
delta: "-3.1%",
|
||||
deltaVariant: "warning-light",
|
||||
detail: "clean flow",
|
||||
tone: "warning",
|
||||
sparkline: [21, 27, 28, 34, 32, 35, 33, 31, 34],
|
||||
},
|
||||
{
|
||||
id: "sensor-reach",
|
||||
title: "Sensor Reach",
|
||||
label: "Global Nodes",
|
||||
value: "18,420",
|
||||
delta: "+6.8%",
|
||||
deltaVariant: "success-light",
|
||||
detail: "93 regions",
|
||||
tone: "success",
|
||||
sparkline: [25, 23, 29, 28, 26, 31, 27, 30, 29],
|
||||
},
|
||||
]
|
||||
|
||||
export const threatVectors = [
|
||||
{ name: "Bot", blocked: 34, watched: 62 },
|
||||
{ name: "Phish", blocked: 30, watched: 74 },
|
||||
{ name: "DDoS", blocked: 52, watched: 33 },
|
||||
{ name: "Inject", blocked: 22, watched: 48 },
|
||||
{ name: "Auth", blocked: 43, watched: 68 },
|
||||
{ name: "Probe", blocked: 18, watched: 58 },
|
||||
{ name: "Exfil", blocked: 56, watched: 35 },
|
||||
{ name: "Beacon", blocked: 38, watched: 64 },
|
||||
{ name: "Day0", blocked: 14, watched: 28 },
|
||||
]
|
||||
|
||||
export const networkFlow = [
|
||||
{ month: "January", api: 1820, webhook: 1640 },
|
||||
{ month: "February", api: 2340, webhook: 2160 },
|
||||
{ month: "March", api: 1960, webhook: 1880 },
|
||||
{ month: "April", api: 2780, webhook: 2540 },
|
||||
{ month: "May", api: 2100, webhook: 1920 },
|
||||
{ month: "June", api: 3120, webhook: 2880 },
|
||||
{ month: "July", api: 2540, webhook: 2320 },
|
||||
{ month: "August", api: 3480, webhook: 3160 },
|
||||
{ month: "September", api: 2860, webhook: 2580 },
|
||||
{ month: "October", api: 2420, webhook: 2140 },
|
||||
{ month: "November", api: 3240, webhook: 2960 },
|
||||
{ month: "December", api: 2680, webhook: 2440 },
|
||||
]
|
||||
|
||||
export const networkFlowSummary = {
|
||||
change: "+12.8%",
|
||||
year: "2026",
|
||||
}
|
||||
|
||||
const loadSamples = [
|
||||
72, 68, 22, 18, 61, 71, 20, 26, 31, 70, 46, 88, 39, 25, 12, 33, 18, 28, 42,
|
||||
36, 61, 68, 74, 70, 91, 32, 82, 66, 52, 76, 48, 35, 70, 62, 57, 49, 37, 58,
|
||||
71, 7, 11, 69, 34, 28, 40, 61, 17, 55, 64, 19, 63, 67,
|
||||
] as const
|
||||
|
||||
export type LoadState = "nominal" | "warm" | "critical"
|
||||
|
||||
export type LoadPoint = {
|
||||
node: string
|
||||
load: number
|
||||
state: LoadState
|
||||
}
|
||||
|
||||
export const loadDistribution: LoadPoint[] = loadSamples.map((load, index) => ({
|
||||
node: `N${String(index).padStart(2, "0")}`,
|
||||
load,
|
||||
state: load >= 86 ? "critical" : load >= 76 ? "warm" : "nominal",
|
||||
}))
|
||||
|
||||
const loadStateTotals = loadDistribution.reduce<Record<LoadState, number>>(
|
||||
(totals, point) => ({
|
||||
...totals,
|
||||
[point.state]: totals[point.state] + 1,
|
||||
}),
|
||||
{
|
||||
nominal: 0,
|
||||
warm: 0,
|
||||
critical: 0,
|
||||
}
|
||||
)
|
||||
|
||||
export const clusterLoadSummary = {
|
||||
details: [
|
||||
{
|
||||
label: "Nodes",
|
||||
value: String(loadDistribution.length),
|
||||
variant: "secondary",
|
||||
},
|
||||
{
|
||||
label: "Warm",
|
||||
value: String(loadStateTotals.warm),
|
||||
variant: "warning-light",
|
||||
},
|
||||
{
|
||||
label: "Critical",
|
||||
value: String(loadStateTotals.critical),
|
||||
variant: "destructive-light",
|
||||
},
|
||||
],
|
||||
} satisfies {
|
||||
details: Array<{
|
||||
label: string
|
||||
value: string
|
||||
variant: ComponentProps<typeof Badge>["variant"]
|
||||
}>
|
||||
}
|
||||
|
||||
export const activeThreats = [
|
||||
{
|
||||
id: "00",
|
||||
label: "API Flood",
|
||||
source: "Edge WAF",
|
||||
state: "Mitigating",
|
||||
value: 4521,
|
||||
progress: 92,
|
||||
tone: "danger",
|
||||
},
|
||||
{
|
||||
id: "01",
|
||||
label: "Mail Spoof",
|
||||
source: "Mail Relay",
|
||||
state: "Reviewing",
|
||||
value: 3102,
|
||||
progress: 64,
|
||||
tone: "warning",
|
||||
},
|
||||
{
|
||||
id: "02",
|
||||
label: "Cloud Probe",
|
||||
source: "Cloud API",
|
||||
state: "Queued",
|
||||
value: 1250,
|
||||
progress: 26,
|
||||
tone: "info",
|
||||
},
|
||||
{
|
||||
id: "03",
|
||||
label: "Mesh Beacon",
|
||||
source: "Int Node",
|
||||
state: "Watching",
|
||||
value: 420,
|
||||
progress: 9,
|
||||
tone: "success",
|
||||
},
|
||||
] satisfies Array<{
|
||||
id: string
|
||||
label: string
|
||||
source: string
|
||||
state: string
|
||||
value: number
|
||||
progress: number
|
||||
tone: MetricTone
|
||||
}>
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
|
||||
import { ChartTooltip } from "./chart-tooltip"
|
||||
import { activeThreats, clusterLoadSummary, loadDistribution } from "./data"
|
||||
import { PanelCorners, PanelHeading } from "./panel-heading"
|
||||
import { loadStateColor, toneStyles } from "./tone-styles"
|
||||
|
||||
const chartGridProps = {
|
||||
vertical: false,
|
||||
stroke: "var(--border)",
|
||||
strokeDasharray: "3 3",
|
||||
strokeOpacity: 0.75,
|
||||
}
|
||||
|
||||
export function LoadPanel() {
|
||||
return (
|
||||
<Card className="relative h-full overflow-hidden p-0">
|
||||
<PanelCorners />
|
||||
<CardContent className="flex h-full flex-col p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-sm leading-4 font-semibold">Cluster Load</h2>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-3 text-xs leading-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-zinc-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Normal
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-amber-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Warm
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-red-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Critical
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
{clusterLoadSummary.details.map((detail) => (
|
||||
<Badge
|
||||
key={detail.label}
|
||||
variant={detail.variant}
|
||||
radius="full"
|
||||
className="h-6 gap-1.5 px-2 text-xs"
|
||||
>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{detail.value}
|
||||
</span>
|
||||
<span>{detail.label}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto h-60 pt-5">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={loadDistribution}
|
||||
margin={{ top: 10, right: 0, bottom: 0, left: 0 }}
|
||||
barCategoryGap={2}
|
||||
>
|
||||
<CartesianGrid {...chartGridProps} />
|
||||
<XAxis
|
||||
dataKey="node"
|
||||
axisLine={false}
|
||||
interval={25}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis hide domain={[0, 100]} />
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--muted)" }}
|
||||
content={({ active, payload, label }) => (
|
||||
<ChartTooltip
|
||||
active={active}
|
||||
payload={payload}
|
||||
label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="load"
|
||||
name="Load"
|
||||
radius={[5, 5, 5, 5]}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{loadDistribution.map((entry) => (
|
||||
<Cell
|
||||
key={entry.node}
|
||||
fill={loadStateColor[entry.state]}
|
||||
opacity={entry.state === "nominal" ? 0.62 : 1}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActiveThreatsPanel() {
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<PanelCorners />
|
||||
<CardContent className="space-y-5 p-4">
|
||||
<PanelHeading title="Active Lanes" description="Threat Queue" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{activeThreats.map((threat) => {
|
||||
const tone = toneStyles[threat.tone]
|
||||
|
||||
return (
|
||||
<div key={threat.id} className="space-y-2.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn("size-2 shrink-0 rounded-full", tone.dot)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">
|
||||
[{threat.id}] {threat.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span>{threat.source}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
||||
/>
|
||||
<span>{threat.state}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm font-semibold tabular-nums">
|
||||
{threat.value.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted h-1.5 min-w-0 flex-1 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn("h-full rounded-full", tone.bar)}
|
||||
style={{ width: `${threat.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground w-8 text-right text-[10px] leading-none tabular-nums">
|
||||
{threat.progress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
|
||||
import { CardDotField } from "./card-dot-field"
|
||||
import { type MetricCard } from "./data"
|
||||
import { PanelCorners } from "./panel-heading"
|
||||
import { toneStyles } from "./tone-styles"
|
||||
|
||||
function Sparkline({
|
||||
values,
|
||||
color,
|
||||
}: {
|
||||
values: readonly number[]
|
||||
color: string
|
||||
}) {
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
const spread = Math.max(1, max - min)
|
||||
const points = values
|
||||
.map((value, index) => {
|
||||
const x = (index / (values.length - 1)) * 72
|
||||
const y = 28 - ((value - min) / spread) * 22
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 72 32"
|
||||
className="h-9 w-24 shrink-0 opacity-95"
|
||||
role="img"
|
||||
aria-label="Metric trend"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="miter"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MetricTile({ metric }: { metric: MetricCard }) {
|
||||
const tone = toneStyles[metric.tone]
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
||||
<PanelCorners />
|
||||
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate text-sm leading-4 font-semibold">
|
||||
{metric.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs leading-4">
|
||||
{metric.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="min-w-0 space-y-2.5">
|
||||
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight">
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={metric.deltaVariant} radius="full">
|
||||
{metric.delta}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{metric.detail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline values={metric.sparkline} color={tone.stroke} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useState } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { type DateRange } from "react-day-picker"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Calendar } from "@evobgp/ui/components/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { CalendarIcon, ShieldCheckIcon, DownloadIcon } from "lucide-react"
|
||||
|
||||
type PeriodKey = "last30" | "prev30"
|
||||
|
||||
type ReportDateRange = {
|
||||
from: Date
|
||||
to: Date
|
||||
}
|
||||
|
||||
type DateRangePreset = {
|
||||
id: string
|
||||
label: string
|
||||
period: PeriodKey
|
||||
range: ReportDateRange
|
||||
}
|
||||
|
||||
const reportRange = (
|
||||
fromMonth: number,
|
||||
fromDay: number,
|
||||
toMonth: number,
|
||||
toDay: number,
|
||||
year = 2026
|
||||
): ReportDateRange => ({
|
||||
from: new Date(year, fromMonth, fromDay),
|
||||
to: new Date(year, toMonth, toDay),
|
||||
})
|
||||
|
||||
const preset = (
|
||||
id: string,
|
||||
label: string,
|
||||
period: PeriodKey,
|
||||
range: ReportDateRange
|
||||
): DateRangePreset => ({ id, label, period, range })
|
||||
|
||||
const LAST_30_RANGE = reportRange(4, 12, 5, 10)
|
||||
const PREVIOUS_30_RANGE = reportRange(3, 12, 4, 11)
|
||||
|
||||
const REPORT_RANGE_PRESETS: DateRangePreset[] = [
|
||||
preset("today", "Today", "last30", reportRange(5, 10, 5, 10)),
|
||||
preset("yesterday", "Yesterday", "last30", reportRange(5, 9, 5, 9)),
|
||||
preset("last7", "Last 7 days", "last30", reportRange(5, 4, 5, 10)),
|
||||
preset("last30", "Last 30 days", "last30", LAST_30_RANGE),
|
||||
preset("monthToDate", "Month to date", "last30", reportRange(5, 1, 5, 10)),
|
||||
preset("lastMonth", "Last month", "last30", reportRange(4, 1, 4, 31)),
|
||||
preset("yearToDate", "Year to date", "last30", reportRange(0, 1, 5, 10)),
|
||||
preset("lastYear", "Last year", "prev30", reportRange(0, 1, 11, 31, 2025)),
|
||||
]
|
||||
|
||||
const MAX_REPORT_DATE = LAST_30_RANGE.to
|
||||
|
||||
function isSameRange(first: ReportDateRange, second: DateRange) {
|
||||
const secondFrom = second.from
|
||||
const secondTo = second.to ?? second.from
|
||||
|
||||
return (
|
||||
Boolean(secondFrom && secondTo) &&
|
||||
first.from.getTime() === secondFrom?.getTime() &&
|
||||
first.to.getTime() === secondTo?.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRange(
|
||||
range: DateRange | undefined,
|
||||
fallback: ReportDateRange
|
||||
): ReportDateRange {
|
||||
if (!range?.from) return fallback
|
||||
|
||||
const from = range.from
|
||||
const to = range.to ?? range.from
|
||||
|
||||
return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from }
|
||||
}
|
||||
|
||||
function formatReportRange(range: ReportDateRange) {
|
||||
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`
|
||||
}
|
||||
|
||||
function getPeriodForRange(range: ReportDateRange) {
|
||||
const matchingPreset = getMatchingPreset(range)
|
||||
|
||||
if (matchingPreset) return matchingPreset.period
|
||||
return range.to.getTime() <= PREVIOUS_30_RANGE.to.getTime()
|
||||
? "prev30"
|
||||
: "last30"
|
||||
}
|
||||
|
||||
function getMatchingPreset(range: DateRange | undefined) {
|
||||
if (!range?.from || !range.to) return undefined
|
||||
const normalizedRange = normalizeRange(range, LAST_30_RANGE)
|
||||
|
||||
return REPORT_RANGE_PRESETS.find((preset) =>
|
||||
isSameRange(preset.range, normalizedRange)
|
||||
)
|
||||
}
|
||||
|
||||
function ReportDateRangePicker({
|
||||
period,
|
||||
onPeriodChange,
|
||||
}: {
|
||||
period: PeriodKey
|
||||
onPeriodChange: (value: PeriodKey) => void
|
||||
}) {
|
||||
const initialRange = period === "prev30" ? PREVIOUS_30_RANGE : LAST_30_RANGE
|
||||
const [open, setOpen] = useState(false)
|
||||
const [committedRange, setCommittedRange] =
|
||||
useState<ReportDateRange>(initialRange)
|
||||
const [draftRange, setDraftRange] = useState<DateRange | undefined>(
|
||||
initialRange
|
||||
)
|
||||
|
||||
const selectedPresetId = getMatchingPreset(draftRange ?? committedRange)?.id
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen) {
|
||||
setDraftRange(committedRange)
|
||||
}
|
||||
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const nextRange = normalizeRange(draftRange, committedRange)
|
||||
|
||||
setCommittedRange(nextRange)
|
||||
onPeriodChange(getPeriodForRange(nextRange))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="group/pick-date w-[250px] max-w-full justify-between leading-none font-normal tabular-nums"
|
||||
>
|
||||
<span className="truncate">
|
||||
{formatReportRange(committedRange)}
|
||||
</span>
|
||||
<CalendarIcon className="text-muted-foreground/80 group-hover/pick-date:text-foreground shrink-0 transition-colors" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-auto p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col sm:grid sm:grid-cols-[10rem_1fr]">
|
||||
<div className="border-border flex flex-wrap gap-1 border-b p-2 sm:flex-col sm:border-r sm:border-b-0">
|
||||
{REPORT_RANGE_PRESETS.map((preset) => {
|
||||
const selected = selectedPresetId === preset.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "secondary" : "ghost"}
|
||||
className={
|
||||
selected
|
||||
? "justify-start"
|
||||
: "text-muted-foreground justify-start"
|
||||
}
|
||||
onClick={() => setDraftRange(preset.range)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={draftRange}
|
||||
onSelect={setDraftRange}
|
||||
numberOfMonths={2}
|
||||
defaultMonth={draftRange?.from ?? committedRange.from}
|
||||
disabled={{
|
||||
after: MAX_REPORT_DATE,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border flex items-center justify-between gap-2 border-t px-3 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDraftRange(LAST_30_RANGE)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDraftRange(committedRange)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleApply}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavbarActions() {
|
||||
const [periodKey, setPeriodKey] = useState<PeriodKey>("last30")
|
||||
|
||||
function handleExport() {
|
||||
toast.success("Export queued", {
|
||||
description: "Security telemetry report is being prepared.",
|
||||
icon: (
|
||||
<ShieldCheckIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ReportDateRangePicker period={periodKey} onPeriodChange={setPeriodKey} />
|
||||
|
||||
<Button size="sm" type="button" onClick={handleExport}>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span className="hidden sm:block">Export</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@evobgp/ui/components/breadcrumb"
|
||||
|
||||
export function NavbarBreadcrumb() {
|
||||
return (
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Security</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">Telemetry</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NavbarActions } from "./navbar-actions"
|
||||
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<header
|
||||
className="flex min-h-9 w-full shrink-0 items-center justify-between gap-2 pb-1"
|
||||
aria-label="Security telemetry header"
|
||||
>
|
||||
<NavbarBreadcrumb />
|
||||
|
||||
<NavbarActions />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@evobgp/ui/components/chart"
|
||||
|
||||
import { networkFlow, networkFlowSummary, threatVectors } from "./data"
|
||||
import { PanelCorners, PanelHeading } from "./panel-heading"
|
||||
|
||||
const threatChartConfig = {
|
||||
blocked: {
|
||||
label: "Blocked",
|
||||
color: "var(--color-blue-600)",
|
||||
},
|
||||
watched: {
|
||||
label: "Watched",
|
||||
color: "var(--color-sky-300)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
const flowChartConfig = {
|
||||
api: {
|
||||
label: "API Calls",
|
||||
color: "var(--color-yellow-500)",
|
||||
},
|
||||
webhook: {
|
||||
label: "Webhooks",
|
||||
color: "var(--color-emerald-500)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
const chartGridProps = {
|
||||
vertical: false,
|
||||
stroke: "var(--border)",
|
||||
strokeDasharray: "3 3",
|
||||
strokeOpacity: 0.75,
|
||||
}
|
||||
|
||||
function getChartColor(config: ChartConfig, name: string | number) {
|
||||
return config[String(name)]?.color
|
||||
}
|
||||
|
||||
function getChartLabel(config: ChartConfig, name: string | number) {
|
||||
return config[String(name)]?.label ?? String(name)
|
||||
}
|
||||
|
||||
function formatTooltipItem(
|
||||
config: ChartConfig,
|
||||
value: unknown,
|
||||
name: string | number
|
||||
) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: getChartColor(config, name) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{getChartLabel(config, name)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{Number(value ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CrosshatchPattern({
|
||||
config,
|
||||
idPrefix,
|
||||
}: {
|
||||
config: ChartConfig
|
||||
idPrefix: string
|
||||
}) {
|
||||
const entries = Object.entries(config).filter(([, value]) => value.color)
|
||||
|
||||
return (
|
||||
<>
|
||||
{entries.map(([key, { color }]) => (
|
||||
<pattern
|
||||
key={key}
|
||||
id={`${idPrefix}-${key}`}
|
||||
x="0"
|
||||
y="0"
|
||||
width="8"
|
||||
height="8"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M0,8 L8,0" stroke={color} strokeWidth="0.8" opacity="0.4" />
|
||||
<path d="M0,0 L8,8" stroke={color} strokeWidth="0.8" opacity="0.2" />
|
||||
</pattern>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThreatVectorsPanel() {
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<PanelCorners />
|
||||
<CardContent className="flex flex-col gap-5 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<PanelHeading title="Threat Vectors" description="Blocked Signals" />
|
||||
<div className="text-muted-foreground flex shrink-0 flex-wrap items-center gap-4 text-xs sm:justify-end">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-blue-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Blocked
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-sky-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Watched
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChartContainer
|
||||
config={threatChartConfig}
|
||||
className="h-56 w-full min-w-0"
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={threatVectors}
|
||||
margin={{ top: 8, right: 4, bottom: 0, left: 4 }}
|
||||
barGap={3}
|
||||
barCategoryGap={8}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="dashboard5-threat-blocked"
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="8"
|
||||
height="8"
|
||||
>
|
||||
<rect
|
||||
width="8"
|
||||
height="8"
|
||||
fill="var(--color-blocked)"
|
||||
opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M0,8 L8,0 M4,12 L12,4 M-4,4 L4,-4"
|
||||
stroke="var(--color-blocked)"
|
||||
strokeWidth="1.5"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<path
|
||||
d="M2,10 L10,2 M6,14 L14,6 M-2,6 L6,-2"
|
||||
stroke="var(--color-blocked)"
|
||||
strokeWidth="1"
|
||||
opacity="0.25"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<CartesianGrid {...chartGridProps} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis hide domain={[0, 78]} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="dot"
|
||||
className="min-w-36 gap-2"
|
||||
labelFormatter={(value) => (
|
||||
<div className="border-border/50 mb-0.5 border-b pb-2">
|
||||
<span className="text-xs font-medium">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
formatter={(value, name) =>
|
||||
formatTooltipItem(threatChartConfig, value, name)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="blocked"
|
||||
fill="url(#dashboard5-threat-blocked)"
|
||||
stroke="var(--color-blocked)"
|
||||
strokeWidth={1}
|
||||
radius={[5, 5, 5, 5]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="watched"
|
||||
fill="var(--color-watched)"
|
||||
fillOpacity={0.86}
|
||||
stroke="var(--color-watched)"
|
||||
strokeWidth={1}
|
||||
radius={[5, 5, 5, 5]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function NetworkFlowPanel() {
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<PanelCorners />
|
||||
<CardContent className="flex flex-col gap-5 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<PanelHeading title="Network Flow" description="API and webhooks" />
|
||||
<Badge variant="success-light" radius="full">
|
||||
{networkFlowSummary.change}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<ChartContainer
|
||||
config={flowChartConfig}
|
||||
className="h-56 w-full min-w-0"
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={networkFlow}
|
||||
margin={{ top: 20, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<CartesianGrid {...chartGridProps} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => String(value).slice(0, 3)}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="dot"
|
||||
className="min-w-40 gap-2.5"
|
||||
labelFormatter={(value) => (
|
||||
<div className="border-border/50 mb-0.5 border-b pb-2">
|
||||
<span className="text-xs font-medium">
|
||||
{value} {networkFlowSummary.year}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
formatter={(value, name) =>
|
||||
formatTooltipItem(flowChartConfig, value, name)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<defs>
|
||||
<CrosshatchPattern
|
||||
config={flowChartConfig}
|
||||
idPrefix="dashboard5-flow-crosshatch"
|
||||
/>
|
||||
</defs>
|
||||
<Area
|
||||
dataKey="webhook"
|
||||
type="natural"
|
||||
fill="url(#dashboard5-flow-crosshatch-webhook)"
|
||||
fillOpacity={0.5}
|
||||
stroke="var(--color-webhook)"
|
||||
stackId="a"
|
||||
strokeWidth={1.25}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
dataKey="api"
|
||||
type="natural"
|
||||
fill="url(#dashboard5-flow-crosshatch-api)"
|
||||
fillOpacity={0.5}
|
||||
stroke="var(--color-api)"
|
||||
stackId="a"
|
||||
strokeWidth={1.25}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function PanelCorners() {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="border-foreground/65 absolute top-0 left-0 size-2 border-t border-l"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="border-foreground/65 absolute right-0 bottom-0 size-2 border-r border-b"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PanelHeading({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-sm leading-4 font-semibold">{title}</h2>
|
||||
<p className="text-muted-foreground text-xs leading-4">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { metricCards } from "./data"
|
||||
import { ActiveThreatsPanel, LoadPanel } from "./load-panels"
|
||||
import { MetricTile } from "./metric-tile"
|
||||
import { Navbar } from "./navbar"
|
||||
import { NetworkFlowPanel, ThreatVectorsPanel } from "./network-panels"
|
||||
|
||||
/**
|
||||
* Dense edge security dashboard inspired by a telemetry wall.
|
||||
* The main entry only owns section order; records and panel details stay
|
||||
* in focused local files so the block remains easy to adapt.
|
||||
*/
|
||||
export function SecurityDashboard() {
|
||||
return (
|
||||
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Edge Security Telemetry
|
||||
</h1>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<section
|
||||
aria-label="Security Summary"
|
||||
className="grid grid-cols-1 gap-4 @3xl:grid-cols-2 @6xl:grid-cols-4"
|
||||
>
|
||||
{metricCards.map((metric) => (
|
||||
<MetricTile key={metric.id} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Security Flow"
|
||||
className="grid grid-cols-1 gap-4 @5xl:grid-cols-2"
|
||||
>
|
||||
<ThreatVectorsPanel />
|
||||
<NetworkFlowPanel />
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Security Load"
|
||||
className="grid grid-cols-1 gap-4 @5xl:grid-cols-[minmax(0,2fr)_minmax(300px,1fr)]"
|
||||
>
|
||||
<LoadPanel />
|
||||
<ActiveThreatsPanel />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type LoadState, type MetricTone } from "./data"
|
||||
|
||||
export const toneStyles: Record<
|
||||
MetricTone,
|
||||
{
|
||||
dot: string
|
||||
stroke: string
|
||||
bar: string
|
||||
}
|
||||
> = {
|
||||
danger: {
|
||||
dot: "bg-red-500",
|
||||
stroke: "var(--color-red-500)",
|
||||
bar: "bg-red-500",
|
||||
},
|
||||
success: {
|
||||
dot: "bg-emerald-500",
|
||||
stroke: "var(--color-emerald-500)",
|
||||
bar: "bg-emerald-500",
|
||||
},
|
||||
warning: {
|
||||
dot: "bg-amber-500",
|
||||
stroke: "var(--color-amber-500)",
|
||||
bar: "bg-amber-500",
|
||||
},
|
||||
info: {
|
||||
dot: "bg-blue-500",
|
||||
stroke: "var(--color-blue-500)",
|
||||
bar: "bg-blue-500",
|
||||
},
|
||||
}
|
||||
|
||||
export const loadStateColor: Record<LoadState, string> = {
|
||||
nominal: "var(--color-zinc-300)",
|
||||
warm: "var(--color-amber-500)",
|
||||
critical: "var(--color-red-500)",
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SecurityDashboard } from "./components/security-dashboard"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="bg-background min-h-svh w-full p-3 sm:p-4 lg:p-6"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<SecurityDashboard />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Headphones, CircleCheckIcon, SmileIcon } from "lucide-react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface CardData {
|
||||
icon: ReactNode
|
||||
iconBg: string
|
||||
value: string | number
|
||||
label: string
|
||||
info: ReactNode
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const cards: CardData[] = [
|
||||
{
|
||||
icon: (
|
||||
<Headphones aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-blue-600 dark:text-blue-400",
|
||||
value: 320,
|
||||
label: "Support Tickets",
|
||||
info: <Badge variant="info-light">12 Open, 308 Closed</Badge>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<CircleCheckIcon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-emerald-600 dark:text-emerald-400",
|
||||
value: "98%",
|
||||
label: "Resolved",
|
||||
info: <Badge variant="success-light">+2.1% this month</Badge>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<SmileIcon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-amber-600 dark:text-amber-400",
|
||||
value: "4.8",
|
||||
label: "Satisfaction Rate",
|
||||
info: <Badge variant="warning-light">Avg. (out of 5)</Badge>,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
|
||||
import { cards } from "./data"
|
||||
|
||||
export function Stats() {
|
||||
return (
|
||||
<div className="@container w-full grow">
|
||||
{/* Grid */}
|
||||
<div className="mx-auto grid max-w-5xl grow grid-cols-1 gap-5 @3xl:grid-cols-3">
|
||||
{cards.map((card, i) => (
|
||||
<Frame key={i}>
|
||||
<FramePanel className="flex flex-col items-start gap-6">
|
||||
<Item
|
||||
className={cn(
|
||||
"border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4",
|
||||
card.iconBg
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-2xl leading-none font-bold">
|
||||
{card.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{card.info}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Stats } from "./components/stats"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-10 md:p-20">
|
||||
<Stats />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
export type ActivityDotBadge = {
|
||||
label: string
|
||||
dotClass: string
|
||||
}
|
||||
|
||||
export type ActivityLightBadge = {
|
||||
label: string
|
||||
variant:
|
||||
| "primary-light"
|
||||
| "success-light"
|
||||
| "warning-light"
|
||||
| "info-light"
|
||||
| "destructive-light"
|
||||
}
|
||||
|
||||
export type FinanceActivity = {
|
||||
id: number
|
||||
user: string
|
||||
avatar: string
|
||||
action: string
|
||||
target: string
|
||||
detail: string
|
||||
badges: [ActivityDotBadge] | [ActivityDotBadge, ActivityLightBadge]
|
||||
attachment?: {
|
||||
name: string
|
||||
size: string
|
||||
}
|
||||
participants?: {
|
||||
src: string
|
||||
fallback: string
|
||||
}[]
|
||||
participantCount?: number
|
||||
actions?: {
|
||||
label: string
|
||||
variant?: "default" | "outline"
|
||||
}[]
|
||||
date: string
|
||||
dateTime: string
|
||||
}
|
||||
|
||||
export const financeActivities: FinanceActivity[] = [
|
||||
{
|
||||
id: 1,
|
||||
user: "Nadia Flores",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
action: "approved payout batch",
|
||||
target: "ACH-4182",
|
||||
detail: "$142,800 routed to 38 merchant accounts",
|
||||
badges: [
|
||||
{ label: "Payout", dotClass: "bg-emerald-500" },
|
||||
{ label: "Same Day", variant: "success-light" },
|
||||
],
|
||||
date: "5 minutes ago",
|
||||
dateTime: "2026-05-06T09:45:00+05:00",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user: "Theo Ramsey",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
action: "flagged review on",
|
||||
target: "Transfer TX-9041",
|
||||
detail: "Velocity threshold exceeded for new payee",
|
||||
badges: [
|
||||
{ label: "Risk", dotClass: "bg-amber-500" },
|
||||
{ label: "High", variant: "warning-light" },
|
||||
],
|
||||
actions: [{ label: "Review" }, { label: "Clear", variant: "outline" }],
|
||||
date: "18 minutes ago",
|
||||
dateTime: "2026-05-06T09:32:00+05:00",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
user: "Iris Chen",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=96&h=96&dpr=2&q=80",
|
||||
action: "reconciled ledger entry",
|
||||
target: "LDG-7749",
|
||||
detail: "Subscription invoice matched to bank settlement",
|
||||
badges: [{ label: "Ledger", dotClass: "bg-sky-500" }],
|
||||
attachment: {
|
||||
name: "settlement-match.csv",
|
||||
size: "48kb",
|
||||
},
|
||||
date: "42 minutes ago",
|
||||
dateTime: "2026-05-06T09:08:00+05:00",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
user: "Marcus Bell",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
||||
action: "raised limit for",
|
||||
target: "Northstar Workspace",
|
||||
detail: "Monthly card volume increased to $850K",
|
||||
badges: [{ label: "Limit Raised", dotClass: "bg-violet-500" }],
|
||||
participants: [
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
||||
fallback: "SC",
|
||||
},
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
|
||||
fallback: "MR",
|
||||
},
|
||||
],
|
||||
participantCount: 2,
|
||||
date: "1 hour ago",
|
||||
dateTime: "2026-05-06T08:50:00+05:00",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
user: "Amara Okafor",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544723795-3fb6469f5b39?w=96&h=96&dpr=2&q=80",
|
||||
action: "sent invoice reminder",
|
||||
target: "INV-2098",
|
||||
detail: "Net 7 renewal balance due tomorrow",
|
||||
badges: [
|
||||
{ label: "Invoice", dotClass: "bg-orange-500" },
|
||||
{ label: "Due Tomorrow", variant: "destructive-light" },
|
||||
],
|
||||
attachment: {
|
||||
name: "invoice-2098.pdf",
|
||||
size: "76kb",
|
||||
},
|
||||
date: "2 hours ago",
|
||||
dateTime: "2026-05-06T07:50:00+05:00",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
} from "@/components/reui/timeline"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { ButtonGroup } from "@evobgp/ui/components/button-group"
|
||||
import {
|
||||
financeActivities,
|
||||
type ActivityDotBadge,
|
||||
type FinanceActivity,
|
||||
} from "./data"
|
||||
import { PaperclipIcon, DownloadIcon } from "lucide-react"
|
||||
|
||||
function getInitials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
}
|
||||
|
||||
function ActivityTarget({ target }: { target: string }) {
|
||||
const idMatch = target.match(/^(.*?)([A-Z]+-\d+)$/)
|
||||
|
||||
if (!idMatch) {
|
||||
return <span className="font-medium">{target}</span>
|
||||
}
|
||||
|
||||
const [, prefix, id] = idMatch
|
||||
|
||||
return (
|
||||
<>
|
||||
{prefix}
|
||||
<a
|
||||
href="#"
|
||||
className="text-foreground hover:text-primary font-medium underline underline-offset-2"
|
||||
>
|
||||
{id}
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityAttachment({
|
||||
attachment,
|
||||
}: {
|
||||
attachment: NonNullable<FinanceActivity["attachment"]>
|
||||
}) {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button type="button" variant="outline" size="xs">
|
||||
<PaperclipIcon aria-hidden="true" />
|
||||
<span className="truncate">{attachment.name}</span>
|
||||
<span className="opacity-60">({attachment.size})</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
aria-label={`Download ${attachment.name}`}
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ParticipantGroup({
|
||||
participants,
|
||||
count,
|
||||
}: {
|
||||
participants: NonNullable<FinanceActivity["participants"]>
|
||||
count?: number
|
||||
}) {
|
||||
return (
|
||||
<AvatarGroup className="-space-x-1">
|
||||
{participants.map((participant) => (
|
||||
<Avatar key={participant.src} className="size-5">
|
||||
<AvatarImage src={participant.src} alt={participant.fallback} />
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{participant.fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
{count && count > 0 ? (
|
||||
<AvatarGroupCount className="size-5 text-[9px] leading-none">
|
||||
+{count}
|
||||
</AvatarGroupCount>
|
||||
) : null}
|
||||
</AvatarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function DotBadge({ badge }: { badge: ActivityDotBadge }) {
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
<span
|
||||
className={cn("size-1.5 shrink-0 rounded-full", badge.dotClass)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityStatusRow({ activity }: { activity: FinanceActivity }) {
|
||||
const [primaryBadge, secondaryBadge] = activity.badges
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<TimelineDate dateTime={activity.dateTime} className="mt-0 mb-0">
|
||||
{activity.date}
|
||||
</TimelineDate>
|
||||
<DotBadge badge={primaryBadge} />
|
||||
{secondaryBadge ? (
|
||||
<Badge variant={secondaryBadge.variant}>{secondaryBadge.label}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FinanceActivityTimeline() {
|
||||
return (
|
||||
<section
|
||||
className="w-full max-w-md"
|
||||
aria-labelledby="finance-activity-title"
|
||||
>
|
||||
<div className="mb-5 space-y-0.5">
|
||||
<h1 id="finance-activity-title" className="text-base font-semibold">
|
||||
Finance Activity
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-5">
|
||||
Payouts, risk, invoices, and ledger updates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Timeline defaultValue={financeActivities.length}>
|
||||
{financeActivities.map((activity) => (
|
||||
<TimelineItem
|
||||
key={activity.id}
|
||||
step={activity.id}
|
||||
className="group-data-[orientation=vertical]/timeline:ms-10 group-data-[orientation=vertical]/timeline:not-last:pb-4"
|
||||
>
|
||||
<TimelineHeader>
|
||||
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:top-2 group-data-[orientation=vertical]/timeline:-left-8 group-data-[orientation=vertical]/timeline:h-[calc(100%-2.25rem)] group-data-[orientation=vertical]/timeline:translate-y-6" />
|
||||
<TimelineIndicator className="size-7 overflow-hidden rounded-full border-none group-data-[orientation=vertical]/timeline:-left-8">
|
||||
<Avatar className="size-7">
|
||||
<AvatarImage src={activity.avatar} alt={activity.user} />
|
||||
<AvatarFallback className="text-[10px] font-medium">
|
||||
{getInitials(activity.user)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="min-w-0 pb-1">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm leading-5">
|
||||
<span className="text-foreground hover:text-primary font-medium">
|
||||
{activity.user}
|
||||
</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{activity.action}
|
||||
</span>{" "}
|
||||
<ActivityTarget target={activity.target} />
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-4">
|
||||
{activity.detail}
|
||||
</p>
|
||||
{(activity.attachment ||
|
||||
activity.actions?.length ||
|
||||
activity.participants?.length) && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{activity.attachment ? (
|
||||
<ActivityAttachment attachment={activity.attachment} />
|
||||
) : null}
|
||||
{activity.participants?.length ? (
|
||||
<ParticipantGroup
|
||||
participants={activity.participants}
|
||||
count={activity.participantCount}
|
||||
/>
|
||||
) : null}
|
||||
{activity.actions?.map((action) => (
|
||||
<Button
|
||||
key={action.label}
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={
|
||||
action.variant === "outline" ? "outline" : "default"
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ActivityStatusRow activity={activity} />
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { FinanceActivityTimeline } from "./components/finance-activity-timeline"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-6 sm:p-8 md:p-12">
|
||||
<FinanceActivityTimeline />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
|
||||
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { recentPlatformActivity } from '@/lib/metrics'
|
||||
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
const KIND_META = {
|
||||
job: { icon: Info, className: 'text-info' },
|
||||
revision: { icon: CheckCircle, className: 'text-success' },
|
||||
network: { icon: AlertTriangle, className: 'text-warning' },
|
||||
} as const
|
||||
|
||||
function statusBadgeVariant(status: string) {
|
||||
const s = status.toLowerCase()
|
||||
if (['ok', 'success', 'completed', 'done'].includes(s)) return 'success-light' as const
|
||||
if (['running', 'queued', 'pending'].includes(s)) return 'info-light' as const
|
||||
if (['warning', 'mismatch'].includes(s)) return 'warning-light' as const
|
||||
if (['failed', 'error', 'cancelled'].includes(s)) return 'destructive-light' as const
|
||||
return 'outline' as const
|
||||
}
|
||||
|
||||
export function DashboardActivityTimeline({
|
||||
jobs,
|
||||
revisions,
|
||||
peers,
|
||||
speakers,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
revisions: RevisionRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const items = recentPlatformActivity(jobs, revisions, peers, speakers, 6)
|
||||
|
||||
return (
|
||||
<DashboardFramePanel
|
||||
title="Недавняя активность"
|
||||
description="Задачи, ревизии и сетевые события"
|
||||
className="h-full"
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-sm">Нет недавних событий</p>
|
||||
) : (
|
||||
<div className="px-4 py-4">
|
||||
<Timeline defaultValue={items.length}>
|
||||
{items.map((item, index) => {
|
||||
const meta = KIND_META[item.kind]
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<TimelineItem
|
||||
key={item.id}
|
||||
step={index + 1}
|
||||
className="group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-4"
|
||||
>
|
||||
<TimelineHeader>
|
||||
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:top-2 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem)] group-data-[orientation=vertical]/timeline:translate-y-5" />
|
||||
<TimelineIndicator className="border-none bg-transparent group-data-[orientation=vertical]/timeline:-left-6">
|
||||
<span
|
||||
className={cn(
|
||||
'bg-muted/70 flex size-7 items-center justify-center rounded-full',
|
||||
meta.className,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
</span>
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="min-w-0 pb-1 text-foreground">
|
||||
<TimelineTitle className="text-sm leading-snug font-normal">
|
||||
{item.message}
|
||||
</TimelineTitle>
|
||||
<div className="mt-2">
|
||||
<Badge variant={statusBadgeVariant(item.status)} size="sm">
|
||||
{item.statusLabel ?? item.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
})}
|
||||
</Timeline>
|
||||
</div>
|
||||
)}
|
||||
</DashboardFramePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export function DashboardFramePanel({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: {
|
||||
title?: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
}) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
|
||||
return (
|
||||
<Frame stacked dense className={cn('h-full w-full', className)}>
|
||||
{hasHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3 border-b border-(--frame-panel-border-color)">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
<FramePanel className={cn('flex flex-col p-0!', contentClassName)}>{children}</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
ListChecks,
|
||||
Network,
|
||||
ServerCog,
|
||||
Share2,
|
||||
} from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
|
||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type KpiCard = {
|
||||
icon: ReactNode
|
||||
iconClass: string
|
||||
value: string
|
||||
label: string
|
||||
badge: ReactNode
|
||||
}
|
||||
|
||||
function buildKpis({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}): KpiCard[] {
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const network = aggregateNetworkMetrics(peers, speakers)
|
||||
const peersEnabled = network.peersEnabled
|
||||
const bgpPct =
|
||||
peersEnabled > 0 ? Math.round((network.peersEstablished / peersEnabled) * 100) : null
|
||||
const running = runningJobCount(jobs)
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
||||
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
||||
|
||||
return [
|
||||
{
|
||||
icon: <Boxes aria-hidden />,
|
||||
iconClass: 'text-primary',
|
||||
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
||||
label: 'Модули активны',
|
||||
badge: (
|
||||
<Badge variant="primary-light" size="sm">
|
||||
{loading ? '…' : `${modules.length} всего`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Network aria-hidden />,
|
||||
iconClass: 'text-info',
|
||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||
label: 'BGP готовность',
|
||||
badge: (
|
||||
<Badge
|
||||
variant={
|
||||
bgpPct !== null && bgpPct >= 90
|
||||
? 'success-light'
|
||||
: bgpPct !== null && bgpPct < 70
|
||||
? 'warning-light'
|
||||
: 'outline'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading || bgpPct === null
|
||||
? 'нет включённых пиров'
|
||||
: `${network.peersEstablished} установлено`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClass: 'text-success',
|
||||
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
||||
label: 'Пиры Established',
|
||||
badge: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <ServerCog aria-hidden />,
|
||||
iconClass: 'text-warning',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
label: 'Спикеры online',
|
||||
badge: (
|
||||
<Badge
|
||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'live-снимок'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <ListChecks aria-hidden />,
|
||||
iconClass: 'text-focus',
|
||||
value: loading ? '—' : String(running),
|
||||
label: 'Активные задачи',
|
||||
badge: (
|
||||
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||
{loading ? '…' : `${jobs.length} в выборке`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <AlertTriangle aria-hidden />,
|
||||
iconClass: 'text-destructive',
|
||||
value: loading ? '—' : String(riskCount),
|
||||
label: 'Риски',
|
||||
badge: (
|
||||
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||
{loading
|
||||
? '…'
|
||||
: riskCount > 0
|
||||
? `${failedJobs} задач · ${network.peersMismatch} расхождений`
|
||||
: 'в норме'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function DashboardKpiGrid({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const cards = buildKpis({ modules, peers, speakers, jobs, loading })
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{cards.map((card) => (
|
||||
<Frame key={card.label}>
|
||||
<FramePanel className="flex flex-col items-start gap-4">
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
card.iconClass,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">
|
||||
{card.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm font-medium">{card.label}</div>
|
||||
</div>
|
||||
{card.badge}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
ArrowUpDownIcon,
|
||||
BoxesIcon,
|
||||
ChevronDownIcon,
|
||||
FilterIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import {
|
||||
DataGridTable,
|
||||
DataGridTableHeader,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evobgp/ui/components/dropdown-menu'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||
import type { ModuleRow } from '@/types/api'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
type ModuleSort = 'name' | 'type' | 'priority' | 'last_refreshed_at'
|
||||
type EnabledFilter = 'all' | 'enabled' | 'disabled'
|
||||
|
||||
const sortLabels: Record<ModuleSort, string> = {
|
||||
name: 'Название',
|
||||
type: 'Тип',
|
||||
priority: 'Приоритет',
|
||||
last_refreshed_at: 'Обновлено',
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
|
||||
|
||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
||||
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }]
|
||||
}
|
||||
|
||||
export function DashboardModulesGrid({
|
||||
modules,
|
||||
isLoading = false,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>('all')
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [sortBy, setSortBy] = useState<ModuleSort>('name')
|
||||
const [sorting, setSorting] = useState<SortingState>(() => buildSorting('name'))
|
||||
|
||||
const filteredModules = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
return modules.filter((module) => {
|
||||
const matchesSearch =
|
||||
q.length === 0 ||
|
||||
`${module.name} ${module.type} ${moduleTypeRu(module.type)}`.toLowerCase().includes(q)
|
||||
const matchesEnabled =
|
||||
enabledFilter === 'all' ||
|
||||
(enabledFilter === 'enabled' ? module.enabled !== false : module.enabled === false)
|
||||
return matchesSearch && matchesEnabled
|
||||
})
|
||||
}, [modules, searchQuery, enabledFilter])
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) => ({ ...current, pageIndex: 0 }))
|
||||
}, [])
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(value: ModuleSort) => {
|
||||
setSortBy(value)
|
||||
setSorting(buildSorting(value))
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<ModuleRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
id: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
|
||||
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" />
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
id: 'type',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Тип' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
id: 'priority',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
|
||||
),
|
||||
meta: { headerTitle: 'Приоритет' },
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
accessorFn: (row) => (row.enabled !== false ? 'enabled' : 'disabled'),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => <ModeBadge enabled={row.original.enabled !== false} />,
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
accessorFn: (row) => row.last_refreshed_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.last_refreshed_at
|
||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
sortingFn: (a, b) =>
|
||||
(a.original.last_refreshed_at ?? '').localeCompare(b.original.last_refreshed_at ?? ''),
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredModules,
|
||||
columns,
|
||||
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
|
||||
state: { pagination, sorting },
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getRowId: (row) => row.id,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
const activeFilterCount = enabledFilter === 'all' ? 0 : 1
|
||||
|
||||
return (
|
||||
<DashboardFramePanel
|
||||
title="Модули"
|
||||
description="Поиск, сортировка и быстрый переход к настройке"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
||||
<PlusIcon />
|
||||
Создать
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredModules.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={EMPTY_MESSAGE}
|
||||
tableLayout={{
|
||||
dense: true,
|
||||
rowBorder: true,
|
||||
headerSticky: false,
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
width: 'fixed',
|
||||
}}
|
||||
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
|
||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<InputGroup className="w-full min-w-40 sm:max-w-xs">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchQuery}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value)
|
||||
resetPagination()
|
||||
}}
|
||||
placeholder="Поиск модулей…"
|
||||
aria-label="Поиск модулей"
|
||||
/>
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label="Очистить поиск"
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<ArrowUpDownIcon data-icon="inline-start" aria-hidden />
|
||||
{sortLabels[sortBy]}
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
<DropdownMenuGroup>
|
||||
{(Object.keys(sortLabels) as ModuleSort[]).map((value) => (
|
||||
<DropdownMenuItem key={value} onClick={() => handleSortChange(value)}>
|
||||
{sortLabels[value]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<FilterIcon data-icon="inline-start" aria-hidden />
|
||||
Фильтры
|
||||
{activeFilterCount > 0 ? (
|
||||
<Badge variant="outline" radius="full">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Состояние</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={enabledFilter === 'enabled'}
|
||||
closeOnClick={false}
|
||||
onCheckedChange={(checked) => {
|
||||
setEnabledFilter(checked ? 'enabled' : 'all')
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
Только включённые
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={enabledFilter === 'disabled'}
|
||||
closeOnClick={false}
|
||||
onCheckedChange={(checked) => {
|
||||
setEnabledFilter(checked ? 'disabled' : 'all')
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
Только выключенные
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuGroup>
|
||||
{activeFilterCount > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
closeOnClick={false}
|
||||
onClick={() => {
|
||||
setEnabledFilter('all')
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
Сбросить фильтры
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
) : (
|
||||
<>
|
||||
<DataGridScrollArea>
|
||||
<DataGridTableHeader />
|
||||
</DataGridScrollArea>
|
||||
<div className="text-muted-foreground flex min-h-40 items-center justify-center px-4 text-center text-sm">
|
||||
{EMPTY_MESSAGE}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t px-4 py-3">
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridPagination
|
||||
sizes={[10, 15, 20]}
|
||||
info="{from}–{to} из {count}"
|
||||
className="py-0"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center text-sm">0 модулей</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DataGrid>
|
||||
</DashboardFramePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { ChartBarStrip } from '@/components/analytics/chart-bar-strip'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
capacityUtilization,
|
||||
peerCapacityBars,
|
||||
speakerCapacityBars,
|
||||
} from '@/lib/metrics'
|
||||
import { runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type CapacityMode = 'peers' | 'speakers'
|
||||
|
||||
export function DashboardNetworkHealth({
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<CapacityMode>('peers')
|
||||
|
||||
const bars = useMemo(
|
||||
() => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)),
|
||||
[mode, peers, speakers],
|
||||
)
|
||||
const utilization = capacityUtilization(bars)
|
||||
const queued = runningJobCount(jobs)
|
||||
|
||||
const established =
|
||||
mode === 'peers'
|
||||
? peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length
|
||||
: speakers.filter((s) => s.live?.agent_ok).length
|
||||
const total =
|
||||
mode === 'peers' ? peers.filter((p) => p.enabled !== false).length : speakers.length
|
||||
|
||||
return (
|
||||
<DashboardFramePanel
|
||||
title="Загрузка BGP"
|
||||
description="Утилизация сессий по пирам и спикерам"
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'peers', label: 'Пиры' },
|
||||
{ value: 'speakers', label: 'Спикеры' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 px-4 py-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-3xl font-semibold tracking-tight tabular-nums">
|
||||
{loading ? '—' : `${utilization}%`}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{loading ? '…' : `${established} / ${total} ${mode === 'peers' ? 'установлено' : 'в сети'}`}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" radius="full" className="h-7 gap-1.5 px-2.5 text-xs">
|
||||
<span className="font-semibold tabular-nums">{loading ? '—' : queued}</span>
|
||||
<span>активных задач</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex h-36 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartBarStrip bars={bars} />
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground flex flex-wrap gap-3 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="bg-chart-3 size-2 rounded-full" aria-hidden />
|
||||
Ниже порога
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="bg-chart-2 size-2 rounded-full" aria-hidden />
|
||||
Установлено / online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardFramePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, ModuleRow } from '@/types/api'
|
||||
|
||||
type FlowMode = 'jobs' | 'modules'
|
||||
|
||||
export function DashboardOperationsBreakdown({
|
||||
jobs,
|
||||
modules,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
modules: ModuleRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<FlowMode>('jobs')
|
||||
|
||||
const slices = useMemo(
|
||||
() => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)),
|
||||
[mode, jobs, modules],
|
||||
)
|
||||
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули'
|
||||
|
||||
return (
|
||||
<DashboardFramePanel
|
||||
title="Поток операций"
|
||||
description="Распределение задач и типов модулей"
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'jobs', label: 'Задачи' },
|
||||
{ value: 'modules', label: 'Модули' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="px-4 py-4">
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric
|
||||
slices={slices}
|
||||
centerLabel={centerLabel}
|
||||
centerValue={total}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DashboardFramePanel>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
export function DashboardQuickActions() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
|
||||
<Plus className="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}>
|
||||
<Tags className="size-4" />
|
||||
Добавить BGP-сообщество
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'overview' }} />}>
|
||||
<Network className="size-4" />
|
||||
Сеть
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'peers' }} />}>
|
||||
<Share2 className="size-4" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
render={<Link to="/operations" search={{ tab: 'revisions' }} />}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Деплой
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/monitoring" search={{ tab: 'system' }} />}>
|
||||
<Gauge className="size-4" />
|
||||
Мониторинг
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
type QuickLink = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
description: string
|
||||
to: string
|
||||
search?: Record<string, string>
|
||||
}
|
||||
|
||||
const LINKS: QuickLink[] = [
|
||||
{
|
||||
icon: <Plus aria-hidden />,
|
||||
label: 'Создать модуль',
|
||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||
to: '/modules/new',
|
||||
},
|
||||
{
|
||||
icon: <Tags aria-hidden />,
|
||||
label: 'BGP-сообщества',
|
||||
description: 'Справочник communities для политик экспорта.',
|
||||
to: '/directories',
|
||||
},
|
||||
{
|
||||
icon: <Network aria-hidden />,
|
||||
label: 'Сеть',
|
||||
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
||||
to: '/network',
|
||||
search: { tab: 'overview' },
|
||||
},
|
||||
{
|
||||
icon: <Share2 aria-hidden />,
|
||||
label: 'Добавить пира',
|
||||
description: 'Настройка BGP-соседа и шаблонов сессии.',
|
||||
to: '/network',
|
||||
search: { tab: 'peers' },
|
||||
},
|
||||
{
|
||||
icon: <Play aria-hidden />,
|
||||
label: 'Деплой',
|
||||
description: 'Ревизии конфигурации и применение на нодах.',
|
||||
to: '/operations',
|
||||
search: { tab: 'revisions' },
|
||||
},
|
||||
{
|
||||
icon: <Gauge aria-hidden />,
|
||||
label: 'Мониторинг',
|
||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||
to: '/monitoring',
|
||||
search: { tab: 'system' },
|
||||
},
|
||||
]
|
||||
|
||||
export function DashboardQuickLinks() {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{LINKS.map((link) => (
|
||||
<Frame key={link.label} spacing="sm">
|
||||
<FrameHeader className="px-1! py-1!">
|
||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||
{link.icon}
|
||||
<span className="text-foreground text-sm font-medium">{link.label}</span>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="space-y-3.5">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">{link.description}</p>
|
||||
<Link
|
||||
to={link.to}
|
||||
search={link.search}
|
||||
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
Перейти →
|
||||
</Link>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,7 +31,10 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
options,
|
||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||
const filterValue = column?.getFilterValue()
|
||||
const selectedValues = new Set(
|
||||
Array.isArray(filterValue) ? (filterValue as string[]) : []
|
||||
)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
@@ -53,16 +56,13 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
className="px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
<Badge variant="secondary" className="px-1 font-normal">
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
@@ -72,7 +72,7 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
className="px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
@@ -102,28 +102,39 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<div className="p-1">
|
||||
{filteredOptions.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
const facetCount = facets?.get(option.value)
|
||||
const toggleOption = () => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isSelected}
|
||||
onClick={toggleOption}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
toggleOption()
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"rounded-md relative flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
"border-primary rounded-sm flex h-4 w-4 items-center justify-center border",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
@@ -132,12 +143,12 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
|
||||
<option.icon className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
{facetCount !== undefined && (
|
||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
{facetCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -150,8 +161,16 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<div className="bg-border -mx-1 my-1 h-px" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => column?.setFilterValue(undefined)}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
column?.setFilterValue(undefined)
|
||||
}
|
||||
}}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
>
|
||||
Clear filters
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ interface DataGridColumnHeaderProps<
|
||||
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
|
||||
pinnable?: boolean
|
||||
filter?: ReactNode
|
||||
visibility?: boolean
|
||||
@@ -47,7 +48,10 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||
|
||||
const columnOrder = table.getState().columnOrder
|
||||
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
|
||||
const columnVisibilityKey =
|
||||
props.tableLayout?.columnsVisibility && visibility
|
||||
? JSON.stringify(table.getState().columnVisibility)
|
||||
: ""
|
||||
const isSorted = column.getIsSorted()
|
||||
const isPinned = column.getIsPinned()
|
||||
const canSort = column.getCanSort()
|
||||
@@ -74,18 +78,18 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
)
|
||||
|
||||
const headerButtonClassName = cn(
|
||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
|
||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg",
|
||||
className
|
||||
)
|
||||
|
||||
const sortIcon =
|
||||
canSort &&
|
||||
(isSorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3.25" />
|
||||
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
|
||||
) : isSorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.25" />
|
||||
<ArrowUpIcon className="size-3.25" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" />
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
|
||||
))
|
||||
|
||||
const hasControls =
|
||||
@@ -276,7 +280,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
|
||||
if (hasControls) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-between gap-1.5">
|
||||
<div className="-ms-2 flex h-full items-center justify-between gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
@@ -299,7 +303,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="-me-1 size-7 rounded-md"
|
||||
className="rounded-lg -me-1 size-7"
|
||||
onClick={() => column.pin(false)}
|
||||
aria-label={`Unpin ${resolvedTitle} column`}
|
||||
title={`Unpin ${resolvedTitle} column`}
|
||||
@@ -313,7 +317,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
|
||||
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<div className="-ms-2 flex h-full items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
|
||||
@@ -4,8 +4,12 @@ import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
SelectMenu,
|
||||
} from "@/components/select-field"
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
@@ -31,11 +35,8 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
|
||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesLabel: "Show",
|
||||
sizesDescription: "per page",
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
more: false,
|
||||
info: "{from} - {to} of {count}",
|
||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||
rowsPerPageLabel: "Rows per page",
|
||||
@@ -46,24 +47,24 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
|
||||
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
||||
|
||||
const btnBaseClasses = "size-7 p-0 text-sm"
|
||||
const btnBaseClasses = "p-0 text-sm"
|
||||
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
|
||||
const pageIndex = table.getState().pagination.pageIndex
|
||||
const pageSize = table.getState().pagination.pageSize
|
||||
const from = pageIndex * pageSize + 1
|
||||
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
|
||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||
const pageCount = table.getPageCount()
|
||||
|
||||
// Replace placeholders in paginationInfo
|
||||
const paginationInfo = mergedProps?.info
|
||||
const paginationInfo = mergedProps.info
|
||||
? mergedProps.info
|
||||
.replace("{from}", from.toString())
|
||||
.replace("{to}", to.toString())
|
||||
.replace("{count}", recordCount.toString())
|
||||
.replaceAll("{from}", from.toString())
|
||||
.replaceAll("{to}", to.toString())
|
||||
.replaceAll("{count}", recordCount.toString())
|
||||
: `${from} - ${to} of ${recordCount}`
|
||||
|
||||
// Pagination limit logic
|
||||
const paginationMoreLimit = mergedProps?.moreLimit || 5
|
||||
const paginationMoreLimit = mergedProps.moreLimit || 5
|
||||
|
||||
// Determine the start and end of the pagination group
|
||||
const currentGroupStart =
|
||||
@@ -137,47 +138,48 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
data-slot="data-grid-pagination"
|
||||
className={cn(
|
||||
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
|
||||
mergedProps?.className
|
||||
mergedProps.className
|
||||
)}
|
||||
>
|
||||
<div className="order-2 flex flex-wrap items-center gap-2 pb-2.5 sm:order-1 sm:pb-0">
|
||||
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.sizesSkeleton
|
||||
mergedProps.sizesSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{mergedProps.rowsPerPageLabel}
|
||||
</div>
|
||||
<SelectMenu
|
||||
items={
|
||||
mergedProps?.sizes?.map((size: number) => ({
|
||||
value: `${size}`,
|
||||
label: `${size}`,
|
||||
})) ?? []
|
||||
}
|
||||
<Select
|
||||
value={`${pageSize}`}
|
||||
triggerClassName="min-w-20 w-auto tabular-nums"
|
||||
size="sm"
|
||||
side="top"
|
||||
contentClassName="min-w-20"
|
||||
onValueChange={(value) => {
|
||||
if (!value) return
|
||||
table.setPageSize(Number(value))
|
||||
const newPageSize = Number(value)
|
||||
table.setPageSize(newPageSize)
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="w-16" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" className="min-w-18">
|
||||
{mergedProps.sizes?.map((size: number) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.infoSkeleton
|
||||
mergedProps.infoSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
|
||||
<div className="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">
|
||||
{paginationInfo}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="order-1 flex items-center space-x-1 sm:order-2">
|
||||
<div className="order-1 flex items-center space-x-1">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -25,6 +25,11 @@ const INITIAL_METRICS = {
|
||||
trackHeight: 0,
|
||||
} as const
|
||||
|
||||
const SCROLLBAR_CLASSNAME =
|
||||
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
|
||||
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
|
||||
|
||||
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
|
||||
|
||||
type ScrollbarMetrics = {
|
||||
@@ -365,11 +370,11 @@ function DataGridScrollArea({
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="horizontal"
|
||||
orientation="horizontal"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
className={SCROLLBAR_CLASSNAME}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
className={SCROLLBAR_THUMB_CLASSNAME}
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
@@ -379,11 +384,11 @@ function DataGridScrollArea({
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="vertical"
|
||||
orientation="vertical"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
className={SCROLLBAR_CLASSNAME}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
className={SCROLLBAR_THUMB_CLASSNAME}
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
|
||||
@@ -71,10 +71,10 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -87,11 +87,11 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -119,15 +119,10 @@ function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
||||
|
||||
return (
|
||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
||||
<DataGridTableBodyRow
|
||||
row={row}
|
||||
dndRef={setNodeRef}
|
||||
dndStyle={style}
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
|
||||
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
|
||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
@@ -227,7 +222,7 @@ function DataGridTableDndRows<TData>({
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const { column } = header
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
@@ -111,11 +112,11 @@ function DataGridTableDndHeader<TData>({
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<span className="grow truncate">
|
||||
<div className="grow">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
</div>
|
||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
@@ -184,33 +185,40 @@ function DataGridTableDnd<TData>({
|
||||
}, [isDraggingColumn])
|
||||
|
||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
||||
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
|
||||
if (!draggingNodeRect || !containerRef.current) {
|
||||
return { ...transform, y: 0 }
|
||||
const modifiers = useMemo(() => {
|
||||
const restrictToTableBounds: Modifier = ({
|
||||
draggingNodeRect,
|
||||
transform,
|
||||
}) => {
|
||||
if (!draggingNodeRect || !containerRef.current) {
|
||||
return { ...transform, y: 0 }
|
||||
}
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const edgeOffset = 0
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
||||
const maxX =
|
||||
containerRect.right -
|
||||
draggingNodeRect.left -
|
||||
draggingNodeRect.width +
|
||||
edgeOffset
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: 0, // Lock vertical movement
|
||||
}
|
||||
}
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const edgeOffset = 0
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
||||
const maxX =
|
||||
containerRect.right -
|
||||
draggingNodeRect.left -
|
||||
draggingNodeRect.width +
|
||||
edgeOffset
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: 0, // Lock vertical movement
|
||||
}
|
||||
}
|
||||
return [restrictToTableBounds]
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
id={useId()}
|
||||
modifiers={[restrictToTableBounds]}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingColumn(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingColumn(false)
|
||||
@@ -233,7 +241,7 @@ function DataGridTableDnd<TData>({
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
@@ -277,19 +285,16 @@ function DataGridTableDnd<TData>({
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<DataGridTableBodyRow row={row}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<TData, unknown>) => {
|
||||
return (
|
||||
<SortableContext
|
||||
key={cell.id}
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<DataGridTableDndCell cell={cell} />
|
||||
</SortableContext>
|
||||
)
|
||||
})}
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<TData, unknown>) => (
|
||||
<DataGridTableDndCell cell={cell} key={cell.id} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && (
|
||||
<DataGridTableBodyRowExpandded row={row} />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
CSSProperties,
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
@@ -19,10 +21,12 @@ import {
|
||||
DataGridTableRenderedRow,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
getDataGridTableMergedHeaderGroups,
|
||||
getDataGridTableRowSections,
|
||||
getPinningStyles,
|
||||
hasDataGridTableRightPinnedColumns,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { DATA_GRID_MESSAGES_RU } from "@/lib/data-grid-defaults"
|
||||
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
|
||||
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
|
||||
import {
|
||||
useVirtualizer,
|
||||
VirtualItem,
|
||||
@@ -69,7 +73,6 @@ interface DataGridTableVirtualProps<TData> {
|
||||
|
||||
interface VirtualBodyProps<TData> {
|
||||
table: Table<TData>
|
||||
columnCount: number
|
||||
topRows: Row<TData>[]
|
||||
centerRows: Row<TData>[]
|
||||
bottomRows: Row<TData>[]
|
||||
@@ -84,49 +87,140 @@ interface VirtualBodyProps<TData> {
|
||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||
}
|
||||
|
||||
function DataGridTableVirtualSpacer({
|
||||
columnCount,
|
||||
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
|
||||
column,
|
||||
}: {
|
||||
column: Column<TData>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const isPinned = column.getIsPinned()
|
||||
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
|
||||
const isFirstRightPinned =
|
||||
isPinned === "right" && column.getIsFirstColumn("right")
|
||||
|
||||
return (
|
||||
<td
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...(props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
getPinningStyles(column)),
|
||||
...(props.tableLayout?.columnsResizable && {
|
||||
width: `calc(var(--col-${column.id}-size) * 1px)`,
|
||||
}),
|
||||
}}
|
||||
data-pinned={isPinned || undefined}
|
||||
data-last-col={
|
||||
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"p-0",
|
||||
props.tableLayout?.cellBorder && "border-e",
|
||||
props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualUtilityRow<TData>({
|
||||
table,
|
||||
children,
|
||||
centerCellClassName,
|
||||
centerCellStyle,
|
||||
rowClassName,
|
||||
ariaHidden,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
children: ReactNode
|
||||
centerCellClassName?: string
|
||||
centerCellStyle?: CSSProperties
|
||||
rowClassName?: string
|
||||
ariaHidden?: boolean
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
|
||||
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
||||
const rightVisibleColumns = table.getRightVisibleLeafColumns()
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
return (
|
||||
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
|
||||
{leftVisibleColumns.map((column) => (
|
||||
<DataGridTableVirtualPinnedPlaceholderCell
|
||||
column={column}
|
||||
key={column.id}
|
||||
/>
|
||||
))}
|
||||
<td
|
||||
colSpan={Math.max(centerVisibleColumns.length, 1)}
|
||||
className={centerCellClassName}
|
||||
style={centerCellStyle}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
{rightVisibleColumns.map((column) => (
|
||||
<DataGridTableVirtualPinnedPlaceholderCell
|
||||
column={column}
|
||||
key={column.id}
|
||||
/>
|
||||
))}
|
||||
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualSpacer<TData>({
|
||||
table,
|
||||
height,
|
||||
}: {
|
||||
columnCount: number
|
||||
table: Table<TData>
|
||||
height: number
|
||||
}) {
|
||||
if (height <= 0) return null
|
||||
|
||||
return (
|
||||
<tr aria-hidden="true">
|
||||
<td colSpan={columnCount} style={{ height, padding: 0 }} />
|
||||
</tr>
|
||||
<DataGridTableVirtualUtilityRow
|
||||
table={table}
|
||||
ariaHidden
|
||||
centerCellClassName="p-0"
|
||||
centerCellStyle={{ height, padding: 0 }}
|
||||
>
|
||||
{null}
|
||||
</DataGridTableVirtualUtilityRow>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualStatusRow({
|
||||
function DataGridTableVirtualStatusRow<TData>({
|
||||
table,
|
||||
children,
|
||||
className,
|
||||
columnCount,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
children: ReactNode
|
||||
className?: string
|
||||
columnCount: number
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columnCount}
|
||||
className={cn(
|
||||
"text-muted-foreground py-4 text-center text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
</tr>
|
||||
<DataGridTableVirtualUtilityRow
|
||||
table={table}
|
||||
centerCellClassName={cn(
|
||||
"text-muted-foreground py-4 text-center text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</DataGridTableVirtualUtilityRow>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualBody<TData>({
|
||||
table,
|
||||
columnCount,
|
||||
topRows,
|
||||
centerRows,
|
||||
bottomRows,
|
||||
@@ -179,7 +273,7 @@ function DataGridTableVirtualBody<TData>({
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-start"
|
||||
columnCount={columnCount}
|
||||
table={table}
|
||||
height={leadingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
@@ -203,7 +297,7 @@ function DataGridTableVirtualBody<TData>({
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-end"
|
||||
columnCount={columnCount}
|
||||
table={table}
|
||||
height={trailingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
@@ -216,10 +310,7 @@ function DataGridTableVirtualBody<TData>({
|
||||
|
||||
if (showFetchingRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-loading"
|
||||
columnCount={columnCount}
|
||||
>
|
||||
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-4 opacity-60" />
|
||||
{loadingMoreMessage}
|
||||
@@ -232,7 +323,7 @@ function DataGridTableVirtualBody<TData>({
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-complete"
|
||||
columnCount={columnCount}
|
||||
table={table}
|
||||
className="py-3 text-xs"
|
||||
>
|
||||
{allRowsLoadedMessage}
|
||||
@@ -280,13 +371,12 @@ function DataGridTableVirtual<TData>({
|
||||
virtualizerOptions,
|
||||
}: DataGridTableVirtualProps<TData>) {
|
||||
const { table, props } = useDataGrid()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||
table,
|
||||
props.tableLayout?.rowsPinnable
|
||||
)
|
||||
const columnCount =
|
||||
table.getVisibleFlatColumns().length +
|
||||
(props.tableLayout?.columnsResizable ? 1 : 0)
|
||||
const isInfiniteMode = typeof onFetchMore === "function"
|
||||
const [viewportElements, setViewportElements] =
|
||||
useState<DataGridTableVirtualScrollElements>({
|
||||
@@ -305,9 +395,9 @@ function DataGridTableVirtual<TData>({
|
||||
|
||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||
const loadingMoreMessage =
|
||||
props.fetchingMoreMessage || props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||
const allRowsLoadedMessage =
|
||||
props.allRowsLoadedMessage || DATA_GRID_MESSAGES_RU.allRecordsLoadedMessage
|
||||
props.allRowsLoadedMessage || "All records loaded"
|
||||
|
||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setViewportElements({
|
||||
@@ -371,10 +461,7 @@ function DataGridTableVirtual<TData>({
|
||||
isVirtualizationEnabled && customMeasureElement
|
||||
? virtualizer.measureElement
|
||||
: undefined
|
||||
const resolvedFetchMoreOffset = useMemo(
|
||||
() => Math.max(0, fetchMoreOffset),
|
||||
[fetchMoreOffset]
|
||||
)
|
||||
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -416,29 +503,21 @@ function DataGridTableVirtual<TData>({
|
||||
<DataGridTableBase>
|
||||
{renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, hIndex) => {
|
||||
{mergedHeaderGroups.map((headerGroup) => (
|
||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() !== "right")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={hIndex}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
@@ -446,8 +525,36 @@ function DataGridTableVirtual<TData>({
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() === "right")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
!hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
</DataGridTableHead>
|
||||
)}
|
||||
|
||||
@@ -459,7 +566,6 @@ function DataGridTableVirtual<TData>({
|
||||
<DataGridTableBody>
|
||||
<MemoizedVirtualBody
|
||||
table={table}
|
||||
columnCount={columnCount}
|
||||
topRows={topRows}
|
||||
centerRows={centerRows}
|
||||
bottomRows={bottomRows}
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
TouchEvent as ReactTouchEvent,
|
||||
Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
Column,
|
||||
flexRender,
|
||||
Header,
|
||||
HeaderGroup,
|
||||
Row,
|
||||
Table,
|
||||
} from "@tanstack/react-table"
|
||||
@@ -28,15 +28,12 @@ import { cva } from "class-variance-authority"
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||
import { Spinner } from "@evobgp/ui/components/spinner"
|
||||
import { DATA_GRID_MESSAGES_RU } from "@/lib/data-grid-defaults"
|
||||
|
||||
const headerCellSpacingVariants = cva("", {
|
||||
variants: {
|
||||
size: {
|
||||
dense:
|
||||
"px-2 h-8",
|
||||
default:
|
||||
"px-3",
|
||||
dense: "px-2 h-8",
|
||||
default: "px-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -47,10 +44,8 @@ const headerCellSpacingVariants = cva("", {
|
||||
const bodyCellSpacingVariants = cva("", {
|
||||
variants: {
|
||||
size: {
|
||||
dense:
|
||||
"px-2 py-1.5",
|
||||
default:
|
||||
"px-3 py-2",
|
||||
dense: "px-2 py-1.5",
|
||||
default: "px-3 py-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -61,10 +56,8 @@ const bodyCellSpacingVariants = cva("", {
|
||||
const footerCellSpacingVariants = cva("", {
|
||||
variants: {
|
||||
size: {
|
||||
dense:
|
||||
"px-2 py-1.5",
|
||||
default:
|
||||
"px-3 py-2",
|
||||
dense: "px-2 py-1.5",
|
||||
default: "px-3 py-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -78,9 +71,12 @@ function getPinningStyles<TData>(column: Column<TData>): CSSProperties {
|
||||
return {
|
||||
left: isPinned === "left" ? `${column.getStart("left")}px` : undefined,
|
||||
right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined,
|
||||
position: isPinned ? "sticky" : "relative",
|
||||
position: isPinned ? "sticky" : undefined,
|
||||
transform: isPinned ? "translateZ(0)" : undefined,
|
||||
contain: isPinned ? "paint" : undefined,
|
||||
width: column.getSize(),
|
||||
zIndex: isPinned ? 1 : 0,
|
||||
zIndex: isPinned ? 30 : undefined,
|
||||
backgroundClip: isPinned ? "padding-box" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,6 +334,55 @@ function getDataGridTableResolvedRows<TData>(
|
||||
return resolvedRows
|
||||
}
|
||||
|
||||
function getDataGridTableOrderedVisibleColumns<TData>(table: Table<TData>) {
|
||||
return [
|
||||
...table.getLeftVisibleLeafColumns(),
|
||||
...table.getCenterVisibleLeafColumns(),
|
||||
...table.getRightVisibleLeafColumns(),
|
||||
] as Column<TData>[]
|
||||
}
|
||||
|
||||
function getDataGridTableOrderedVisibleCells<TData>(row: Row<TData>) {
|
||||
return [
|
||||
...row.getLeftVisibleCells(),
|
||||
...row.getCenterVisibleCells(),
|
||||
...row.getRightVisibleCells(),
|
||||
] as Cell<TData, unknown>[]
|
||||
}
|
||||
|
||||
function getDataGridTableMergedHeaderGroups<TData>(table: Table<TData>) {
|
||||
const leftHeaderGroups = table.getLeftHeaderGroups()
|
||||
const centerHeaderGroups = table.getCenterHeaderGroups()
|
||||
const rightHeaderGroups = table.getRightHeaderGroups()
|
||||
const headerGroupCount = Math.max(
|
||||
leftHeaderGroups.length,
|
||||
centerHeaderGroups.length,
|
||||
rightHeaderGroups.length
|
||||
)
|
||||
|
||||
return Array.from({ length: headerGroupCount }, (_, index) => {
|
||||
const leftGroup = leftHeaderGroups[index]
|
||||
const centerGroup = centerHeaderGroups[index]
|
||||
const rightGroup = rightHeaderGroups[index]
|
||||
|
||||
return {
|
||||
id:
|
||||
[leftGroup?.id, centerGroup?.id, rightGroup?.id]
|
||||
.filter(Boolean)
|
||||
.join(":") || `header-group-${index}`,
|
||||
headers: [
|
||||
...(leftGroup?.headers ?? []),
|
||||
...(centerGroup?.headers ?? []),
|
||||
...(rightGroup?.headers ?? []),
|
||||
] as Header<TData, unknown>[],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function hasDataGridTableRightPinnedColumns<TData>(table: Table<TData>) {
|
||||
return (table.getState().columnPinning.right?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
function DataGridTableFillCol() {
|
||||
const { props } = useDataGrid()
|
||||
|
||||
@@ -391,14 +436,17 @@ function DataGridTableFillFootCell() {
|
||||
aria-hidden="true"
|
||||
data-slot="data-grid-table-fill-foot-cell"
|
||||
style={{ width: "var(--data-grid-fill-size, 0px)" }}
|
||||
className="border-t p-0"
|
||||
className="p-0"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableBase({ children }: { children: ReactNode }) {
|
||||
const { props, table } = useDataGrid()
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
|
||||
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
||||
const rightVisibleColumns = table.getRightVisibleLeafColumns()
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
/**
|
||||
* Compute column widths as CSS custom properties once upfront (memoized).
|
||||
@@ -427,7 +475,7 @@ function DataGridTableBase({ children }: { children: ReactNode }) {
|
||||
<table
|
||||
data-slot="data-grid-table"
|
||||
className={cn(
|
||||
"text-foreground text-sm caption-bottom text-left align-middle font-normal rtl:text-right",
|
||||
"text-foreground caption-bottom text-left align-middle text-sm font-normal rtl:text-right",
|
||||
props.tableLayout?.columnsResizable ? "min-w-0" : "w-full min-w-full",
|
||||
props.tableLayout?.width === "auto" ? "table-auto" : "table-fixed",
|
||||
!props.tableLayout?.columnsResizable && "",
|
||||
@@ -445,7 +493,7 @@ function DataGridTableBase({ children }: { children: ReactNode }) {
|
||||
}
|
||||
>
|
||||
<colgroup>
|
||||
{visibleColumns.map((column) => (
|
||||
{[...leftVisibleColumns, ...centerVisibleColumns].map((column) => (
|
||||
<col
|
||||
key={column.id}
|
||||
style={
|
||||
@@ -457,7 +505,20 @@ function DataGridTableBase({ children }: { children: ReactNode }) {
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<DataGridTableFillCol />
|
||||
{hasRightPinnedColumns ? <DataGridTableFillCol /> : null}
|
||||
{rightVisibleColumns.map((column) => (
|
||||
<col
|
||||
key={column.id}
|
||||
style={
|
||||
props.tableLayout?.columnsResizable
|
||||
? { width: `calc(var(--col-${column.id}-size) * 1px)` }
|
||||
: props.tableLayout?.width === "fixed"
|
||||
? { width: column.getSize() }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{!hasRightPinnedColumns ? <DataGridTableFillCol /> : null}
|
||||
</colgroup>
|
||||
{children}
|
||||
</table>
|
||||
@@ -476,6 +537,7 @@ function DataGridTableViewport({
|
||||
style?: CSSProperties
|
||||
}) {
|
||||
const { props, table } = useDataGrid()
|
||||
const didApplyAutoSizeColumnRef = useRef<string | null>(null)
|
||||
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
)
|
||||
@@ -483,16 +545,34 @@ function DataGridTableViewport({
|
||||
const handleViewportRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
setViewportElement(node)
|
||||
|
||||
if (props.tableLayout?.columnsResizable && node) {
|
||||
const scrollViewport =
|
||||
(node.closest(
|
||||
'[data-slot="scroll-area-viewport"]'
|
||||
) as HTMLElement | null) ?? node.parentElement
|
||||
const measurementTarget = scrollViewport ?? node
|
||||
|
||||
setContainerWidth(measurementTarget.clientWidth)
|
||||
} else if (!node) {
|
||||
setContainerWidth(0)
|
||||
}
|
||||
|
||||
assignRef(viewportRef, node)
|
||||
},
|
||||
[viewportRef]
|
||||
[props.tableLayout?.columnsResizable, viewportRef]
|
||||
)
|
||||
const fillWidth =
|
||||
props.tableLayout?.columnsResizable && containerWidth > 0
|
||||
? Math.max(0, containerWidth - table.getTotalSize())
|
||||
: 0
|
||||
const autoSizeColumnId = table
|
||||
.getVisibleLeafColumns()
|
||||
.find(
|
||||
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
||||
)?.id
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!viewportElement || !props.tableLayout?.columnsResizable) {
|
||||
setContainerWidth(0)
|
||||
return
|
||||
@@ -520,6 +600,22 @@ function DataGridTableViewport({
|
||||
}
|
||||
}, [props.tableLayout?.columnsResizable, viewportElement])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!props.tableLayout?.columnsResizable) return
|
||||
if (!autoSizeColumnId || fillWidth <= 0) return
|
||||
if (didApplyAutoSizeColumnRef.current === autoSizeColumnId) return
|
||||
|
||||
const autoSizeColumn = table.getColumn(autoSizeColumnId)
|
||||
if (!autoSizeColumn) return
|
||||
|
||||
didApplyAutoSizeColumnRef.current = autoSizeColumnId
|
||||
table.setColumnSizing((old) => ({
|
||||
...old,
|
||||
[autoSizeColumnId]:
|
||||
(old[autoSizeColumnId] ?? autoSizeColumn.getSize()) + fillWidth,
|
||||
}))
|
||||
}, [autoSizeColumnId, fillWidth, props.tableLayout?.columnsResizable, table])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid-table-viewport"
|
||||
@@ -556,20 +652,18 @@ function DataGridTableHead({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableHeadRow<TData>({
|
||||
function DataGridTableHeadRow({
|
||||
children,
|
||||
headerGroup,
|
||||
rowId,
|
||||
}: {
|
||||
children: ReactNode
|
||||
headerGroup: HeaderGroup<TData>
|
||||
rowId: string
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={headerGroup.id}
|
||||
className={cn(
|
||||
"bg-muted/40",
|
||||
props.tableLayout?.headerBorder && "[&>th]:border-b",
|
||||
props.tableLayout?.cellBorder && "*:last:border-e-0",
|
||||
props.tableLayout?.stripped && "bg-transparent",
|
||||
@@ -578,7 +672,6 @@ function DataGridTableHeadRow<TData>({
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<DataGridTableFillHeadCell />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -598,9 +691,13 @@ function DataGridTableHeadRowCell<TData>({
|
||||
|
||||
const { column } = header
|
||||
const isPinned = column.getIsPinned()
|
||||
const isFirstLeftPinned =
|
||||
isPinned === "left" && column.getIsFirstColumn("left")
|
||||
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
|
||||
const isFirstRightPinned =
|
||||
isPinned === "right" && column.getIsFirstColumn("right")
|
||||
const isLastRightPinned =
|
||||
isPinned === "right" && column.getIsLastColumn("right")
|
||||
const isLastVisibleColumn =
|
||||
column.getIndex() ===
|
||||
header.getContext().table.getVisibleLeafColumns().length - 1
|
||||
@@ -610,7 +707,6 @@ function DataGridTableHeadRowCell<TData>({
|
||||
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
ref={dndRef}
|
||||
style={{
|
||||
...(props.tableLayout?.width === "fixed" &&
|
||||
@@ -626,23 +722,31 @@ function DataGridTableHeadRowCell<TData>({
|
||||
...(dndStyle ? dndStyle : null),
|
||||
}}
|
||||
data-pinned={isPinned || undefined}
|
||||
data-outer-pinned-col={
|
||||
isFirstLeftPinned ? "left" : isLastRightPinned ? "right" : undefined
|
||||
}
|
||||
data-last-col={
|
||||
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"text-secondary-foreground/80 h-9 relative text-left align-middle font-normal rtl:text-right [&:has([role=checkbox])]:pe-0",
|
||||
"text-foreground relative h-10 text-left align-middle font-medium rtl:text-right [&:has([role=checkbox])]:pe-0",
|
||||
headerCellSpacing,
|
||||
props.tableLayout?.headerBackground && "bg-muted",
|
||||
props.tableLayout?.cellBorder && "border-e",
|
||||
props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() &&
|
||||
"overflow-visible",
|
||||
(isPinned ? "overflow-hidden" : "overflow-visible"),
|
||||
props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() &&
|
||||
isLastVisibleColumn &&
|
||||
"pe-8",
|
||||
props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
"[&[data-pinned][data-last-col]]:border-border data-pinned:bg-muted/90 data-pinned:backdrop-blur-xs [&:not([data-pinned]):has(+[data-pinned])_div.cursor-col-resize:last-child]:opacity-0 [&[data-last-col=left]_div.cursor-col-resize:last-child]:opacity-0 [&[data-pinned=left][data-last-col=left]]:border-e! [&[data-pinned=right]:last-child_div.cursor-col-resize:last-child]:opacity-0 [&[data-pinned=right][data-last-col=right]]:border-s!",
|
||||
cn(
|
||||
"data-pinned:bg-muted data-outer-pinned-col:bg-clip-padding data-pinned:isolate",
|
||||
"[&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right]:last-child_div.cursor-col-resize:last-child]:opacity-0 [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]",
|
||||
"[&:not([data-pinned]):has(+[data-pinned])_div.cursor-col-resize:last-child]:opacity-0 [&[data-last-col=left]_div.cursor-col-resize:last-child]:opacity-0"
|
||||
),
|
||||
header.column.columnDef.meta?.headerClassName,
|
||||
column.getIndex() === 0 ||
|
||||
column.getIndex() === header.headerGroup.headers.length - 1
|
||||
@@ -662,6 +766,7 @@ function DataGridTableHeadRowCellResize<TData>({
|
||||
}) {
|
||||
const { props, table } = useDataGrid()
|
||||
const { column } = header
|
||||
const isPinned = column.getIsPinned()
|
||||
const isLastVisibleColumn =
|
||||
column.getIndex() ===
|
||||
header.getContext().table.getVisibleLeafColumns().length - 1
|
||||
@@ -703,7 +808,9 @@ function DataGridTableHeadRowCellResize<TData>({
|
||||
"absolute top-0 h-full cursor-col-resize user-select-none touch-none z-10 flex",
|
||||
isLastVisibleColumn
|
||||
? "end-0 w-5 justify-end before:hidden"
|
||||
: "-end-2 w-5 justify-center before:absolute before:inset-y-0 before:w-px before:-translate-x-px before:bg-border",
|
||||
: isPinned
|
||||
? "end-0 w-5 justify-end before:hidden"
|
||||
: "-end-2 w-5 justify-center before:absolute before:inset-y-0 before:w-px before:-translate-x-px before:bg-border",
|
||||
column.getIsResizing() &&
|
||||
(isResizeModeOnEnd
|
||||
? "opacity-100"
|
||||
@@ -766,7 +873,7 @@ function DataGridTableResizeIndicator({
|
||||
>
|
||||
<div className="bg-primary/85 absolute inset-y-0 left-0 w-px -translate-x-1/2" />
|
||||
<div
|
||||
className="bg-primary absolute top-0 left-0 -translate-x-1/2 rounded-b-sm shadow-xs"
|
||||
className="bg-primary rounded-b-sm absolute top-0 left-0 -translate-x-1/2 shadow-xs"
|
||||
style={{
|
||||
width: 5,
|
||||
height: Math.max(headerHeight, 6),
|
||||
@@ -777,7 +884,13 @@ function DataGridTableResizeIndicator({
|
||||
}
|
||||
|
||||
function DataGridTableRowSpacer() {
|
||||
return <tbody aria-hidden="true" className="h-2"></tbody>
|
||||
return (
|
||||
<tbody
|
||||
aria-hidden="true"
|
||||
className="h-2"
|
||||
data-slot="data-grid-table-body-spacer"
|
||||
></tbody>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableBody({ children }: { children: ReactNode }) {
|
||||
@@ -785,8 +898,8 @@ function DataGridTableBody({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<tbody
|
||||
data-slot="data-grid-table-body"
|
||||
className={cn(
|
||||
"[&_tr:last-child]:border-0",
|
||||
props.tableLayout?.rowRounded &&
|
||||
"[&_td:first-child]:rounded-l-lg",
|
||||
props.tableLayout?.rowRounded &&
|
||||
@@ -802,7 +915,10 @@ function DataGridTableBody({ children }: { children: ReactNode }) {
|
||||
function DataGridTableFoot({ children }: { children: ReactNode }) {
|
||||
const { props } = useDataGrid()
|
||||
return (
|
||||
<tfoot className={cn("border-t", props.tableClassNames?.footer)}>
|
||||
<tfoot
|
||||
data-slot="data-grid-table-foot"
|
||||
className={cn(props.tableClassNames?.footer)}
|
||||
>
|
||||
{children}
|
||||
</tfoot>
|
||||
)
|
||||
@@ -810,10 +926,14 @@ function DataGridTableFoot({ children }: { children: ReactNode }) {
|
||||
|
||||
function DataGridTableFootRow({ children }: { children: ReactNode }) {
|
||||
const { props } = useDataGrid()
|
||||
const footRowBottomBorderClasses = "[&:not(:last-child)>td]:border-b"
|
||||
|
||||
return (
|
||||
<tr
|
||||
data-slot="data-grid-table-foot-row"
|
||||
className={cn(
|
||||
"bg-muted/40 dark:bg-background",
|
||||
props.tableLayout?.footerBackground && "bg-muted/40 dark:bg-background",
|
||||
props.tableLayout?.rowBorder && footRowBottomBorderClasses,
|
||||
props.tableLayout?.cellBorder && "*:last:border-e-0"
|
||||
)}
|
||||
>
|
||||
@@ -840,8 +960,9 @@ function DataGridTableFootRowCell({
|
||||
<td
|
||||
colSpan={colSpan}
|
||||
className={cn(
|
||||
"text-secondary-foreground/80 border-t align-middle font-medium",
|
||||
"text-secondary-foreground/80 align-middle font-medium",
|
||||
spacing,
|
||||
props.tableLayout?.footerBackground && "bg-muted/40 dark:bg-background",
|
||||
props.tableLayout?.cellBorder && "border-e",
|
||||
className
|
||||
)}
|
||||
@@ -870,7 +991,6 @@ function DataGridTableBodyRowSkeleton({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<DataGridTableFillBodyCell />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -904,7 +1024,7 @@ function DataGridTableBodyRowSkeletonCell<TData>({
|
||||
column.columnDef.meta?.cellClassName,
|
||||
props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
'[&[data-pinned][data-last-col]]:border-border data-pinned:bg-background/90 data-pinned:backdrop-blur-xs" [&[data-pinned=left][data-last-col=left]]:border-e! [&[data-pinned=right][data-last-col=right]]:border-s!',
|
||||
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]",
|
||||
column.getIndex() === 0 ||
|
||||
column.getIndex() === table.getVisibleFlatColumns().length - 1
|
||||
? props.tableClassNames?.edgeCell
|
||||
@@ -934,6 +1054,9 @@ function DataGridTableBodyRow<TData>({
|
||||
const { props, table } = useDataGrid()
|
||||
const isRowPinned = row.getIsPinned()
|
||||
|
||||
const bodyRowBottomBorderClasses =
|
||||
"[&:not(:last-child)>td]:border-b [tbody:has(+tfoot)_&:last-child>td]:border-b [*:has(>[data-slot=data-grid]+[data-slot=data-grid-pagination])_[data-slot=data-grid]_&:last-child>td]:border-b"
|
||||
|
||||
return (
|
||||
<tr
|
||||
ref={(node) => {
|
||||
@@ -954,8 +1077,9 @@ function DataGridTableBodyRow<TData>({
|
||||
props.onRowClick && "cursor-pointer",
|
||||
!props.tableLayout?.stripped &&
|
||||
props.tableLayout?.rowBorder &&
|
||||
"border-border border-b [&:not(:last-child)>td]:border-b",
|
||||
props.tableLayout?.cellBorder && "*:last:border-e-0",
|
||||
bodyRowBottomBorderClasses,
|
||||
props.tableLayout?.cellBorder &&
|
||||
`*:last:border-e-0 ${bodyRowBottomBorderClasses}`,
|
||||
props.tableLayout?.stripped &&
|
||||
"odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent",
|
||||
table.options.enableRowSelection && "*:first:relative",
|
||||
@@ -969,23 +1093,22 @@ function DataGridTableBodyRow<TData>({
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<DataGridTableFillBodyCell />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableBodyRowExpandded<TData>({ row }: { row: Row<TData> }) {
|
||||
const { props, table } = useDataGrid()
|
||||
const bodyRowBottomBorderClasses =
|
||||
"[&:not(:last-child)>td]:border-b [tbody:has(+tfoot)_&:last-child>td]:border-b [*:has(>[data-slot=data-grid]+[data-slot=data-grid-pagination])_[data-slot=data-grid]_&:last-child>td]:border-b"
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
props.tableLayout?.rowBorder && "[&:not(:last-child)>td]:border-b"
|
||||
)}
|
||||
className={cn(props.tableLayout?.rowBorder && bodyRowBottomBorderClasses)}
|
||||
>
|
||||
<td
|
||||
colSpan={
|
||||
row.getVisibleCells().length +
|
||||
getDataGridTableOrderedVisibleCells(row).length +
|
||||
(props.tableLayout?.columnsResizable ? 1 : 0)
|
||||
}
|
||||
>
|
||||
@@ -1022,9 +1145,7 @@ function DataGridTableBodyRowCell<TData>({
|
||||
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
ref={dndRef}
|
||||
{...(props.tableLayout?.columnsDraggable && !isPinned ? { cell } : {})}
|
||||
style={{
|
||||
...(props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
@@ -1048,7 +1169,11 @@ function DataGridTableBodyRowCell<TData>({
|
||||
cell.column.columnDef.meta?.cellClassName,
|
||||
props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
'[&[data-pinned][data-last-col]]:border-border data-pinned:bg-background/90 data-pinned:backdrop-blur-xs" [&[data-pinned=left][data-last-col=left]]:border-e! [&[data-pinned=right][data-last-col=right]]:border-s!',
|
||||
cn(
|
||||
"data-pinned:bg-background data-pinned:isolate",
|
||||
"[&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)]",
|
||||
"[&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
|
||||
),
|
||||
column.getIndex() === 0 ||
|
||||
column.getIndex() === row.getVisibleCells().length - 1
|
||||
? props.tableClassNames?.edgeCell
|
||||
@@ -1069,6 +1194,12 @@ function DataGridTableRenderedRow<TData>({
|
||||
pinnedBoundary?: DataGridTablePinnedBoundary
|
||||
rowRef?: React.Ref<HTMLTableRowElement>
|
||||
}) {
|
||||
const { props, table } = useDataGrid()
|
||||
const leftVisibleCells = row.getLeftVisibleCells()
|
||||
const centerVisibleCells = row.getCenterVisibleCells()
|
||||
const rightVisibleCells = row.getRightVisibleCells()
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<DataGridTableBodyRow
|
||||
@@ -1076,11 +1207,24 @@ function DataGridTableRenderedRow<TData>({
|
||||
pinnedBoundary={pinnedBoundary}
|
||||
rowRef={rowRef}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
|
||||
{[...leftVisibleCells, ...centerVisibleCells].map(
|
||||
(cell: Cell<TData, unknown>) => (
|
||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
{rightVisibleCells.map((cell: Cell<TData, unknown>) => (
|
||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
))}
|
||||
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
||||
</Fragment>
|
||||
@@ -1090,16 +1234,16 @@ function DataGridTableRenderedRow<TData>({
|
||||
function DataGridTableEmpty() {
|
||||
const { table, props } = useDataGrid()
|
||||
const visibleColumnCount =
|
||||
table.getVisibleLeafColumns().length +
|
||||
getDataGridTableOrderedVisibleColumns(table).length +
|
||||
(props.tableLayout?.columnsResizable ? 1 : 0)
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={Math.max(visibleColumnCount, 1)}
|
||||
className="text-muted-foreground text-sm py-6 text-center"
|
||||
className="text-muted-foreground py-6 text-center text-sm"
|
||||
>
|
||||
{props.emptyMessage || DATA_GRID_MESSAGES_RU.emptyMessage}
|
||||
{props.emptyMessage || "No data available"}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -1110,9 +1254,9 @@ function DataGridTableLoader() {
|
||||
|
||||
return (
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="text-muted-foreground bg-card rounded-lg text-sm flex items-center gap-2 border px-4 py-2 leading-none font-medium">
|
||||
<div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium">
|
||||
<Spinner className="size-5 opacity-60" />
|
||||
{props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||
{props.loadingMessage || "Loading..."}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1124,7 +1268,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isPinned ? DATA_GRID_MESSAGES_RU.unpinRowLabel : DATA_GRID_MESSAGES_RU.pinRowLabel}
|
||||
aria-label={isPinned ? "Unpin row" : "Pin row"}
|
||||
onClick={() => {
|
||||
if (isPinned) {
|
||||
row.pin(false)
|
||||
@@ -1133,7 +1277,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground inline-flex size-7 items-center justify-center rounded-md transition-colors",
|
||||
"text-muted-foreground hover:text-foreground rounded-lg inline-flex size-7 items-center justify-center transition-colors",
|
||||
isPinned && "text-primary hover:text-primary/80"
|
||||
)}
|
||||
>
|
||||
@@ -1180,7 +1324,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label={DATA_GRID_MESSAGES_RU.selectRowLabel}
|
||||
aria-label="Select row"
|
||||
className="align-[inherit]"
|
||||
/>
|
||||
</>
|
||||
@@ -1199,7 +1343,7 @@ function DataGridTableRowSelectAll() {
|
||||
indeterminate={isSomeSelected && !isAllSelected}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label={DATA_GRID_MESSAGES_RU.selectAllLabel}
|
||||
aria-label="Select all"
|
||||
className="align-[inherit]"
|
||||
/>
|
||||
)
|
||||
@@ -1210,15 +1354,31 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
|
||||
const pagination = table.getState().pagination
|
||||
|
||||
if (isLoading && props.loadingMode === "skeleton" && pagination?.pageSize) {
|
||||
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
|
||||
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
||||
const rightVisibleColumns = table.getRightVisibleLeafColumns()
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => (
|
||||
<DataGridTableBodyRowSkeletonCell column={column} key={colIndex}>
|
||||
{[...leftVisibleColumns, ...centerVisibleColumns].map((column) => (
|
||||
<DataGridTableBodyRowSkeletonCell column={column} key={column.id}>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
))}
|
||||
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
{rightVisibleColumns.map((column) => (
|
||||
<DataGridTableBodyRowSkeletonCell column={column} key={column.id}>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
))}
|
||||
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))}
|
||||
</>
|
||||
@@ -1250,7 +1410,7 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||
{props.loadingMessage || "Loading..."}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1289,35 +1449,29 @@ const MemoizedDataGridTableBodyRows = memo(
|
||||
|
||||
function DataGridTableHeader<TData>() {
|
||||
const { table, props } = useDataGrid()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
return (
|
||||
<DataGridTableViewport>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
{mergedHeaderGroups.map((headerGroup) => {
|
||||
return (
|
||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() !== "right")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
@@ -1325,9 +1479,37 @@ function DataGridTableHeader<TData>() {
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() === "right")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
!hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
@@ -1342,36 +1524,36 @@ function DataGridTable<TData>({
|
||||
renderHeader?: boolean
|
||||
}) {
|
||||
const { table, props } = useDataGrid()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
return (
|
||||
<DataGridTableViewport>
|
||||
<DataGridTableBase>
|
||||
{renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
{mergedHeaderGroups.map((headerGroup) => {
|
||||
return (
|
||||
<DataGridTableHeadRow
|
||||
key={headerGroup.id}
|
||||
rowId={headerGroup.id}
|
||||
>
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() !== "right")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
<DataGridTableHeadRowCell
|
||||
header={header}
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
@@ -1379,9 +1561,40 @@ function DataGridTable<TData>({
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() === "right")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell
|
||||
header={header}
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
!hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
)}
|
||||
|
||||
@@ -1413,6 +1626,8 @@ export {
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableFootRow,
|
||||
DataGridTableFootRowCell,
|
||||
@@ -1427,8 +1642,11 @@ export {
|
||||
DataGridTableRowSelectAll,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
getDataGridTableMergedHeaderGroups,
|
||||
getPinningStyles,
|
||||
getDataGridTableResolvedRows,
|
||||
getDataGridTableRowSections,
|
||||
hasDataGridTableRightPinnedColumns,
|
||||
}
|
||||
|
||||
export type { DataGridTablePinnedBoundary }
|
||||
@@ -17,6 +17,7 @@ declare module "@tanstack/react-table" {
|
||||
cellClassName?: string
|
||||
skeleton?: ReactNode
|
||||
expandedContent?: (row: TData) => ReactNode
|
||||
autoSize?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +82,7 @@ export interface DataGridProps<TData extends object> {
|
||||
rowRounded?: boolean
|
||||
stripped?: boolean
|
||||
headerBackground?: boolean
|
||||
footerBackground?: boolean
|
||||
headerBorder?: boolean
|
||||
headerSticky?: boolean
|
||||
width?: "auto" | "fixed"
|
||||
@@ -106,7 +108,7 @@ export interface DataGridProps<TData extends object> {
|
||||
}
|
||||
|
||||
const DataGridContext = createContext<
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
DataGridContextProps<any> | undefined
|
||||
>(undefined)
|
||||
|
||||
@@ -192,7 +194,8 @@ function DataGrid<TData extends object>({
|
||||
rowRounded: false,
|
||||
stripped: false,
|
||||
headerSticky: false,
|
||||
headerBackground: true,
|
||||
headerBackground: false,
|
||||
footerBackground: false,
|
||||
headerBorder: true,
|
||||
width: "fixed",
|
||||
columnsVisibility: false,
|
||||
@@ -253,12 +256,7 @@ function DataGridContainer({
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid"
|
||||
className={cn(
|
||||
"w-full overflow-hidden",
|
||||
border &&
|
||||
"border-border rounded-lg border",
|
||||
className
|
||||
)}
|
||||
className={cn("w-full overflow-hidden", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { createContext, useCallback, useContext, useState } from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
// Types
|
||||
type TimelineContextValue = {
|
||||
activeStep: number
|
||||
setActiveStep: (step: number) => void
|
||||
}
|
||||
|
||||
// Context
|
||||
const TimelineContext = createContext<TimelineContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const useTimeline = () => {
|
||||
const context = useContext(TimelineContext)
|
||||
if (!context) {
|
||||
throw new Error("useTimeline must be used within a Timeline")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Components
|
||||
interface TimelineProps extends useRender.ComponentProps<"div"> {
|
||||
defaultValue?: number
|
||||
value?: number
|
||||
onValueChange?: (value: number) => void
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
|
||||
function Timeline({
|
||||
defaultValue = 1,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = "vertical",
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineProps) {
|
||||
const [activeStep, setInternalStep] = useState(defaultValue)
|
||||
|
||||
const setActiveStep = useCallback(
|
||||
(step: number) => {
|
||||
if (value === undefined) {
|
||||
setInternalStep(step)
|
||||
}
|
||||
onValueChange?.(step)
|
||||
},
|
||||
[value, onValueChange]
|
||||
)
|
||||
|
||||
const currentStep = value ?? activeStep
|
||||
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
),
|
||||
"data-orientation": orientation,
|
||||
"data-slot": "timeline",
|
||||
children,
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineContext.Provider
|
||||
value={{ activeStep: currentStep, setActiveStep }}
|
||||
>
|
||||
{useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})}
|
||||
</TimelineContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// TimelineContent
|
||||
function TimelineContent({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
className: cn("text-muted-foreground text-sm", className),
|
||||
"data-slot": "timeline-content",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineDate
|
||||
type TimelineDateProps = useRender.ComponentProps<"time">
|
||||
|
||||
function TimelineDate({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineDateProps) {
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-date",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "time",
|
||||
render,
|
||||
props: mergeProps<"time">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineHeader
|
||||
function TimelineHeader({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
className: cn(className),
|
||||
"data-slot": "timeline-header",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineIndicator
|
||||
type TimelineIndicatorProps = useRender.ComponentProps<"div">
|
||||
|
||||
function TimelineIndicator({
|
||||
className,
|
||||
children,
|
||||
render,
|
||||
...props
|
||||
}: TimelineIndicatorProps) {
|
||||
const defaultProps = {
|
||||
"aria-hidden": true,
|
||||
className: cn(
|
||||
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-indicator",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineItem
|
||||
interface TimelineItemProps extends useRender.ComponentProps<"div"> {
|
||||
step: number
|
||||
}
|
||||
|
||||
function TimelineItem({
|
||||
step,
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineItemProps) {
|
||||
const { activeStep } = useTimeline()
|
||||
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary",
|
||||
className
|
||||
),
|
||||
"data-completed": step <= activeStep || undefined,
|
||||
"data-slot": "timeline-item",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineSeparator
|
||||
function TimelineSeparator({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
"aria-hidden": true,
|
||||
className: cn(
|
||||
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-separator",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineTitle
|
||||
function TimelineTitle({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"h3">) {
|
||||
const defaultProps = {
|
||||
className: cn("font-medium text-sm", className),
|
||||
"data-slot": "timeline-title",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "h3",
|
||||
render,
|
||||
props: mergeProps<"h3">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { panelCardInsetClassName } from '@/components/panel-card'
|
||||
import { SectionCards } from './section-cards'
|
||||
@@ -19,55 +17,43 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||
|
||||
export function AnalyticsDashboardSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3 lg:items-start">
|
||||
<Card className="gap-0">
|
||||
<CardContent className="space-y-5 py-5">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-2 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="gap-0">
|
||||
<CardContent className="space-y-5 py-5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-36 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="gap-0">
|
||||
<CardContent className="space-y-5 py-5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-44 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-12">
|
||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-8" />
|
||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-4" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
<Skeleton className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<Card className="gap-0">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className={cn('flex gap-2 border-b', panelCardInsetClassName)}>
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div className={cn('flex gap-2 border-b', panelCardInsetClassName)} key={`r-${r}`}>
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl ring-1 ring-foreground/10">
|
||||
<div className="flex flex-col">
|
||||
<div className={`flex gap-2 border-b ${panelCardInsetClassName}`}>
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div className={`flex gap-2 border-b ${panelCardInsetClassName}`} key={`r-${r}`}>
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,16 +4,15 @@ import { RefreshCw } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
import {
|
||||
DashboardNetworkCapacityCard,
|
||||
DashboardOperationsFlowCard,
|
||||
DashboardPlatformCard,
|
||||
} from '@/components/analytics'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { DashboardQuickActions } from '@/components/dashboard/dashboard-quick-actions'
|
||||
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { DashboardKpiGrid } from '@/components/dashboard/dashboard-kpi-grid'
|
||||
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
||||
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
||||
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
|
||||
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
|
||||
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
|
||||
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
@@ -89,51 +88,68 @@ function DashboardComponent() {
|
||||
{initialLoading ? (
|
||||
<AnalyticsDashboardSkeleton />
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-3 lg:items-start">
|
||||
<DashboardPlatformCard
|
||||
<>
|
||||
<DashboardKpiGrid
|
||||
modules={modules}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
jobs={jobs}
|
||||
revisions={revisions}
|
||||
/>
|
||||
<DashboardNetworkCapacityCard peers={peers} speakers={speakers} jobs={jobs} />
|
||||
<DashboardOperationsFlowCard jobs={jobs} modules={modules} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-12 xl:items-start">
|
||||
<div className="xl:col-span-8">
|
||||
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
||||
</div>
|
||||
<div className="xl:col-span-4">
|
||||
<DashboardActivityTimeline
|
||||
jobs={jobs}
|
||||
revisions={revisions}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
|
||||
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<DataGridCard
|
||||
<DashboardFramePanel
|
||||
title="Недавние задачи"
|
||||
description="Последние фоновые операции"
|
||||
className="h-full"
|
||||
>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-3 h-24 w-auto" />
|
||||
<Skeleton className="m-4 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
|
||||
)}
|
||||
</DataGridCard>
|
||||
</DashboardFramePanel>
|
||||
|
||||
<DataGridCard
|
||||
<DashboardFramePanel
|
||||
title="Последние ревизии"
|
||||
description="История конфигураций"
|
||||
className="h-full"
|
||||
>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-3 h-24 w-auto" />
|
||||
<Skeleton className="m-4 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
||||
)}
|
||||
</DataGridCard>
|
||||
</DashboardFramePanel>
|
||||
</div>
|
||||
|
||||
<PanelCard
|
||||
title="Быстрые действия"
|
||||
description="Частые переходы к настройке и деплою"
|
||||
footer={<DashboardQuickActions />}
|
||||
footerClassName="gap-2 px-5 py-4"
|
||||
/>
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-sm font-semibold">Быстрые действия</h2>
|
||||
<p className="text-muted-foreground text-sm">Частые переходы к настройке и деплою</p>
|
||||
</div>
|
||||
<DashboardQuickLinks />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
"include": ["src", "vite.config.ts"],
|
||||
"exclude": ["src/components/blocks"]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import type { TooltipValueType } from "recharts"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn(
|
||||
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent",
|
||||
outline: "border-border",
|
||||
muted: "border-transparent bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "gap-2.5 px-3 py-2.5",
|
||||
sm: "gap-2.5 px-3 py-2.5",
|
||||
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(itemVariants({ variant, size, className })),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "item",
|
||||
variant,
|
||||
size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
children,
|
||||
value,
|
||||
...props
|
||||
}: ProgressPrimitive.Root.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
data-slot="progress"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ProgressTrack>
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Track
|
||||
className={cn(
|
||||
"relative flex h-1 w-full items-center overflow-hidden rounded-full bg-muted",
|
||||
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-track"
|
||||
@@ -23,39 +43,12 @@ function ProgressIndicator({
|
||||
return (
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn("h-full rounded-full bg-primary transition-all", className)}
|
||||
className={cn("h-full bg-primary transition-all", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
children,
|
||||
value,
|
||||
...props
|
||||
}: ProgressPrimitive.Root.Props) {
|
||||
const hasCustomTrack = React.Children.toArray(children).some(
|
||||
(child) => React.isValidElement(child) && child.type === ProgressTrack,
|
||||
)
|
||||
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
data-slot="progress"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hasCustomTrack ? (
|
||||
<ProgressTrack>
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
) : null}
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Label
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
@@ -13,8 +15,7 @@ function Tabs({
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2",
|
||||
orientation === "horizontal" ? "flex-col" : "flex-row",
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -23,7 +24,7 @@ function Tabs({
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:h-auto data-[variant=line]:rounded-none data-[variant=line]:border-b data-[variant=line]:border-border data-[variant=line]:bg-transparent data-[variant=line]:p-0",
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -57,11 +58,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:h-auto group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:rounded-none group-data-[variant=line]/tabs-list:border-0 group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:px-0 group-data-[variant=line]/tabs-list:pb-3 group-data-[variant=line]/tabs-list:pt-1 group-data-[variant=line]/tabs-list:shadow-none",
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:group-data-[variant=line]/tabs-list:after:bottom-0 group-data-[orientation=horizontal]/tabs:group-data-[variant=line]/tabs-list:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100 group-data-[variant=line]/tabs-list:data-active:after:z-10",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user