Compare commits

...
6 Commits
Author SHA1 Message Date
Denozordec a3f3ffd672 refactor: integrate PanelCard and enhance dashboard components for improved layout
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m7s
Refactored multiple dashboard components to utilize the new PanelCard for better organization and presentation. Updated the DashboardFramePanel, DashboardQuickLinks, and DashboardOperationsBreakdown components to streamline layouts and enhance user experience. Removed deprecated components and improved loading states in various sections, ensuring a cohesive interface throughout the application.
2026-07-09 21:39:20 +07:00
Denozordec 1a142e68a9 refactor: update Dashboard components for improved layout and integration
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Successful in 59s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m20s
Refactored the DashboardQuickLinkCard to enhance its structure and presentation by integrating FramePanel. Updated the DashboardQuickLinks component to utilize Frame for better organization, including a header and description. Removed the previous DashboardFramePanel integration in the DashboardComponent to streamline the layout, ensuring a more cohesive user experience.
2026-07-09 21:15:04 +07:00
Denozordec 46e7b2aa6b chore: update BadgeTabs component to log additional layout metrics
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 1m0s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 22s
Added new layout metrics logging to the BadgeTabs component, enhancing debugging capabilities. This update includes additional timestamped entries for layout metrics, providing better insights into component behavior during runtime.
2026-07-09 21:06:33 +07:00
Denozordec 1152bb40f7 feat: enhance BadgeTabs and Auth components with layout improvements and API integration
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m7s
CI / go (push) Successful in 1m14s
CI / bird2 (push) Successful in 20s
CI / release (push) Successful in 4m52s
Added layout metrics logging to the BadgeTabs component for better debugging and analysis. Updated the Auth route to include a reason for redirection when a token is required, improving user feedback. Enhanced the Settings component to display an alert when API token access is needed, ensuring clarity for users. Additionally, refined the Tabs component to improve responsiveness based on orientation, contributing to a more cohesive user experience.
2026-07-09 20:54:35 +07:00
Denozordec 538daea0f1 refactor: update dashboard components for improved layout and functionality
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m7s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m21s
Refactored the DashboardQuickLinks component to utilize the new DashboardQuickLinkCard for better organization and presentation. Enhanced the DashboardComponent to integrate quick links within a DashboardFramePanel, improving the overall user experience. Updated the metric-tile component import path for consistency. Removed unnecessary imports from the separator component to streamline the codebase.
2026-07-09 18:49:55 +07:00
Denozordec 39f1295438 refactor: update configuration and enhance skeleton components for improved UI consistency
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m5s
CI / go (push) Successful in 1m5s
CI / bird2 (push) Successful in 19s
CI / release (push) Successful in 4m22s
Modified .npmrc to set a new store directory. Updated eslint configuration to ignore additional paths. Adjusted tsconfig to exclude specific components and refined the SectionCardsSkeleton and AnalyticsDashboardSkeleton for better layout and loading states. Removed the deprecated DashboardQuickActions component to streamline the codebase.
2026-07-09 18:23:53 +07:00
225 changed files with 25831 additions and 769 deletions
+16
View File
@@ -0,0 +1,16 @@
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101206}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101204}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138640}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138642}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605143758}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605144457}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605145907}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152669}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152671}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222064}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222067}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605819320}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605827145}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605864391}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606471768}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606472923}
+1 -1
View File
@@ -1 +1 @@
engine-strict=true
store-dir=C:\Users\shats\AppData\Local\pnpm-test-store\store\v10
+1 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals'
export default tseslint.config(
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
{ ignores: ['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
@@ -1,5 +1,4 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card'
import { readinessBreakdown } from '@/lib/metrics'
import type { ReadyStatus } from '@/queries/monitoring'
@@ -13,21 +12,25 @@ export function MonitoringHealthCard({
loading?: boolean
}) {
const slices = readinessBreakdown(ready, healthOk)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
if (loading) {
return (
<DonutBreakdownCard
title="Доступность системы"
description="Проверки живучести и готовности"
slices={[]}
centerLabel="Проверки"
/>
)
}
return (
<AnalyticsCardShell
<DonutBreakdownCard
title="Доступность системы"
description="Проверки живучести и готовности"
info="Диаграмма отражает результат GET /v1/health и проверок из GET /v1/ready."
>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Проверки" centerValue={total} />
)}
</AnalyticsCardShell>
description="GET /v1/health · GET /v1/ready"
slices={slices}
centerLabel="Проверки"
badge={healthOk ? 'API OK' : undefined}
/>
)
}
@@ -1,6 +1,5 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card'
import { SegmentedProgressCard } from '@/components/patterns/segmented-progress-card'
import { peerSessionBreakdown } from '@/lib/metrics'
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { PeerRow, SpeakerRow } from '@/types/api'
@@ -16,51 +15,44 @@ export function NetworkOverviewAnalyticsCard({
}) {
const net = aggregateNetworkMetrics(peers, speakers)
const slices = peerSessionBreakdown(peers)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
const speakersPct =
net.speakersTotal > 0 ? Math.round((net.speakersOnline / net.speakersTotal) * 100) : 0
if (loading) {
return (
<DonutBreakdownCard
title="Сводка BGP"
description="Установленные сессии и спикеры"
slices={[]}
centerLabel="Пиры"
/>
)
}
return (
<AnalyticsCardShell
title="Сводка BGP"
description="Установленные сессии, доступность спикеров и расхождения по live-данным"
info="Снимок текущего состояния пиров и спикеров."
>
<AnalyticsKpiRow
items={[
{
label: 'Пиры с установленной сессией',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
delta: {
direction: net.peersMismatch > 0 ? 'down' : 'up',
label: net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'сессии в норме',
tone: net.peersMismatch > 0 ? 'warning' : 'success',
},
},
{
label: 'Спикеры в сети',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
delta: {
direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up',
label:
net.speakersOnline < net.speakersTotal
? `${net.speakersTotal - net.speakersOnline} не в сети`
: 'все в сети',
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
},
},
{
label: 'Пиры всего',
value: loading ? '—' : String(net.peersTotal),
delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' },
},
]}
<div className="grid gap-4 lg:grid-cols-2">
<DonutBreakdownCard
title="Сводка BGP"
description="Распределение состояний пиров"
slices={slices}
centerLabel="Пиры"
badge={net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'в норме'}
/>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
)}
</AnalyticsCardShell>
<SegmentedProgressCard
title="Спикеры"
description="Доступность live-агентов"
primary={{
value: `${net.speakersOnline}/${net.speakersTotal}`,
label: 'Online',
percent: speakersPct,
}}
secondary={{
value: net.speakersTotal - net.speakersOnline,
label: 'Offline',
percent: 100 - speakersPct,
}}
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`}
/>
</div>
)
}
@@ -0,0 +1,220 @@
"use client"
import { useState } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbSeparator,
} from "@evobgp/ui/components/breadcrumb"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import { SidebarTrigger } from "@evobgp/ui/components/sidebar"
import { APPS, ENVIRONMENTS, WORKSPACES } from "./data"
import { Logo } from "./logo"
import { SystemStats } from "./system-stats"
import { ChevronsUpDownIcon, CheckIcon, PlusIcon } from "lucide-react"
// ── Org Switcher ──
function OrgSwitcher() {
const [activeOrg, setActiveOrg] = useState(WORKSPACES[0])
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="sm" className="text-foreground" />
}
>
<activeOrg.Logo className="size-3" />
<span className="sr-only sm:not-sr-only">{activeOrg.name}</span>
<ChevronsUpDownIcon className="size-3.5 opacity-60" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>Organizations</DropdownMenuLabel>
<DropdownMenuGroup>
{WORKSPACES.map((ws) => (
<DropdownMenuItem
key={ws.id}
onClick={() => setActiveOrg(ws)}
className={cn(activeOrg.id === ws.id && "bg-accent")}
>
<ws.Logo className="size-5 shrink-0" />
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm">{ws.name}</span>
<span className="text-muted-foreground truncate text-xs">
{ws.tier}
</span>
</div>
{activeOrg.id === ws.id && (
<CheckIcon className="text-primary ml-auto size-4" aria-hidden="true" />
)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PlusIcon aria-hidden="true" />
Create Organization
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
// ── App Switcher ──
function AppSwitcher() {
const [activeApp, setActiveApp] = useState(
APPS.find((a) => a.isActive) ?? APPS[0]
)
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="sm"
type="button"
className="text-foreground"
/>
}
>
<span className="hidden sm:inline">{activeApp.name}</span>
<span className="sm:hidden">{activeApp.name.split(" ")[0]}</span>
<ChevronsUpDownIcon className="size-3.5 opacity-60" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Applications</DropdownMenuLabel>
<DropdownMenuGroup>
{APPS.map((app) => (
<DropdownMenuItem
key={app.id}
onClick={() => setActiveApp(app)}
className={cn(activeApp.id === app.id && "bg-accent")}
>
{app.icon}
{app.name}
{activeApp.id === app.id && (
<CheckIcon className="text-primary ml-auto size-4" aria-hidden="true" />
)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PlusIcon aria-hidden="true" />
Create Application
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
// ── Environment Switcher ──
function EnvironmentSwitcher() {
const [activeEnv, setActiveEnv] = useState(
ENVIRONMENTS.find((e) => e.isActive) ?? ENVIRONMENTS[0]
)
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="sm"
type="button"
className="text-foreground"
/>
}
>
<span>{activeEnv.name}</span>
<ChevronsUpDownIcon className="size-3.5 opacity-60" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Environments</DropdownMenuLabel>
<DropdownMenuGroup>
{ENVIRONMENTS.map((env) => (
<DropdownMenuItem
key={env.id}
onClick={() => setActiveEnv(env)}
className={cn(activeEnv.id === env.id && "bg-accent")}
>
{env.icon}
{env.name}
{activeEnv.id === env.id && (
<CheckIcon className="text-primary ml-auto size-4" aria-hidden="true" />
)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
// ── Site Header ──
export function AppHeader() {
return (
<header className="bg-background sticky top-0 z-50 flex w-full items-center border-b">
<div className="flex h-(--header-height) w-full items-center gap-2 px-4">
{/* Mobile sidebar trigger */}
<SidebarTrigger className="md:hidden" />
{/* Logo */}
<div className="hidden items-center pl-0.5 md:flex">
<Logo />
</div>
<Breadcrumb>
<BreadcrumbList className="gap-1">
<BreadcrumbItem>
<OrgSwitcher />
</BreadcrumbItem>
<BreadcrumbSeparator className="text-xs opacity-60">
/
</BreadcrumbSeparator>
<BreadcrumbItem>
<AppSwitcher />
</BreadcrumbItem>
<BreadcrumbSeparator className="text-xs opacity-60">
/
</BreadcrumbSeparator>
<BreadcrumbItem>
<EnvironmentSwitcher />
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
{/* Right side */}
<div className="ml-auto flex items-center gap-1">
<SystemStats />
</div>
</div>
</header>
)
}
@@ -0,0 +1,43 @@
import { type CSSProperties } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import { SidebarInset, SidebarProvider } from "@evobgp/ui/components/sidebar"
import { AppHeader } from "./app-header"
import { AppSidebar } from "./app-sidebar"
export function AppShell() {
return (
<SidebarProvider
className={cn(
"flex flex-col",
"[--sidebar:var(--color-background)]",
"[--sidebar-accent:color-mix(in_oklab,var(--color-primary)_5%,transparent)]",
"[--sidebar-accent-foreground:var(--color-primary)]"
)}
style={
{
"--sidebar-width": "260px",
"--sidebar-width-icon": "62px",
"--header-height": "50px",
} as CSSProperties
}
>
{/* Header */}
<AppHeader />
<div className="flex flex-1">
<AppSidebar />
<SidebarInset>
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="border-border/40 bg-muted/40 aspect-video rounded-lg border border-dashed" />
<div className="border-border/40 bg-muted/40 aspect-video rounded-lg border border-dashed" />
<div className="border-border/40 bg-muted/40 aspect-video rounded-lg border border-dashed" />
</div>
<div className="border-border/40 bg-muted/40 min-h-[1000px] flex-1 rounded-lg border border-dashed" />
</div>
</SidebarInset>
</div>
</SidebarProvider>
)
}
@@ -0,0 +1,51 @@
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
useSidebar,
} from "@evobgp/ui/components/sidebar"
import { NEWS_ARTICLES } from "./data"
import { NavMain } from "./nav-main"
import { NavUser } from "./nav-user"
import { SearchForm } from "./search-form"
import { SidebarNews } from "./sidebar-news"
import { SidebarRailToggle } from "./sidebar-rail-toggle"
// ── App Sidebar ──
export function AppSidebar() {
const { isMobile } = useSidebar()
return (
<Sidebar
collapsible="icon"
className="top-(--header-height) h-[calc(100svh-var(--header-height))]!"
>
{/* Header */}
<SidebarHeader className="flex flex-row items-center px-4 in-data-[state=collapsed]:flex-col in-data-[state=collapsed]:items-start in-data-[state=collapsed]:justify-center">
<div className="w-full flex-1 pt-2">
<SearchForm />
</div>
</SidebarHeader>
{/* Sidebar */}
<SidebarContent className="gap-2 px-2">
<NavMain />
<div className="mt-auto">
<SidebarNews articles={NEWS_ARTICLES} />
</div>
</SidebarContent>
{/* Footer */}
{/* customize: mt-2 keeps a fixed gap above the footer so the news card never butts against it when the viewport shrinks and the content scrolls */}
<SidebarFooter className="mt-2 px-4">
<NavUser />
</SidebarFooter>
{!isMobile && <SidebarRailToggle />}
</Sidebar>
)
}
@@ -0,0 +1,385 @@
import { type ComponentType, type ReactNode } from "react"
import { GlobeIcon, ShieldIcon, SmartphoneIcon, RocketIcon, FlaskConicalIcon, CodeIcon, LayoutDashboardIcon, UsersIcon, Building2Icon, ShieldCheckIcon, WebhookIcon, KeyRoundIcon, CpuIcon, MonitorIcon, MemoryStickIcon, WifiIcon } from "lucide-react"
// ── Types ──
export type NavChild = {
id: string
label: string
isActive?: boolean
}
export type NavItem = {
id: string
label: string
icon: ReactNode
badge?: string | number
isActive?: boolean
children?: NavChild[]
}
export type Workspace = {
id: string
name: string
tier: string
Logo: ComponentType<{ className?: string }>
}
export type App = {
id: string
name: string
icon: ReactNode
isActive?: boolean
}
export type Environment = {
id: string
name: string
icon: ReactNode
isActive?: boolean
}
export type NewsArticle = {
href: string
title: string
summary: string
image: string
}
export type ResourceMetric = {
id: string
label: string
unit: string
icon: ReactNode
seedRange: [number, number]
color: string
spikeThreshold: number
}
export type Agent = {
id: string
name: string
memoryMb: number
color: string
}
// ── Logos ──
// Acme: indigo → violet → fuchsia diagonal sweep
function AcmeLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 28 28"
fill="none"
className={className}
aria-hidden="true"
>
<defs>
<linearGradient
id="ws-acme"
x1="0"
y1="0"
x2="28"
y2="28"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="#4f46e5" />
<stop offset="35%" stopColor="#7c3aed" />
<stop offset="70%" stopColor="#a21caf" />
<stop offset="100%" stopColor="#db2777" />
</linearGradient>
</defs>
<circle cx="14" cy="14" r="14" fill="url(#ws-acme)" />
</svg>
)
}
// Starter: rose → orange → amber → lime horizontal sweep
function StarterLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 28 28"
fill="none"
className={className}
aria-hidden="true"
>
<defs>
<linearGradient
id="ws-starter"
x1="0"
y1="0"
x2="28"
y2="28"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="#f43f5e" />
<stop offset="33%" stopColor="#f97316" />
<stop offset="66%" stopColor="#eab308" />
<stop offset="100%" stopColor="#84cc16" />
</linearGradient>
</defs>
<circle cx="14" cy="14" r="14" fill="url(#ws-starter)" />
</svg>
)
}
// Enterprise: sky → blue → indigo vertical sweep
function EnterpriseLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 28 28"
fill="none"
className={className}
aria-hidden="true"
>
<defs>
<linearGradient
id="ws-enterprise"
x1="0"
y1="0"
x2="28"
y2="28"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="#38bdf8" />
<stop offset="35%" stopColor="#3b82f6" />
<stop offset="70%" stopColor="#6366f1" />
<stop offset="100%" stopColor="#14b8a6" />
</linearGradient>
</defs>
<circle cx="14" cy="14" r="14" fill="url(#ws-enterprise)" />
</svg>
)
}
// ── Data ──
export const USER = {
name: "Nick Bold",
email: "[email protected]",
initials: "NB",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
} as const
export const WORKSPACES: Workspace[] = [
{ id: "acme", name: "Acme Inc", tier: "Pro", Logo: AcmeLogo },
{ id: "starter", name: "Starter Kit", tier: "Free", Logo: StarterLogo },
{
id: "enterprise",
name: "Enterprise",
tier: "Enterprise",
Logo: EnterpriseLogo,
},
]
export const APPS: App[] = [
{
id: "web-app",
name: "Web App",
isActive: true,
icon: (
<GlobeIcon className="size-4" aria-hidden="true" />
),
},
{
id: "admin",
name: "Admin Panel",
icon: (
<ShieldIcon className="size-4" aria-hidden="true" />
),
},
{
id: "mobile",
name: "Mobile App",
icon: (
<SmartphoneIcon className="size-4" aria-hidden="true" />
),
},
]
export const ENVIRONMENTS: Environment[] = [
{
id: "production",
name: "Production",
isActive: true,
icon: (
<RocketIcon className="size-4" aria-hidden="true" />
),
},
{
id: "staging",
name: "Staging",
icon: (
<FlaskConicalIcon className="size-4" aria-hidden="true" />
),
},
{
id: "development",
name: "Development",
icon: (
<CodeIcon className="size-4" aria-hidden="true" />
),
},
]
export const NAV_MAIN: NavItem[] = [
{
id: "overview",
label: "Overview",
isActive: true,
icon: (
<LayoutDashboardIcon aria-hidden="true" />
),
},
{
id: "users",
label: "Users",
icon: (
<UsersIcon aria-hidden="true" />
),
children: [
{ id: "all-users", label: "All Users" },
{ id: "invitations", label: "Invitations" },
{ id: "roles", label: "Roles", isActive: true },
],
},
{
id: "organizations",
label: "Organizations",
icon: (
<Building2Icon aria-hidden="true" />
),
children: [
{ id: "org-list", label: "All Organizations" },
{ id: "org-settings", label: "Settings" },
],
},
{
id: "security",
label: "Security",
icon: (
<ShieldCheckIcon aria-hidden="true" />
),
children: [
{ id: "attack-protection", label: "Attack Protection" },
{ id: "fraud", label: "Fraud Detection" },
{ id: "audit-log", label: "Audit Log" },
],
},
{
id: "webhooks",
label: "Webhooks",
icon: (
<WebhookIcon aria-hidden="true" />
),
},
{
id: "api-keys",
label: "API Keys",
icon: (
<KeyRoundIcon aria-hidden="true" />
),
},
]
export const RESOURCE_METRICS: ResourceMetric[] = [
{
id: "cpu",
label: "CPU",
unit: "%",
icon: (
<CpuIcon className="size-3 shrink-0 transition-colors duration-300" aria-hidden="true" />
),
seedRange: [20, 65],
color: "var(--color-blue-500)",
spikeThreshold: 60,
},
{
id: "gpu",
label: "GPU",
unit: "%",
icon: (
<MonitorIcon className="size-3 shrink-0 transition-colors duration-300" aria-hidden="true" />
),
seedRange: [10, 50],
color: "var(--color-emerald-500)",
spikeThreshold: 45,
},
{
id: "memory",
label: "Memory",
unit: "GB",
icon: (
<MemoryStickIcon className="size-3 shrink-0 transition-colors duration-300" aria-hidden="true" />
),
seedRange: [4, 12],
color: "var(--color-amber-500)",
spikeThreshold: 12,
},
{
id: "network",
label: "Network",
unit: "Mbps",
icon: (
<WifiIcon className="size-3 shrink-0 transition-colors duration-300" aria-hidden="true" />
),
seedRange: [5, 80],
color: "var(--color-violet-500)",
spikeThreshold: 70,
},
]
export const AGENTS: Agent[] = [
{
id: "ingest",
name: "Ingest Worker",
memoryMb: 256,
color: "var(--color-indigo-500)",
},
{
id: "transform",
name: "Transform Pipeline",
memoryMb: 512,
color: "var(--color-purple-500)",
},
{
id: "query",
name: "Query Engine",
memoryMb: 384,
color: "var(--color-sky-500)",
},
{
id: "export",
name: "Export Service",
memoryMb: 128,
color: "var(--color-teal-500)",
},
]
export const NEWS_ARTICLES: NewsArticle[] = [
{
href: "https://pro.reui.io/changelog?v=2.0.3",
title: "Multi-theme support is here",
summary:
"Switch between Vega, Nova, Maia, Lyra, and Mira themes across all components.",
image:
"https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=400&h=225&fit=crop&q=80",
},
{
href: "https://pro.reui.io/changelog?v=2.0.2",
title: "Icon library v2 released",
summary:
"Five icon libraries unified under a single API with automatic mapping.",
image:
"https://images.unsplash.com/photo-1579546929518-9e396f3cc809?w=400&h=225&fit=crop&q=80",
},
{
href: "https://pro.reui.io/changelog?v=2.0.1",
title: "Sidebar layout patterns guide",
summary:
"Learn best practices for building responsive sidebar layouts with collapsible navigation.",
image:
"https://images.unsplash.com/photo-1541701494587-cb58502866ab?w=400&h=225&fit=crop&q=80",
},
]
@@ -0,0 +1,29 @@
import { Item, ItemMedia } from "@evobgp/ui/components/item"
export function Logo() {
return (
<Item
className="bg-primary text-primary-foreground flex size-7 shrink-0 items-center justify-center p-0"
aria-hidden="true"
>
<ItemMedia variant="icon" className="size-auto">
<svg
width="50"
height="50"
viewBox="25.668 25.1352 49.6644 50"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-3.5"
>
<circle cx="70.634" cy="29.8334" r="4.69799" fill="currentColor" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M25.668 57.0144V29.8332C25.668 27.2386 27.7713 25.1352 30.366 25.1352C32.9606 25.1352 35.0639 27.2386 35.0639 29.8332V57.0144C35.0639 61.833 38.9702 65.7392 43.7888 65.7392H57.2116C62.0302 65.7392 65.9364 61.833 65.9364 57.0144V43.7258C65.9364 41.1312 68.0398 39.0278 70.6344 39.0278C73.229 39.0278 75.3324 41.1312 75.3324 43.7258V57.0144C75.3324 67.0222 67.2194 75.1352 57.2116 75.1352H43.7888C33.7809 75.1352 25.668 67.0222 25.668 57.0144Z"
fill="currentColor"
/>
</svg>
</ItemMedia>
</Item>
)
}
@@ -0,0 +1,122 @@
"use client"
import { useState } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@evobgp/ui/components/sidebar"
import { NAV_MAIN, type NavChild, type NavItem } from "./data"
import { ChevronRightIcon } from "lucide-react"
// ── Nav Sub Items ──
function NavSubItem({ child }: { child: NavChild }) {
return (
<SidebarMenuSubItem>
{/* Sidebar */}
<SidebarMenuSubButton render={<a href="#" />} isActive={child.isActive}>
{child.label}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
}
function NavSubMenu({ id, items }: { id: string; items: NavChild[] }) {
return (
<SidebarMenuSub id={`subnav-${id}`}>
{items.map((child) => (
<NavSubItem key={child.id} child={child} />
))}
</SidebarMenuSub>
)
}
// ── Collapsible Nav Item ──
export function CollapsibleNavItem({
item,
}: {
item: NavItem & { children: NavChild[] }
}) {
const [open, setOpen] = useState(() => item.children.some((c) => c.isActive))
return (
<SidebarMenuItem>
{/* Sidebar */}
<SidebarMenuButton
tooltip={item.label}
isActive={item.isActive}
onClick={() => setOpen((prev) => !prev)}
aria-expanded={open}
aria-controls={`subnav-${item.id}`}
>
{item.icon}
<span>{item.label}</span>
<ChevronRightIcon className={cn(
"ml-auto size-4 shrink-0 opacity-60 transition-transform duration-200 group-data-[collapsible=icon]:hidden",
open && "rotate-90"
)} aria-hidden="true" />
</SidebarMenuButton>
{open && <NavSubMenu id={item.id} items={item.children} />}
</SidebarMenuItem>
)
}
// ── Leaf Nav Item ──
export function LeafNavItem({ item }: { item: NavItem }) {
return (
<SidebarMenuItem>
{/* Sidebar */}
<SidebarMenuButton
tooltip={item.label}
isActive={item.isActive}
render={<a href="#" />}
>
{item.icon}
<span>{item.label}</span>
{item.badge !== undefined && (
<SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
)}
</SidebarMenuButton>
</SidebarMenuItem>
)
}
// ── Nav Main ──
export function NavMain() {
return (
<SidebarGroup>
{/* Sidebar */}
<SidebarGroupLabel className="in-data-[state=collapsed]:hidden">
Platform
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="gap-0.25">
{NAV_MAIN.map((item) =>
item.children ? (
<CollapsibleNavItem
key={item.id}
item={item as NavItem & { children: NavChild[] }}
/>
) : (
<LeafNavItem key={item.id} item={item} />
)
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
@@ -0,0 +1,202 @@
import { useEffect, useState } from "react"
import { useTheme } from "next-themes"
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,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@evobgp/ui/components/sidebar"
import { USER } from "./data"
import { SunIcon, MoonIcon, MonitorIcon, ChevronsUpDownIcon, UserIcon, SettingsIcon, InboxIcon, PaletteIcon, LogOutIcon } from "lucide-react"
// ── Theme Toggle ──
const THEMES = [
{
value: "light",
label: "Light",
icon: (
<SunIcon className="size-3.5" aria-hidden="true" />
),
},
{
value: "dark",
label: "Dark",
icon: (
<MoonIcon className="size-3.5" aria-hidden="true" />
),
},
{
value: "system",
label: "System",
icon: (
<MonitorIcon className="size-3.5" aria-hidden="true" />
),
},
]
function ThemeSegmentedToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const currentTheme = mounted ? (theme ?? "system") : "system"
return (
<div
role="radiogroup"
aria-label="Theme"
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
>
{THEMES.map(({ value, label, icon }) => {
const isActive = currentTheme === value
return (
<Button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={label}
variant="ghost"
size="icon-xs"
onClick={() => setTheme(value)}
className={cn(
"rounded-full",
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{icon}
</Button>
)
})}
</div>
)
}
// ── Nav User ──
export function NavUser() {
const { isMobile } = useSidebar()
return (
<SidebarMenu>
{/* Sidebar */}
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:justify-center"
/>
}
>
<Avatar className="size-7 rounded-md">
<AvatarImage src={USER.avatar} alt={USER.name} />
<AvatarFallback className="bg-primary text-primary-foreground rounded-md text-xs font-semibold">
{USER.initials}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-medium">{USER.name}</span>
<span className="text-muted-foreground truncate text-xs">
{USER.email}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4 opacity-50 group-data-[collapsible=icon]:hidden" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
className="w-56"
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2.5 py-2">
<Avatar className="size-8 rounded-md">
<AvatarImage src={USER.avatar} alt={USER.name} />
<AvatarFallback className="bg-primary text-primary-foreground rounded-md text-xs font-semibold">
{USER.initials}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="text-foreground text-sm font-semibold">
{USER.name}
</span>
<span className="text-muted-foreground truncate text-xs font-normal">
{USER.email}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<UserIcon aria-hidden="true" />
Profile
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<SettingsIcon aria-hidden="true" />
Preferences
</DropdownMenuItem>
<DropdownMenuItem>
<InboxIcon aria-hidden="true" />
Manage Accounts
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
className="cursor-default focus:bg-transparent!"
onSelect={(e) => e.preventDefault()}
>
<PaletteIcon aria-hidden="true" />
Theme
<div className="ml-auto">
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOutIcon aria-hidden="true" />
Sign Out
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -0,0 +1,78 @@
"use client"
import { useEffect, useId, useState, type ComponentProps } from "react"
import { Button } from "@evobgp/ui/components/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@evobgp/ui/components/dialog"
import { Input } from "@evobgp/ui/components/input"
import { Kbd } from "@evobgp/ui/components/kbd"
import {
SidebarGroup,
SidebarGroupContent,
} from "@evobgp/ui/components/sidebar"
import { SearchIcon } from "lucide-react"
export function SearchForm({ ...props }: ComponentProps<"form">) {
const [open, setOpen] = useState(false)
const searchInputId = useId()
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setOpen(true)
}
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [])
return (
<form {...props}>
{/* Sidebar */}
<SidebarGroup className="p-0">
<SidebarGroupContent className="relative">
<Button
id="search"
type="button"
variant="outline"
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
onClick={() => setOpen(true)}
>
Search...
</Button>
<SearchIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none" />
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
K
</Kbd>
</SidebarGroupContent>
</SidebarGroup>
<Dialog open={open} onOpenChange={setOpen}>
<DialogHeader className="sr-only">
<DialogTitle>Search</DialogTitle>
<DialogDescription>Search your workspace content.</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-md px-4 py-2 **:data-[slot=dialog-close]:top-1/2 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:-translate-y-1/2 **:data-[slot=dialog-close]:opacity-60">
<div className="relative flex items-center gap-3">
<SearchIcon aria-hidden="true" className="pointer-events-none size-4 opacity-60 select-none" />
<Input
id={searchInputId}
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
autoFocus
placeholder="Type to search..."
aria-label="Search"
/>
</div>
</DialogContent>
</Dialog>
</form>
)
}
@@ -0,0 +1,281 @@
import {
useEffect,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
} from "react"
import { cn } from "@evobgp/ui/lib/utils"
import { type NewsArticle } from "./data"
// ── Constants ──
const OFFSET_FACTOR = 4
const SCALE_FACTOR = 0.03
const OPACITY_FACTOR = 0.1
const COMPLETED_DISPLAY_MS = 2700
// ── News Card ──
function NewsCard({
title,
summary,
image,
onDismiss,
hideContent,
href,
active,
}: {
title: string
summary: string
image?: string
onDismiss?: () => void
hideContent?: boolean
href?: string
active?: boolean
}) {
const ref = useRef<HTMLDivElement>(null)
const drag = useRef<{
start: number
delta: number
startTime: number
maxDelta: number
}>({ start: 0, delta: 0, startTime: 0, maxDelta: 0 })
const animation = useRef<Animation>(undefined)
const [dragging, setDragging] = useState(false)
const onDragMove = (e: globalThis.PointerEvent) => {
if (!ref.current) return
const dx = e.clientX - drag.current.start
drag.current.delta = dx
drag.current.maxDelta = Math.max(drag.current.maxDelta, Math.abs(dx))
ref.current.style.setProperty("--dx", dx.toString())
}
const dismiss = () => {
if (!ref.current) return
const cardWidth = ref.current.getBoundingClientRect().width
const translateX = Math.sign(drag.current.delta) * cardWidth
animation.current = ref.current.animate(
{ opacity: 0, transform: `translateX(${translateX}px)` },
{ duration: 150, easing: "ease-in-out", fill: "forwards" }
)
animation.current.onfinish = () => onDismiss?.()
}
const stopDragging = (cancelled: boolean) => {
unbindListeners()
if (!ref.current) return
setDragging(false)
const dx = drag.current.delta
if (Math.abs(dx) > ref.current.clientWidth / (cancelled ? 2 : 3)) {
dismiss()
return
}
animation.current = ref.current.animate(
{ transform: "translateX(0)" },
{ duration: 150, easing: "ease-in-out" }
)
animation.current.onfinish = () =>
ref.current?.style.setProperty("--dx", "0")
drag.current = { start: 0, delta: 0, startTime: 0, maxDelta: 0 }
}
const onDragEnd = () => stopDragging(false)
const onDragCancel = () => stopDragging(true)
const onPointerDown = (e: ReactPointerEvent) => {
if (!active || !ref.current || animation.current?.playState === "running")
return
bindListeners()
setDragging(true)
drag.current.start = e.clientX
drag.current.startTime = Date.now()
drag.current.delta = 0
ref.current.style.setProperty("--w", ref.current.clientWidth.toString())
}
const onClick = () => {
if (!ref.current || !href) return
if (
drag.current.maxDelta < ref.current.clientWidth / 10 &&
(!drag.current.startTime || Date.now() - drag.current.startTime < 250)
) {
window.open(href, "_blank", "noopener,noreferrer")
}
}
const bindListeners = () => {
document.addEventListener("pointermove", onDragMove)
document.addEventListener("pointerup", onDragEnd)
document.addEventListener("pointercancel", onDragCancel)
}
const unbindListeners = () => {
document.removeEventListener("pointermove", onDragMove)
document.removeEventListener("pointerup", onDragEnd)
document.removeEventListener("pointercancel", onDragCancel)
}
return (
<div
ref={ref}
className={cn(
"border-border bg-card relative cursor-grab border p-3 text-[0.8125rem] shadow-xs select-none",
"rounded-lg",
"translate-x-[calc(var(--dx)*1px)] rotate-[calc(var(--dx)*0.05deg)] opacity-[calc(1-max(var(--dx),-1*var(--dx))/var(--w)/2)]",
"transition-shadow data-[dragging=true]:cursor-grabbing data-[dragging=true]:shadow-md"
)}
style={{ "--dx": 0, "--w": 1 } as CSSProperties}
data-dragging={dragging}
onPointerDown={onPointerDown}
onClick={onClick}
>
<div className={cn(hideContent && "invisible")}>
<div className="flex flex-col gap-1">
<span className="text-foreground line-clamp-1 font-medium">
{title}
</span>
<p className="text-muted-foreground line-clamp-2 h-10 leading-5">
{summary}
</p>
</div>
<div
className={cn(
"border-border/40 bg-muted relative mt-3 aspect-video w-full shrink-0 overflow-hidden border",
"rounded-md"
)}
>
{image && (
<img
src={image}
alt=""
className="size-full object-cover object-center"
draggable={false}
/>
)}
</div>
<div
className={cn(
"grid grid-rows-[0fr] overflow-hidden opacity-0 transition-[grid-template-rows,opacity] duration-200",
"sm:group-has-[*[data-dragging=true]]:grid-rows-[1fr] sm:group-has-[*[data-dragging=true]]:opacity-100",
"sm:group-hover:group-data-[active=true]:grid-rows-[1fr] sm:group-hover:group-data-[active=true]:opacity-100",
"has-focus-visible:grid-rows-[1fr] has-focus-visible:opacity-100"
)}
>
<div className="min-h-0">
<div className="flex items-center justify-between pt-3 text-xs">
<a
href={href ?? "#"}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-muted-foreground hover:text-foreground font-medium transition-colors duration-75"
>
Read more
</a>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
dismiss()
}}
className="text-muted-foreground hover:text-foreground transition-colors duration-75"
>
Dismiss
</button>
</div>
</div>
</div>
</div>
</div>
)
}
// ── Sidebar News ──
export function SidebarNews({ articles }: { articles: NewsArticle[] }) {
const [dismissedIds, setDismissedIds] = useState<string[]>([])
const cards = articles.filter(({ href }) => !dismissedIds.includes(href))
const cardCount = cards.length
const [showCompleted, setShowCompleted] = useState(cardCount > 0)
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | undefined
if (cardCount === 0) {
timeout = setTimeout(() => setShowCompleted(false), COMPLETED_DISPLAY_MS)
}
return () => clearTimeout(timeout)
}, [cardCount])
if (!cards.length && !showCompleted) return null
return (
<div
className="group px-3 in-data-[state=collapsed]:hidden"
data-active={cardCount !== 0}
>
<div className="relative size-full">
{cards.toReversed().map(({ href, title, summary, image }, idx) => (
<div
key={idx}
className={cn(
"absolute top-0 left-0 size-full scale-(--scale) transition-[opacity,transform] duration-200",
cardCount - idx > 3
? [
"opacity-0 sm:group-hover:translate-y-(--y) sm:group-hover:opacity-(--opacity)",
"sm:group-has-[*[data-dragging=true]]:translate-y-(--y) sm:group-has-[*[data-dragging=true]]:opacity-(--opacity)",
]
: "translate-y-(--y) opacity-(--opacity)"
)}
style={
{
"--y": `-${(cardCount - (idx + 1)) * OFFSET_FACTOR}%`,
"--scale": 1 - (cardCount - (idx + 1)) * SCALE_FACTOR,
"--opacity":
cardCount - (idx + 1) >= 6
? 0
: 1 - (cardCount - (idx + 1)) * OPACITY_FACTOR,
} as CSSProperties
}
aria-hidden={idx !== cardCount - 1}
{...(idx !== cardCount - 1 ? { inert: true } : {})}
>
<NewsCard
title={title}
summary={summary}
image={image}
href={href}
hideContent={cardCount - idx > 2}
active={idx === cardCount - 1}
onDismiss={() =>
setDismissedIds([href, ...dismissedIds.slice(0, 50)])
}
/>
</div>
))}
{/* Invisible spacer to hold layout height */}
<div className="pointer-events-none invisible" aria-hidden="true">
<NewsCard title="Title" summary="Description" />
</div>
{/* All-caught-up state */}
{showCompleted && !cardCount && (
<div className="absolute inset-0 flex size-full flex-col items-center justify-end gap-3 pb-4">
<span className="text-muted-foreground text-xs font-medium">
You&apos;re all caught up!
</span>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,61 @@
"use client"
import { cn } from "@evobgp/ui/lib/utils"
import { useSidebar } from "@evobgp/ui/components/sidebar"
export function SidebarRailToggle() {
const { state, toggleSidebar } = useSidebar()
const isExpanded = state === "expanded"
return (
<button
type="button"
aria-label={isExpanded ? "Collapse sidebar" : "Expand sidebar"}
onClick={toggleSidebar}
style={{
left: isExpanded ? "var(--sidebar-width)" : "var(--sidebar-width-icon)",
}}
className={cn(
"group/rail fixed z-30 flex h-12 w-7 cursor-pointer items-center pl-2 outline-none",
"top-[calc(var(--header-height)+50%)] -translate-y-1/2",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-1",
"focus-visible:rounded-sm",
"transition-[left] duration-200 ease-linear"
)}
>
<span className="flex flex-col items-center">
<span
aria-hidden="true"
className={cn(
"bg-foreground/40 block h-2 w-0.5 rounded-t-full",
"origin-bottom transition-all duration-100 ease-linear",
isExpanded
? "group-hover/rail:bg-foreground/60 group-hover/rail:rotate-40"
: "group-hover/rail:bg-foreground/60 group-hover/rail:-rotate-40"
)}
/>
<span
aria-hidden="true"
className={cn(
"bg-foreground/40 block h-2 w-0.5 rounded-b-full",
"origin-top transition-all duration-100 ease-linear",
isExpanded
? "group-hover/rail:bg-foreground/60 group-hover/rail:-rotate-40"
: "group-hover/rail:bg-foreground/60 group-hover/rail:rotate-40"
)}
/>
</span>
<span
className={cn(
"border-border bg-foreground text-background absolute left-full -ml-2 border px-2 py-0.5 text-[11px] font-medium whitespace-nowrap shadow-xs shadow-black/5",
"rounded-md",
"pointer-events-none -translate-x-0.5 opacity-0 transition-all duration-200 ease-out",
"group-hover/rail:translate-x-0 group-hover/rail:opacity-100"
)}
>
{isExpanded ? "Collapse" : "Expand"}
</span>
</button>
)
}
@@ -0,0 +1,441 @@
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type CSSProperties,
} from "react"
import { cn } from "@evobgp/ui/lib/utils"
import { Badge } from "@evobgp/ui/components/badge"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { Progress } from "@evobgp/ui/components/progress"
import {
AGENTS,
RESOURCE_METRICS,
type Agent,
type ResourceMetric,
} from "./data"
import { ActivityIcon, ChevronRightIcon } from "lucide-react"
const HISTORY_LENGTH = 20
const UPDATE_INTERVAL_MS = 1000
const SPIKE_COLOR = "var(--color-red-500)"
const STATS_RANDOM_SEED = 1337
const DEMO_REFERENCE_DATE = new Date("2026-06-12T09:24:00")
const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
// Seeded PRNG (mulberry32) keeps the demo metrics deterministic across
// renders and reloads while preserving the organic feel of live jitter.
function createSeededRandom(seed: number) {
let state = seed
return function nextRandom() {
state = (state + 0x6d2b79f5) | 0
let t = Math.imul(state ^ (state >>> 15), state | 1)
t = (t + Math.imul(t ^ (t >>> 7), t | 61)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const nextRandom = createSeededRandom(STATS_RANDOM_SEED)
function randomInRange(min: number, max: number) {
return Math.round((min + nextRandom() * (max - min)) * 10) / 10
}
function seedHistory(min: number, max: number): number[] {
return Array.from({ length: HISTORY_LENGTH }, () => randomInRange(min, max))
}
function smoothPath(pts: { x: number; y: number }[]): string {
if (pts.length === 0) return ""
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`
let d = `M ${pts[0].x} ${pts[0].y}`
for (let i = 1; i < pts.length; i++) {
const prev = pts[i - 1]
const curr = pts[i]
const cx = (prev.x + curr.x) / 2
d += ` C ${cx} ${prev.y}, ${cx} ${curr.y}, ${curr.x} ${curr.y}`
}
return d
}
// ── Sparkline Area (full-width responsive) ──
function SparklineArea({
data,
max,
color,
spikeThreshold,
height = 36,
}: {
data: number[]
max: number
color: string
spikeThreshold: number
height?: number
}) {
const gradientId = useId()
const W = 100
const H = height
const currentValue = data[data.length - 1] ?? 0
const hasSpike = currentValue > spikeThreshold
const activeColor = hasSpike ? SPIKE_COLOR : color
const pts = data.map((value, i) => ({
x: data.length < 2 ? 0 : (i / (data.length - 1)) * W,
y: H - (Math.min(value, max) / max) * (H - 2),
}))
const linePath = smoothPath(pts)
const areaPath = pts.length > 1 ? `${linePath} L ${W} ${H} L 0 ${H} Z` : ""
return (
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
aria-hidden="true"
className="w-full"
style={{ height: `${H}px` }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={activeColor} stopOpacity={0.35} />
<stop offset="100%" stopColor={activeColor} stopOpacity={0.02} />
</linearGradient>
</defs>
{areaPath && <path d={areaPath} fill={`url(#${gradientId})`} />}
<path
d={linePath}
fill="none"
stroke={activeColor}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
// ── Mini Sparkline (for agent rows) ──
function MiniSparkline({ data, color }: { data: number[]; color: string }) {
const gradientId = useId()
const W = 40
const H = 14
const dataMax = Math.max(...data, 1)
const pts = data.map((value, i) => ({
x: data.length < 2 ? 0 : (i / (data.length - 1)) * W,
y: H - (value / dataMax) * (H - 1),
}))
const linePath = smoothPath(pts)
const areaPath = pts.length > 1 ? `${linePath} L ${W} ${H} L 0 ${H} Z` : ""
return (
<svg
viewBox={`0 0 ${W} ${H}`}
width={W}
height={H}
aria-hidden="true"
className="shrink-0"
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
{areaPath && <path d={areaPath} fill={`url(#${gradientId})`} />}
<path
d={linePath}
fill="none"
stroke={color}
strokeWidth={1}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
// ── Resource Card (grid cell) ──
function ResourceCard({
metric,
history,
}: {
metric: ResourceMetric
history: number[]
}) {
const current = history[history.length - 1] ?? 0
const max = metric.id === "memory" ? 16 : 100
const isHigh = current > metric.spikeThreshold
return (
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<Item
className="flex size-5 shrink-0 items-center justify-center p-0 transition-colors duration-300"
style={{
backgroundColor: `${isHigh ? SPIKE_COLOR : metric.color}18`,
}}
>
<ItemMedia
variant="icon"
className="size-auto"
style={{ color: isHigh ? SPIKE_COLOR : metric.color }}
>
{metric.icon}
</ItemMedia>
</Item>
<span className="text-muted-foreground truncate text-[11px]">
{metric.label}
</span>
</div>
<span
className="shrink-0 text-xs font-semibold tabular-nums transition-colors duration-300"
style={{ color: isHigh ? SPIKE_COLOR : metric.color }}
>
{current}
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">
{metric.unit}
</span>
</span>
</div>
<SparklineArea
data={history}
max={max}
color={metric.color}
spikeThreshold={metric.spikeThreshold}
/>
</div>
)
}
// ── Agent Memory Row ──
type AgentWithHistory = Agent & { history: number[] }
function AgentMemoryRow({ agent }: { agent: AgentWithHistory }) {
const barPercent = Math.min(100, (agent.memoryMb / 512) * 100)
return (
<div className="flex items-center gap-2 px-3 py-1.5">
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: agent.color }}
aria-hidden="true"
/>
<span className="text-muted-foreground min-w-0 flex-1 truncate text-xs">
{agent.name}
</span>
<Progress
value={barPercent}
className="w-12 **:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
style={{ "--bar-color": agent.color } as CSSProperties}
/>
<span className="text-muted-foreground w-10 text-right text-[10px] tabular-nums">
{agent.memoryMb}MB
</span>
</div>
)
}
// ── Status Badge ──
function StatusBadge({ spiking }: { spiking: boolean }) {
return (
<Badge
className={cn(
"h-4 border-0 px-1.5 text-[10px] font-medium",
spiking
? "bg-destructive/10 text-destructive dark:bg-destructive/20"
: "bg-success/10 text-success dark:bg-success/20"
)}
>
{spiking ? "Alert" : "Normal"}
</Badge>
)
}
// ── System Stats ──
export function SystemStats() {
const [histories, setHistories] = useState<Record<string, number[]>>(() => {
const initial: Record<string, number[]> = {}
for (const m of RESOURCE_METRICS) {
initial[m.id] = seedHistory(m.seedRange[0], m.seedRange[1])
}
return initial
})
const [agentData, setAgentData] = useState<AgentWithHistory[]>(() =>
AGENTS.map((a) => ({
...a,
history: Array.from({ length: 10 }, () =>
Math.max(32, Math.round(a.memoryMb + (nextRandom() - 0.5) * 80))
),
}))
)
const [showAgents, setShowAgents] = useState(true)
const [now, setNow] = useState(DEMO_REFERENCE_DATE)
const intervalRef = useRef<ReturnType<typeof setInterval>>(null)
// Derived - no extra state needed
const spiking = RESOURCE_METRICS.some((m) => {
const history = histories[m.id] ?? []
const current = history[history.length - 1] ?? 0
return current > m.spikeThreshold
})
const tick = useCallback(() => {
setHistories((prev) => {
const next: Record<string, number[]> = {}
for (const m of RESOURCE_METRICS) {
const old = prev[m.id] ?? []
const jitter = randomInRange(
m.seedRange[0] * 0.8,
m.seedRange[1] * 1.15
)
const clamped =
m.id === "memory"
? Math.min(16, Math.max(0, jitter))
: Math.min(100, Math.max(0, jitter))
next[m.id] = [...old.slice(-(HISTORY_LENGTH - 1)), clamped]
}
return next
})
setAgentData((prev) =>
prev.map((a) => {
const newMb = Math.max(
32,
Math.round(a.memoryMb + (nextRandom() - 0.5) * 20)
)
return {
...a,
memoryMb: newMb,
history: [...a.history.slice(-9), newMb],
}
})
)
setNow((prev) => new Date(prev.getTime() + UPDATE_INTERVAL_MS))
}, [])
useEffect(() => {
intervalRef.current = setInterval(tick, UPDATE_INTERVAL_MS)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [tick])
return (
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label="System monitor"
className={cn(
"relative inline-flex h-7 items-center gap-1.5 px-2 transition-colors outline-none",
"rounded-md",
"border-border hover:bg-accent focus-visible:ring-ring border focus-visible:ring-2"
)}
/>
}
>
{/* Icon with beep ring on spike */}
<span className="relative flex size-3.5 items-center justify-center">
<ActivityIcon className={cn(
"size-3.5 transition-colors duration-300",
spiking ? "text-destructive" : "text-muted-foreground"
)} aria-hidden="true" />
{spiking && (
<span
className="bg-destructive/25 absolute inset-0 animate-ping rounded-full"
aria-hidden="true"
/>
)}
</span>
<span className="text-foreground text-xs font-medium">System</span>
<StatusBadge spiking={spiking} />
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={8}
className="w-80 gap-0! space-y-0! p-0!"
>
{/* Header */}
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">
System Monitor
</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{TIME_FORMATTER.format(now)}
</span>
</div>
{/* 2×2 Resource Grid */}
<div className="grid grid-cols-2">
{RESOURCE_METRICS.map((metric, i) => (
<div
key={metric.id}
className={cn(
i % 2 === 1 && "border-border border-l",
i >= 2 && "border-border border-t"
)}
>
<ResourceCard
metric={metric}
history={histories[metric.id] ?? []}
/>
</div>
))}
</div>
{/* Per-Agent Memory */}
<div className="border-border border-t">
<button
type="button"
onClick={() => setShowAgents((prev) => !prev)}
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors"
aria-expanded={showAgents}
aria-controls="agent-memory-panel"
>
<ChevronRightIcon className={cn(
"size-3 shrink-0 transition-transform duration-200",
showAgents && "rotate-90"
)} aria-hidden="true" />
Per-Agent Memory
</button>
{showAgents && (
<div id="agent-memory-panel" className="pb-2">
{agentData.map((agent) => (
<AgentMemoryRow key={agent.id} agent={agent} />
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,5 @@
import { AppShell } from "./components/app-shell"
export function Page() {
return <AppShell />
}
@@ -0,0 +1,99 @@
"use client"
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 auth-3's WaveDots backdrop with the twinkle
* animation removed: a dense grid of dots at a fixed per-dot brightness,
* painted once and again on resize or theme change. The dot color resolves
* from the canvas `color` token so it stays neutral in light and dark.
* customize: GAP (density), DOT (size), PEAK (max brightness).
*/
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 // dot spacing in px (tight grid, still distinct dots)
const DOT = 1.5 // dot side in px
const BASE = 0.05 // dim end of a dot
const PEAK = 0.32 // bright end of a dot (kept subtle for a card surface)
const draw = () => {
const rect = canvas.getBoundingClientRect()
if (!rect.width || !rect.height) return
// Resetting width clears the canvas and restores the identity transform,
// so the color probe below reads a raw device pixel.
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = Math.round(rect.width * dpr)
canvas.height = Math.round(rect.height * dpr)
// Resolve the token to concrete sRGB by painting + reading it back, so
// oklch never casts a color and dark mode recolors on theme change.
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)
// Repaint when the theme class toggles so the resolved color stays correct.
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,17 @@
import { Frame } from "@/components/reui/frame"
import { CardItem } from "./card-item"
import { CARDS } from "./data"
export function CardGrid() {
return (
<Frame className="@container w-full">
{/* Grid */}
<div className="grid gap-1 @2xl:grid-cols-2">
{CARDS.map((card) => (
<CardItem key={card.title} card={card} />
))}
</div>
</Frame>
)
}
@@ -0,0 +1,44 @@
import { FramePanel } from "@/components/reui/frame"
import { cn } from "@evobgp/ui/lib/utils"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import { CardDotField } from "./card-dot-field"
import { ICard } from "./data"
import { ChevronRightIcon } from "lucide-react"
export function CardItem({ card }: { card: ICard }) {
return (
<FramePanel className="isolate">
{/* Card */}
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
<div className="relative z-10 space-y-7.5">
<Item
className={cn(
"p-0",
"border-background flex size-11 items-center justify-center border-2 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-3">
<span className="block text-sm leading-tight font-medium">
{card.title}
</span>
<p className="text-muted-foreground text-xs leading-relaxed">
{card.description}
</p>
<a
href="#"
className="text-primay inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{card.link}
<ChevronRightIcon aria-hidden="true" className="size-2.5 shrink-0" />
</a>
</div>
</div>
</FramePanel>
)
}
@@ -0,0 +1,53 @@
import { type ReactNode } from "react"
import { ShoppingBagIcon, TrendingUp, BarChart3Icon, Settings2Icon } from "lucide-react"
export interface ICard {
title: string
description: string
link: string
icon: ReactNode
iconBg: string
}
export const CARDS: ICard[] = [
{
title: "Recent Orders Overview",
description:
"Track and review all recent purchases, updates, and status changes in one place.",
link: "View Orders",
icon: (
<ShoppingBagIcon aria-hidden="true" />
),
iconBg: "bg-green-600",
},
{
title: "Active Opportunities Pipeline",
description:
"Monitor ongoing deals, check potential revenue, and update opportunity stages.",
link: "Open Pipeline",
icon: (
<TrendingUp aria-hidden="true" />
),
iconBg: "bg-indigo-600",
},
{
title: "Performance & Sales Reports",
description:
"Analyze weekly and monthly reports to gain deeper insights into performance trends.",
link: "View Reports",
icon: (
<BarChart3Icon aria-hidden="true" />
),
iconBg: "bg-sky-600",
},
{
title: "Integration Settings & Sync",
description:
"Manage connections with third-party tools and ensure data stays in sync.",
link: "Manage Integrations",
icon: (
<Settings2Icon aria-hidden="true" />
),
iconBg: "bg-orange-600",
},
]
@@ -0,0 +1,9 @@
import { CardGrid } from "./components/card-grid"
export function Page() {
return (
<div className="flex min-h-svh w-full max-w-4xl items-center justify-center p-8">
<CardGrid />
</div>
)
}
@@ -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,240 @@
"use client"
import { useState, type CSSProperties } from "react"
import { Cell, Pie, PieChart } from "recharts"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@evobgp/ui/components/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@evobgp/ui/components/chart"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import { Separator } from "@evobgp/ui/components/separator"
import {
chartConfig,
inflowRanges,
rangeOptions,
type InflowSource,
type RangeKey,
} from "./data"
import { ChevronDownIcon } from "lucide-react"
function GaugeGrid() {
const gridPattern =
"[background-image:linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] [background-size:8px_8px]"
return (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-52 overflow-hidden"
>
<div
className={cn(
"absolute inset-x-0 top-0 h-40 [mask-image:linear-gradient(to_bottom,black_0%,black_42%,transparent_100%)] opacity-35",
gridPattern
)}
/>
<div
className={cn(
"absolute -inset-x-8 top-7 h-44 [mask-image:radial-gradient(ellipse_88%_76%_at_50%_16%,black_0%,black_46%,transparent_100%)] opacity-45 blur-md",
gridPattern
)}
/>
<div className="absolute inset-x-0 bottom-0 h-24 bg-[linear-gradient(to_bottom,transparent_0%,var(--card)_72%,var(--card)_100%)]" />
</div>
)
}
function RangeMenu({
selectedRange,
onSelectRange,
}: {
selectedRange: RangeKey
onSelectRange: (range: RangeKey) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="sm"
className="min-w-24 justify-between gap-1.5 px-3"
aria-label="Change inflow range"
/>
}
>
<span>{inflowRanges[selectedRange].label}</span>
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="w-40">
<DropdownMenuGroup>
{rangeOptions.map((range) => (
<DropdownMenuItem
key={range.key}
onClick={() => onSelectRange(range.key)}
>
{range.label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
function SourceIcon({ source }: { source: InflowSource }) {
return (
<Item
className={cn(
"dark:border-border relative isolate flex size-8 items-center justify-center overflow-hidden border-2 border-white bg-[var(--source-icon-bg)] p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:bg-neutral-950 dark:shadow-[0_1px_3px_0_rgba(0,0,0,0.35)] [&_svg]:size-4 [&_svg]:text-white",
source.id !== "qbridge" && "dark:[--source-stipple-opacity:0.38]",
source.id === "qbridge" && "dark:[--source-stipple-opacity:0.3]"
)}
style={{ "--source-icon-bg": source.color } as CSSProperties}
>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 [background-image:radial-gradient(circle_at_center,rgba(255,255,255,0.22)_0.6px,transparent_0.65px)] [background-size:3px_3px] opacity-[var(--source-stipple-opacity,0)]"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[1] [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%)]"
/>
<ItemMedia variant="icon" className="relative z-10 size-auto">
{source.icon}
</ItemMedia>
</Item>
)
}
function SourceMetric({ source }: { source: InflowSource }) {
return (
<div className="flex min-w-0 flex-col items-center gap-2 px-2 text-center">
<SourceIcon source={source} />
<div className="min-w-0">
<div className="text-muted-foreground text-xs">{source.name}</div>
<div className="text-base font-medium">{source.value}</div>
</div>
</div>
)
}
export function Chart() {
const [selectedRange, setSelectedRange] = useState<RangeKey>("week")
const currentRange = inflowRanges[selectedRange]
return (
<Card className="w-full max-w-[360px] gap-0 overflow-hidden py-0">
{/* Header */}
<CardHeader className="relative z-10 flex items-center justify-between border-b p-4!">
<CardTitle className="text-sm font-medium">Capital Inflows</CardTitle>
<CardAction>
<RangeMenu
selectedRange={selectedRange}
onSelectRange={setSelectedRange}
/>
</CardAction>
</CardHeader>
{/* Content */}
<CardContent className="relative p-0!">
<GaugeGrid />
<div className="relative px-5 pt-5 pb-4">
<div className="relative mx-auto h-[158px] w-full max-w-[300px]">
<ChartContainer
config={chartConfig}
className="aspect-auto h-full w-full overflow-visible"
initialDimension={{ width: 300, height: 158 }}
role="img"
aria-label={`Capital inflows for ${currentRange.label}: ${currentRange.total}`}
>
<PieChart
accessibilityLayer
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value, name) => (
<>
<span className="text-muted-foreground">{name}</span>
<span className="font-mono font-medium tabular-nums">
$
{Number(value).toLocaleString(undefined, {
maximumFractionDigits: 1,
})}
M
</span>
</>
)}
/>
}
/>
<Pie
data={currentRange.sources}
dataKey="amount"
nameKey="name"
startAngle={180}
endAngle={0}
cx="50%"
cy="93%"
innerRadius={84}
outerRadius={116}
paddingAngle={1}
cornerRadius={3}
stroke="var(--card)"
strokeWidth={1.5}
isAnimationActive={false}
>
{currentRange.sources.map((source) => (
<Cell key={source.id} fill={`var(--color-${source.id})`} />
))}
</Pie>
</PieChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-x-0 top-[82px] flex flex-col items-center">
<span className="text-muted-foreground text-xs">Capital In</span>
<span className="text-2xl font-medium">{currentRange.total}</span>
</div>
</div>
</div>
<div className="px-5 pb-5">
<Separator className="mb-5" />
<div className="grid grid-cols-[1fr_auto_1fr_auto_1fr] items-stretch">
{currentRange.sources.map((source, index) => (
<div key={source.id} className="contents">
<SourceMetric source={source} />
{index < currentRange.sources.length - 1 ? (
<Separator orientation="vertical" className="mx-2" />
) : null}
</div>
))}
</div>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,147 @@
import { type ReactNode } from "react"
import { type ChartConfig } from "@evobgp/ui/components/chart"
import { ActivityIcon, SparklesIcon, CircleDollarSignIcon } from "lucide-react"
export type RangeKey = "week" | "month" | "quarter"
export interface InflowSource {
id: "wavemark" | "envato" | "qbridge"
name: string
amount: number
value: string
color: string
icon: ReactNode
}
export interface InflowRange {
label: string
total: string
sources: InflowSource[]
}
export const rangeOptions: { key: RangeKey; label: string }[] = [
{ key: "week", label: "Last Week" },
{ key: "month", label: "Last Month" },
{ key: "quarter", label: "Last Quarter" },
]
const sourceIcons = {
wavemark: (
<ActivityIcon aria-hidden="true" />
),
envato: (
<SparklesIcon aria-hidden="true" />
),
qbridge: (
<CircleDollarSignIcon aria-hidden="true" />
),
} satisfies Record<InflowSource["id"], ReactNode>
export const inflowRanges: Record<RangeKey, InflowRange> = {
week: {
label: "Last Week",
total: "$12.4M",
sources: [
{
id: "wavemark",
name: "WaveMark",
amount: 7.6,
value: "$7.6M",
color: "oklch(57% 0.13 184)",
icon: sourceIcons.wavemark,
},
{
id: "envato",
name: "Envato Inc.",
amount: 3.4,
value: "$3.4M",
color: "oklch(76% 0.17 84)",
icon: sourceIcons.envato,
},
{
id: "qbridge",
name: "QBridge B.V.",
amount: 1.2,
value: "$1.2M",
color: "oklch(65% 0.21 45)",
icon: sourceIcons.qbridge,
},
],
},
month: {
label: "Last Month",
total: "$47.8M",
sources: [
{
id: "wavemark",
name: "WaveMark",
amount: 28.4,
value: "$28.4M",
color: "oklch(57% 0.13 184)",
icon: sourceIcons.wavemark,
},
{
id: "envato",
name: "Envato Inc.",
amount: 13.6,
value: "$13.6M",
color: "oklch(76% 0.17 84)",
icon: sourceIcons.envato,
},
{
id: "qbridge",
name: "QBridge B.V.",
amount: 5.8,
value: "$5.8M",
color: "oklch(65% 0.21 45)",
icon: sourceIcons.qbridge,
},
],
},
quarter: {
label: "Last Quarter",
total: "$138.2M",
sources: [
{
id: "wavemark",
name: "WaveMark",
amount: 79.1,
value: "$79.1M",
color: "oklch(57% 0.13 184)",
icon: sourceIcons.wavemark,
},
{
id: "envato",
name: "Envato Inc.",
amount: 42.5,
value: "$42.5M",
color: "oklch(76% 0.17 84)",
icon: sourceIcons.envato,
},
{
id: "qbridge",
name: "QBridge B.V.",
amount: 16.6,
value: "$16.6M",
color: "oklch(65% 0.21 45)",
icon: sourceIcons.qbridge,
},
],
},
}
export const chartConfig = {
wavemark: {
label: "WaveMark",
color: "oklch(57% 0.13 184)",
},
envato: {
label: "Envato Inc.",
color: "oklch(76% 0.17 84)",
},
qbridge: {
label: "QBridge B.V.",
color: "oklch(65% 0.21 45)",
},
} satisfies ChartConfig
@@ -0,0 +1,9 @@
import { Chart } from "./components/chart"
export function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6">
<Chart />
</div>
)
}
@@ -0,0 +1,262 @@
import { Frame, FramePanel } from "@/components/reui/frame"
import { Cell, Pie, PieChart } from "recharts"
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 {
chartConfig,
inflowPeriods,
type InflowFund,
type InflowPeriod,
} from "./data"
import { InfoIcon } from "lucide-react"
const CHART_REVEAL_STYLE = `
@keyframes chart-13-reveal-up {
from {
clip-path: inset(100% 0 0 0);
opacity: 0.75;
}
to {
clip-path: inset(0 0 0 0);
opacity: 1;
}
}
.chart-13-reveal-up {
animation: chart-13-reveal-up 680ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.chart-13-reveal-up {
animation: none;
clip-path: none;
opacity: 1;
}
}
`
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: "Other Sources",
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 Capital Inflows"
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 capital committed during the selected period.</p>
</TooltipContent>
</Tooltip>
)
}
function InflowDonut({ period }: { period: InflowPeriod }) {
const chartData = getDonutData(period)
return (
<div className="chart-13-reveal-up relative size-[8.25rem] shrink-0">
<ChartContainer
aria-label={`Capital Inflows: ${period.total} total for ${period.label}`}
className="aspect-square size-[8.25rem]"
config={chartConfig}
initialDimension={{ width: 132, height: 132 }}
>
<PieChart margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
<ChartTooltip
cursor={false}
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">Capital</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>
)
}
export function Chart() {
return (
<TooltipProvider delay={150}>
<style>{CHART_REVEAL_STYLE}</style>
<Frame className="@container w-full max-w-[29rem]">
<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">Capital Inflows</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>
)
}
@@ -0,0 +1,141 @@
import { type ChartConfig } from "@evobgp/ui/components/chart"
export type InflowFundKey = "northline" | "copper" | "bridgewell" | "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
funds: InflowFund[]
}
const teal = "oklch(0.6 0.145 181.2)"
const amber = "oklch(0.76 0.161 80.1)"
const orange = "oklch(0.66 0.19 42.8)"
export const chartConfig = {
capital: {
label: "Capital",
},
northline: {
label: "WaveMark Capital",
color: teal,
},
copper: {
label: "Copper Market",
color: amber,
},
bridgewell: {
label: "Bridgewell Fund",
color: orange,
},
reserve: {
label: "Other Sources",
color: "var(--muted)",
},
} satisfies ChartConfig
export const inflowPeriods: InflowPeriod[] = [
{
value: "week",
label: "Week",
total: "$12,4M",
funds: [
{
key: "northline",
name: "WaveMark Capital",
amount: "$7,6M",
share: 35,
color: teal,
fill: "var(--color-northline)",
},
{
key: "copper",
name: "Envato Market",
amount: "$3,4M",
share: 34,
color: amber,
fill: "var(--color-copper)",
},
{
key: "bridgewell",
name: "QBridge Investment",
amount: "$1,2M",
share: 14,
color: orange,
fill: "var(--color-bridgewell)",
},
],
},
{
value: "month",
label: "Month",
total: "$48,2M",
funds: [
{
key: "northline",
name: "Northline Capital",
amount: "$21,8M",
share: 39,
color: teal,
fill: "var(--color-northline)",
},
{
key: "copper",
name: "Copper Market",
amount: "$16,5M",
share: 31,
color: amber,
fill: "var(--color-copper)",
},
{
key: "bridgewell",
name: "Bridgewell Fund",
amount: "$6,9M",
share: 18,
color: orange,
fill: "var(--color-bridgewell)",
},
],
},
{
value: "year",
label: "Year",
total: "$186,7M",
funds: [
{
key: "northline",
name: "Northline Capital",
amount: "$72,4M",
share: 37,
color: teal,
fill: "var(--color-northline)",
},
{
key: "copper",
name: "Copper Market",
amount: "$61,2M",
share: 33,
color: amber,
fill: "var(--color-copper)",
},
{
key: "bridgewell",
name: "Bridgewell Fund",
amount: "$29,6M",
share: 16,
color: orange,
fill: "var(--color-bridgewell)",
},
],
},
]
@@ -0,0 +1,9 @@
import { Chart } from "./components/chart"
export function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6">
<Chart />
</div>
)
}
@@ -0,0 +1,154 @@
"use client"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Area, AreaChart, ResponsiveContainer, Tooltip } from "recharts"
import { activeUsersData, customersData, revenueData } from "./data"
import { CircleDollarSignIcon, UserPlusIcon, TrendingUp } from "lucide-react"
// ── Business metric cards ──
const businessCards = [
{
title: "Revenue",
period: "Last 28 days",
value: "6.238$",
timestamp: "",
data: revenueData,
color: "var(--color-emerald-500)",
gradientId: "revenueGradient",
icon: (
<CircleDollarSignIcon aria-hidden="true" />
),
},
{
title: "New Customers",
period: "Last 28 days",
value: "6.202",
timestamp: "3h ago",
data: customersData,
color: "var(--color-blue-500)",
gradientId: "customersGradient",
icon: (
<UserPlusIcon aria-hidden="true" />
),
},
{
title: "Active Users",
period: "Last 28 days",
value: "18.945",
timestamp: "1h ago",
data: activeUsersData,
color: "var(--color-violet-500)",
gradientId: "usersGradient",
icon: (
<TrendingUp aria-hidden="true" />
),
},
]
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, i) => (
<Frame key={i} variant="ghost">
<FramePanel className="space-y-5">
{/* Header */}
<div className="flex items-center gap-2">
<span style={{ color: card.color }} className="[&_svg]:size-5">
{card.icon}
</span>
<span className="text-sm font-semibold">{card.title}</span>
</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
const formatValue = (val: number) => {
if (card.title === "Revenue") {
return `${(val / 1000).toFixed(1)}k US$`
}
return `${(val / 1000).toFixed(1)}k`
}
return (
<div className="bg-popover border-border pointer-events-none rounded-md border p-2 shadow-lg backdrop-blur-sm">
<p className="text-popover-foreground text-sm font-semibold">
{formatValue(value)}
</p>
</div>
)
}
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>
</FramePanel>
</Frame>
))}
</div>
</div>
)
}
@@ -0,0 +1,70 @@
import { ReactNode } from "react"
// ── Types ──
export interface MetricCard {
title: string
period: string
value: string
timestamp: string
data: { value: number }[]
color: string
icon: ReactNode
gradientId: string
}
// ── Data ──
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 },
]
@@ -0,0 +1,9 @@
import { Chart } from "./components/chart"
export function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-10 md:p-20">
<Chart />
</div>
)
}
@@ -0,0 +1,158 @@
"use client"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Frame, FramePanel } from "@/components/reui/frame"
import { LabelList, Pie, PieChart } from "recharts"
import { Button } from "@evobgp/ui/components/button"
import { ChartContainer, ChartTooltip } from "@evobgp/ui/components/chart"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import { Tabs, TabsList, TabsTrigger } from "@evobgp/ui/components/tabs"
import { browserData, chartConfig, PeriodKey, PERIODS } from "./data"
import { TrendingUp, PieChartIcon, InfoIcon } from "lucide-react"
// ── Custom tooltip ──
const CustomTooltip = ({
active,
payload,
}: {
active?: boolean
payload?: {
name: string
color: string
value: number
payload: { browser: string }
}[]
}) => {
if (active && payload && payload.length) {
return (
<div className="bg-popover min-w-[150px] rounded-lg border p-4 shadow-lg backdrop-blur-sm">
<div className="text-popover-foreground border-border/50 mb-3 border-b pb-2 text-sm font-semibold tracking-wider uppercase">
{payload[0].payload.browser}
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div
className="size-2 rounded-sm"
style={{ backgroundColor: payload[0].color }}
/>
<span className="text-muted-foreground text-xs font-medium">
Visitors
</span>
</div>
<span className="text-popover-foreground text-sm font-semibold">
{payload[0].value.toLocaleString()}
</span>
</div>
</div>
)
}
return null
}
export function Chart() {
const [selectedPeriod, setSelectedPeriod] = useState<PeriodKey>("5D")
const { currentData, totalVisitors } = useMemo(() => {
const data = browserData[selectedPeriod] || []
const total = data.reduce((sum, item) => sum + item.visitors, 0)
return { currentData: data, totalVisitors: total }
}, [selectedPeriod])
return (
<Frame className="w-full max-w-md">
{/* Content */}
<FramePanel className="space-y-6 p-6!">
<div className="flex items-center justify-between border-b border-dashed pb-6">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold">Browser Usage</h3>
<Badge variant="success-light" className="gap-1 border-none">
<TrendingUp className="size-3.5" aria-hidden="true" />
<span>+5.2%</span>
</Badge>
</div>
<Button variant="outline" size="sm">
Export
</Button>
</div>
<div className="space-y-6">
<div className="flex items-center gap-3 px-2">
<Item className="bg-primary/5 text-primary flex size-12 items-center justify-center rounded-full p-0 [&_svg]:h-6 [&_svg]:w-6">
<ItemMedia variant="icon" className="size-auto">
<PieChartIcon aria-hidden="true" />
</ItemMedia>
</Item>
<div>
<div className="text-muted-foreground text-sm font-medium">
Total Visitors
</div>
<div className="text-2xl font-bold">
{totalVisitors.toLocaleString()}
</div>
</div>
</div>
<Tabs
value={selectedPeriod}
onValueChange={(value) => setSelectedPeriod(value as PeriodKey)}
className="w-full"
>
<TabsList className="w-full">
{Object.values(PERIODS).map((period) => (
<TabsTrigger
key={period.key}
value={period.key}
className="flex-1"
>
{period.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="mx-auto aspect-square max-h-[200px] w-full">
<ChartContainer
config={chartConfig}
className="h-full w-full overflow-visible"
>
<PieChart className="overflow-visible">
<ChartTooltip content={<CustomTooltip />} />
<Pie
data={currentData}
innerRadius={35}
outerRadius={100}
dataKey="visitors"
nameKey="browser"
strokeWidth={4}
stroke="var(--background)"
cornerRadius={8}
paddingAngle={2}
isAnimationActive={false}
>
<LabelList
dataKey="visitors"
stroke="none"
fontSize={10}
fontWeight={600}
fill="white"
formatter={(value: number) =>
value >= 1000 ? `${(value / 1000).toFixed(1)}k` : value
}
/>
</Pie>
</PieChart>
</ChartContainer>
</div>
<div className="text-muted-foreground flex items-center gap-2 px-2 text-xs">
<InfoIcon className="size-3.5" aria-hidden="true" />
<span>Visitor data based on unique browser signatures.</span>
</div>
</div>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,51 @@
import { ChartConfig } from "@evobgp/ui/components/chart"
// ── Types ──
export type PeriodKey = "5D" | "2W" | "1M"
// ── Config ──
export const chartConfig = {
visitors: { label: "Visitors" },
chrome: { label: "Chrome", color: "var(--color-blue-500)" },
safari: { label: "Safari", color: "var(--color-sky-500)" },
firefox: { label: "Firefox", color: "var(--color-orange-500)" },
edge: { label: "Edge", color: "var(--color-indigo-500)" },
other: { label: "Other", color: "var(--color-slate-500)" },
} satisfies ChartConfig
export const PERIODS = {
"5D": { key: "5D", label: "5D" },
"2W": { key: "2W", label: "2W" },
"1M": { key: "1M", label: "1M" },
} as const
// ── Data ──
export const browserData: Record<
PeriodKey,
{ browser: string; visitors: number; fill: string }[]
> = {
"5D": [
{ browser: "chrome", visitors: 275, fill: "var(--color-blue-500)" },
{ browser: "safari", visitors: 200, fill: "var(--color-sky-500)" },
{ browser: "firefox", visitors: 187, fill: "var(--color-orange-500)" },
{ browser: "edge", visitors: 173, fill: "var(--color-indigo-500)" },
{ browser: "other", visitors: 90, fill: "var(--color-slate-500)" },
],
"2W": [
{ browser: "chrome", visitors: 1275, fill: "var(--color-blue-500)" },
{ browser: "safari", visitors: 800, fill: "var(--color-sky-500)" },
{ browser: "firefox", visitors: 587, fill: "var(--color-orange-500)" },
{ browser: "edge", visitors: 473, fill: "var(--color-indigo-500)" },
{ browser: "other", visitors: 290, fill: "var(--color-slate-500)" },
],
"1M": [
{ browser: "chrome", visitors: 4275, fill: "var(--color-blue-500)" },
{ browser: "safari", visitors: 3200, fill: "var(--color-sky-500)" },
{ browser: "firefox", visitors: 2187, fill: "var(--color-orange-500)" },
{ browser: "edge", visitors: 1873, fill: "var(--color-indigo-500)" },
{ browser: "other", visitors: 1090, fill: "var(--color-slate-500)" },
],
}
@@ -0,0 +1,9 @@
import { Chart } from "./components/chart"
export function Page() {
return (
<div className="flex w-full items-center justify-center p-10 md:p-20">
<Chart />
</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 "@/components/dashboard/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,241 @@
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 { type MemberBillingStatus, type MemberRecord } from "./data"
import { EyeIcon, PencilIcon, Trash2Icon } from "lucide-react"
export type MemberRowAction = "view" | "edit" | "delete"
const billingStatusDotClass: Record<MemberBillingStatus, string> = {
Active: "bg-emerald-500",
"Pending invoice": "bg-sky-500",
"Manual review": "bg-amber-500",
"Past due": "bg-rose-500",
}
function MemberRowActions({
member,
onAction,
}: {
member: MemberRecord
onAction: (action: MemberRowAction, member: MemberRecord) => void
}) {
return (
<div className="pointer-events-none flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within/member-row:pointer-events-auto group-focus-within/member-row:opacity-100 group-hover/member-row:pointer-events-auto group-hover/member-row:opacity-100">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`View ${member.fullName}`}
onClick={(event) => {
event.stopPropagation()
onAction("view", member)
}}
>
<EyeIcon aria-hidden="true" />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Edit ${member.fullName}`}
onClick={(event) => {
event.stopPropagation()
onAction("edit", member)
}}
>
<PencilIcon aria-hidden="true" />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
className="hover:text-destructive"
aria-label={`Delete ${member.fullName}`}
onClick={(event) => {
event.stopPropagation()
onAction("delete", member)
}}
>
<Trash2Icon aria-hidden="true" />
</Button>
</div>
)
}
function MemberCell({
member,
onAction,
}: {
member: MemberRecord
onAction: (action: MemberRowAction, member: MemberRecord) => void
}) {
return (
<div className="flex min-w-0 items-center gap-2">
<Avatar className="size-8 shrink-0">
<AvatarImage src={member.avatarSrc} alt={member.fullName} />
<AvatarFallback>{member.initials}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{member.fullName}</div>
<div className="text-muted-foreground truncate text-sm">
{member.displayName}
</div>
</div>
{/* Actions */}
<MemberRowActions member={member} onAction={onAction} />
</div>
)
}
function BillingStatusCell({
billingStatus,
}: {
billingStatus: MemberBillingStatus
}) {
return (
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
billingStatusDotClass[billingStatus]
)}
aria-hidden="true"
/>
<span className="truncate text-sm">{billingStatus}</span>
</div>
)
}
export function createMembersGridColumns({
onAction,
}: {
onAction: (action: MemberRowAction, member: MemberRecord) => void
}): ColumnDef<MemberRecord>[] {
return [
{
accessorKey: "fullName",
id: "fullName",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<MemberCell member={row.original} onAction={onAction} />
),
size: 255,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Member",
},
},
{
accessorKey: "email",
id: "email",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<a
href={`mailto:${row.original.email}`}
className="hover:text-primary block truncate text-sm transition-colors hover:underline"
title={row.original.email}
>
{row.original.email}
</a>
),
size: 220,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Email",
},
},
{
accessorKey: "role",
id: "role",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<span className="block truncate text-sm">{row.original.role}</span>
),
size: 120,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Role",
},
},
{
accessorKey: "billingStatus",
id: "billingStatus",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<BillingStatusCell billingStatus={row.original.billingStatus} />
),
size: 150,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Billing status",
},
},
{
accessorKey: "authProvider",
id: "authProvider",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<span className="block truncate text-sm">
{row.original.authProvider}
</span>
),
size: 130,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Authentication",
},
},
{
accessorKey: "joinedAt",
id: "joinedAt",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<span className="text-muted-foreground block truncate text-sm tabular-nums">
{row.original.joinedAt}
</span>
),
sortingFn: (rowA, rowB) =>
new Date(rowA.original.joinedAt).getTime() -
new Date(rowB.original.joinedAt).getTime(),
size: 130,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Joining date",
},
},
]
}
@@ -0,0 +1,426 @@
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 } 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,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import { Separator } from "@evobgp/ui/components/separator"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import { createMembersGridColumns, type MemberRowAction } from "./columns"
import {
MEMBER_AUTH_PROVIDER_OPTIONS,
MEMBER_BILLING_STATUS_OPTIONS,
MEMBER_RECORDS,
MEMBER_ROLE_OPTIONS,
type FilterOption,
type MemberAuthProvider,
type MemberBillingStatus,
type MemberRecord,
type MemberRole,
} from "./data"
import { SearchIcon, XIcon, ChevronDownIcon } from "lucide-react"
type FilterTabContentProps<T extends string> = {
options: readonly FilterOption<T>[]
selectedValues: T[]
onToggle: (value: T, checked: boolean) => void
}
function getMemberSearchBlob(member: MemberRecord) {
return [
member.fullName,
member.displayName,
member.email,
member.role,
member.billingStatus,
member.authProvider,
member.joinedAt,
]
.join(" ")
.toLowerCase()
}
function toggleFilterValue<T extends string>(
current: T[],
value: T,
checked: boolean
) {
if (checked) {
return current.includes(value) ? current : [...current, value]
}
return current.filter((item) => item !== value)
}
function FilterTabContent<T extends string>({
options,
selectedValues,
onToggle,
}: FilterTabContentProps<T>) {
return (
<DropdownMenuGroup>
{options.map((option) => (
<DropdownMenuCheckboxItem
key={option.value}
checked={selectedValues.includes(option.value)}
closeOnClick={false}
onCheckedChange={(checked) =>
onToggle(option.value, checked === true)
}
>
{option.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
)
}
export function MembersDataGridView() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "fullName", desc: false },
])
const [searchQuery, setSearchQuery] = useState("")
const [selectedRoles, setSelectedRoles] = useState<MemberRole[]>([])
const [selectedBillingStatuses, setSelectedBillingStatuses] = useState<
MemberBillingStatus[]
>([])
const [selectedAuthProviders, setSelectedAuthProviders] = useState<
MemberAuthProvider[]
>([])
const filteredData = useMemo(() => {
const normalizedSearchQuery = searchQuery.trim().toLowerCase()
return MEMBER_RECORDS.filter((member) => {
const matchesSearch =
normalizedSearchQuery.length === 0 ||
getMemberSearchBlob(member).includes(normalizedSearchQuery)
const matchesRole =
selectedRoles.length === 0 || selectedRoles.includes(member.role)
const matchesBillingStatus =
selectedBillingStatuses.length === 0 ||
selectedBillingStatuses.includes(member.billingStatus)
const matchesAuthProvider =
selectedAuthProviders.length === 0 ||
selectedAuthProviders.includes(member.authProvider)
return (
matchesSearch &&
matchesRole &&
matchesBillingStatus &&
matchesAuthProvider
)
})
}, [
searchQuery,
selectedAuthProviders,
selectedBillingStatuses,
selectedRoles,
])
const activeFilterCount =
selectedRoles.length +
selectedBillingStatuses.length +
selectedAuthProviders.length
const handleMemberAction = useCallback(
(action: MemberRowAction, member: MemberRecord) => {
if (action === "view") {
toast.info("Open member", {
description: `Review ${member.fullName}'s profile and recent activity.`,
})
return
}
if (action === "edit") {
toast.message("Edit member", {
description: `Open the access and profile editor for ${member.fullName}.`,
})
return
}
toast.message("Delete member", {
description: `Wire this action to your member removal flow for ${member.fullName}.`,
})
},
[]
)
const columns = useMemo(
() => createMembersGridColumns({ onAction: handleMemberAction }),
[handleMemberAction]
)
const table = useReactTable({
columns,
data: filteredData,
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
getRowId: (row) => row.id,
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const handleSearchChange = (value: string) => {
setSearchQuery(value)
setPagination((current) => ({
...current,
pageIndex: 0,
}))
}
const handleRoleToggle = (value: MemberRole, checked: boolean) => {
setSelectedRoles((current) => toggleFilterValue(current, value, checked))
setPagination((current) => ({
...current,
pageIndex: 0,
}))
}
const handleBillingStatusToggle = (
value: MemberBillingStatus,
checked: boolean
) => {
setSelectedBillingStatuses((current) =>
toggleFilterValue(current, value, checked)
)
setPagination((current) => ({
...current,
pageIndex: 0,
}))
}
const handleAuthProviderToggle = (
value: MemberAuthProvider,
checked: boolean
) => {
setSelectedAuthProviders((current) =>
toggleFilterValue(current, value, checked)
)
setPagination((current) => ({
...current,
pageIndex: 0,
}))
}
const handleResetFilters = () => {
setSelectedRoles([])
setSelectedBillingStatuses([])
setSelectedAuthProviders([])
setPagination((current) => ({
...current,
pageIndex: 0,
}))
}
const handleImport = () => {
toast.message("Import members", {
description:
"Connect this button to your CSV import, directory sync, or SCIM provisioning flow.",
})
}
const handleAddMember = () => {
toast.message("Add member", {
description:
"Open your invite drawer or provisioning dialog from this primary action.",
})
}
return (
<DataGrid
table={table}
recordCount={filteredData.length}
emptyMessage={
filteredData.length === 0
? "No members match your current search or filters."
: undefined
}
tableLayout={{
dense: true,
headerSticky: false,
columnsVisibility: false,
columnsResizable: false,
columnsMovable: false,
}}
tableClassNames={{
bodyRow: "group/member-row",
}}
>
{/* Heading */}
<div className="flex w-full flex-col">
<div className="flex flex-col gap-4 py-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-center gap-2.5">
<h2 className="truncate text-2xl font-semibold tracking-tight">
Members
</h2>
<Badge variant="outline" size="sm" radius="full">
{filteredData.length}
</Badge>
</div>
<div className="flex w-full flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center lg:ml-auto lg:w-auto lg:flex-nowrap lg:justify-end">
<InputGroup className="w-full sm:w-72">
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search..."
aria-label="Search members"
value={searchQuery}
onChange={(event) => handleSearchChange(event.target.value)}
/>
{searchQuery.length > 0 ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
aria-label="Clear search"
size="icon-xs"
onClick={() => handleSearchChange("")}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
aria-label="Filter members"
>
Filters
{activeFilterCount > 0 ? (
<Badge variant="outline" size="sm" radius="full">
{activeFilterCount}
</Badge>
) : null}
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-64">
<Tabs defaultValue="role" className="w-full">
<div className="p-1">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="role">Role</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
<TabsTrigger value="authentication">Auth</TabsTrigger>
</TabsList>
</div>
<TabsContent value="role" className="m-0 px-0.5 pb-1">
<FilterTabContent
options={MEMBER_ROLE_OPTIONS}
selectedValues={selectedRoles}
onToggle={handleRoleToggle}
/>
</TabsContent>
<TabsContent value="billing" className="m-0 px-0.5 pb-1">
<FilterTabContent
options={MEMBER_BILLING_STATUS_OPTIONS}
selectedValues={selectedBillingStatuses}
onToggle={handleBillingStatusToggle}
/>
</TabsContent>
<TabsContent
value="authentication"
className="m-0 px-0.5 pb-1"
>
<FilterTabContent
options={MEMBER_AUTH_PROVIDER_OPTIONS}
selectedValues={selectedAuthProviders}
onToggle={handleAuthProviderToggle}
/>
</TabsContent>
</Tabs>
{activeFilterCount > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
closeOnClick={false}
onClick={handleResetFilters}
>
Reset filters
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<Button type="button" variant="outline" onClick={handleImport}>
Import
</Button>
<Button type="button" onClick={handleAddMember}>
Add member
</Button>
</div>
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<div className="py-3">
{filteredData.length > 0 ? (
<DataGridPagination
sizes={[5, 10, 20]}
info="{from} - {to} of {count} members"
className="py-0"
/>
) : (
<p className="text-muted-foreground text-sm">0 members</p>
)}
</div>
</div>
</DataGrid>
)
}
@@ -0,0 +1,154 @@
export type FilterOption<T extends string> = {
value: T
label: string
}
export const MEMBER_ROLE_OPTIONS = [
{ value: "Admin", label: "Admin" },
{ value: "Finance", label: "Finance" },
{ value: "Support", label: "Support" },
{ value: "Operations", label: "Operations" },
{ value: "Product", label: "Product" },
{ value: "Security", label: "Security" },
{ value: "Growth", label: "Growth" },
] as const satisfies readonly FilterOption<string>[]
export const MEMBER_BILLING_STATUS_OPTIONS = [
{ value: "Active", label: "Active" },
{ value: "Pending invoice", label: "Pending invoice" },
{ value: "Manual review", label: "Manual review" },
{ value: "Past due", label: "Past due" },
] as const satisfies readonly FilterOption<string>[]
export const MEMBER_AUTH_PROVIDER_OPTIONS = [
{ value: "Google", label: "Google" },
{ value: "GitHub", label: "GitHub" },
{ value: "Okta SSO", label: "Okta SSO" },
{ value: "Passwordless", label: "Passwordless" },
] as const satisfies readonly FilterOption<string>[]
export type MemberRole = (typeof MEMBER_ROLE_OPTIONS)[number]["value"]
export type MemberBillingStatus =
(typeof MEMBER_BILLING_STATUS_OPTIONS)[number]["value"]
export type MemberAuthProvider =
(typeof MEMBER_AUTH_PROVIDER_OPTIONS)[number]["value"]
export type MemberRecord = {
id: string
fullName: string
displayName: string
email: string
role: MemberRole
billingStatus: MemberBillingStatus
authProvider: MemberAuthProvider
joinedAt: string
avatarSrc: string
initials: string
}
export const MEMBER_RECORDS: MemberRecord[] = [
{
id: "lena-torres",
fullName: "Lena Torres",
displayName: "lena.torres",
email: "[email protected]",
role: "Admin",
billingStatus: "Active",
authProvider: "Google",
joinedAt: "Apr 17, 2026",
avatarSrc:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&h=80&dpr=2&q=80",
initials: "LT",
},
{
id: "marcus-hale",
fullName: "Marcus Hale",
displayName: "marcus.hale",
email: "[email protected]",
role: "Finance",
billingStatus: "Pending invoice",
authProvider: "Okta SSO",
joinedAt: "Apr 12, 2026",
avatarSrc:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=80&h=80&dpr=2&q=80",
initials: "MH",
},
{
id: "imani-brooks",
fullName: "Imani Brooks",
displayName: "imani.brooks",
email: "[email protected]",
role: "Support",
billingStatus: "Active",
authProvider: "Passwordless",
joinedAt: "Mar 29, 2026",
avatarSrc:
"https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=80&h=80&dpr=2&q=80",
initials: "IB",
},
{
id: "theo-mercer",
fullName: "Theo Mercer",
displayName: "theo.mercer",
email: "[email protected]",
role: "Operations",
billingStatus: "Manual review",
authProvider: "GitHub",
joinedAt: "Mar 18, 2026",
avatarSrc:
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=80&h=80&dpr=2&q=80",
initials: "TM",
},
{
id: "noor-hadid",
fullName: "Noor Hadid",
displayName: "noor.hadid",
email: "[email protected]",
role: "Product",
billingStatus: "Active",
authProvider: "Google",
joinedAt: "Feb 27, 2026",
avatarSrc:
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=80&h=80&dpr=2&q=80",
initials: "NH",
},
{
id: "avery-quinn",
fullName: "Avery Quinn",
displayName: "avery.quinn",
email: "[email protected]",
role: "Security",
billingStatus: "Past due",
authProvider: "Okta SSO",
joinedAt: "Feb 10, 2026",
avatarSrc:
"https://images.unsplash.com/photo-1504593811423-6dd665756598?w=80&h=80&dpr=2&q=80",
initials: "AQ",
},
{
id: "sofia-lane",
fullName: "Sofia Lane",
displayName: "sofia.lane",
email: "[email protected]",
role: "Finance",
billingStatus: "Active",
authProvider: "Google",
joinedAt: "Jan 23, 2026",
avatarSrc:
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=80&h=80&dpr=2&q=80",
initials: "SL",
},
{
id: "dev-rana",
fullName: "Dev Rana",
displayName: "dev.rana",
email: "[email protected]",
role: "Growth",
billingStatus: "Pending invoice",
authProvider: "GitHub",
joinedAt: "Jan 11, 2026",
avatarSrc:
"https://images.unsplash.com/photo-1504257432389-52343af06ae3?w=80&h=80&dpr=2&q=80",
initials: "DR",
},
]
@@ -0,0 +1,15 @@
import { MembersDataGridView } from "./components/data-grid-view"
export function Page() {
return (
<main
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Members data grid
</h1>
<MembersDataGridView />
</main>
)
}
@@ -0,0 +1,512 @@
import { memo, type ComponentProps } from "react"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
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 { 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 { type IReleaseReview, type ReviewStatus, type RiskLevel } from "./data"
import { ReleaseCheckRail } from "./release-check-rail"
import { ChevronRightIcon, MoreHorizontalIcon, EyeIcon, CircleCheckIcon, CopyIcon, FlagIcon } from "lucide-react"
export type ReviewAction = "open" | "approve" | "escalate"
const riskVariant: Record<RiskLevel, ComponentProps<typeof Badge>["variant"]> =
{
Critical: "destructive-light",
High: "warning-light",
Medium: "info-light",
Low: "secondary",
}
const statusConfig: Record<
ReviewStatus,
{
variant: ComponentProps<typeof Badge>["variant"]
dotClassName: string
}
> = {
"Needs approval": {
variant: "warning-outline",
dotClassName: "bg-amber-500 dark:bg-amber-400",
},
Blocked: {
variant: "destructive-outline",
dotClassName: "bg-rose-500 dark:bg-rose-400",
},
Scheduled: {
variant: "info-outline",
dotClassName: "bg-sky-500 dark:bg-sky-400",
},
Ready: {
variant: "success-outline",
dotClassName: "bg-emerald-500 dark:bg-emerald-400",
},
Shipped: {
variant: "outline",
dotClassName: "bg-zinc-500 dark:bg-zinc-400",
},
}
function DotSeparator() {
return (
<span
className="size-1 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500"
aria-hidden="true"
/>
)
}
function initials(name: string) {
return name
.split(" ")
.map((part) => part[0])
.join("")
}
function readinessPercent(review: IReleaseReview) {
if (review.checksTotal === 0) return 100
return Math.round((review.checksCompleted / review.checksTotal) * 100)
}
function readinessRingColor(percent: number) {
if (percent >= 100) return "text-emerald-500"
if (percent >= 50) return "text-amber-500"
return "text-rose-500"
}
export const StatusBadge = memo(function StatusBadge({
status,
}: {
status: ReviewStatus
}) {
return (
<Badge variant={statusConfig[status].variant}>
<span
className={cn(
"size-1.5 shrink-0 rounded-full!",
statusConfig[status].dotClassName
)}
aria-hidden="true"
/>
{status}
</Badge>
)
})
export const RiskBadge = memo(function RiskBadge({
risk,
}: {
risk: RiskLevel
}) {
return <Badge variant={riskVariant[risk]}>{risk}</Badge>
})
const ExpandReleaseButton = memo(function ExpandReleaseButton({
expanded,
onToggle,
}: {
expanded: boolean
onToggle: () => void
}) {
return (
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={
expanded ? "Collapse rollout checks" : "Expand rollout checks"
}
className="text-muted-foreground hover:text-foreground size-6 shrink-0 rounded-md p-0 shadow-none"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onToggle()
}}
>
<ChevronRightIcon className={cn(
"size-3.5 shrink-0 transition-transform duration-150",
expanded && "rotate-90"
)} aria-hidden="true" />
</Button>
)
})
const RequestCell = memo(function RequestCell({
review,
showChecks,
canExpand,
isExpanded,
onToggleExpand,
}: {
review: IReleaseReview
showChecks: boolean
canExpand: boolean
isExpanded: boolean
onToggleExpand: () => void
}) {
return (
<div className="flex min-w-0 items-center gap-2.5">
{showChecks && canExpand ? (
<ExpandReleaseButton expanded={isExpanded} onToggle={onToggleExpand} />
) : (
<span className="size-6 shrink-0" aria-hidden="true" />
)}
<div className="flex min-w-0 flex-col gap-1.5">
<span className="text-foreground truncate text-sm leading-5 font-medium">
{review.title}
</span>
<div className="text-muted-foreground flex min-w-0 flex-wrap items-center gap-2 text-xs leading-4">
<span className="shrink-0 font-mono tracking-wide uppercase">
{review.changeKey}
</span>
<DotSeparator />
<span className="truncate">{review.service.label}</span>
<DotSeparator />
<span className="shrink-0">{review.environment}</span>
</div>
</div>
</div>
)
})
const OwnerCell = memo(function OwnerCell({
review,
}: {
review: IReleaseReview
}) {
const owner = review.owner
return (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar className="size-7 shrink-0">
{owner.avatar ? (
<AvatarImage src={owner.avatar} alt={owner.name} />
) : null}
<AvatarFallback>{initials(owner.name)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground truncate text-sm font-medium">
{owner.name}
</span>
<span className="text-muted-foreground truncate text-xs">
{owner.role}
</span>
</div>
</div>
)
})
const WindowCell = memo(function WindowCell({
review,
}: {
review: IReleaseReview
}) {
return (
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground text-sm font-medium">
{review.windowLabel}
</span>
<div className="text-muted-foreground flex min-w-0 items-center gap-2 text-xs">
<span className="truncate">{review.windowDurationLabel}</span>
<DotSeparator />
<span className="truncate">{review.blastRadiusLabel}</span>
</div>
</div>
)
})
const ApprovalsCell = memo(function ApprovalsCell({
review,
}: {
review: IReleaseReview
}) {
const pending = review.approvalsRequired - review.approvalsApproved
const isComplete = pending <= 0
return (
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground text-sm font-medium tabular-nums">
{review.approvalsApproved}/{review.approvalsRequired}
</span>
<span
className={cn(
"text-xs tabular-nums",
isComplete
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground"
)}
>
{isComplete ? "Complete" : `${pending} pending`}
</span>
</div>
)
})
const ReadinessCell = memo(function ReadinessCell({
review,
}: {
review: IReleaseReview
}) {
const percent = readinessPercent(review)
const radius = 9
const circumference = 2 * Math.PI * radius
const dashOffset = circumference - (percent / 100) * circumference
const toneClassName = readinessRingColor(percent)
return (
<div className="flex items-center gap-2">
<svg
viewBox="0 0 24 24"
className={cn("size-5 shrink-0", toneClassName)}
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r={radius}
fill="none"
className="stroke-border"
strokeWidth="2.5"
/>
<circle
cx="12"
cy="12"
r={radius}
fill="none"
className="stroke-current"
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
transform="rotate(-90 12 12)"
/>
</svg>
<span className="text-foreground text-sm tabular-nums">{percent}%</span>
</div>
)
})
const UpdatedCell = memo(function UpdatedCell({
review,
}: {
review: IReleaseReview
}) {
return (
<span className="text-muted-foreground text-sm">{review.updatedLabel}</span>
)
})
function ReviewActionsCell({
review,
onAction,
}: {
review: IReleaseReview
onAction: (action: ReviewAction, review: IReleaseReview) => void
}) {
const { copyToClipboard } = useCopyToClipboard()
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Actions for ${review.changeKey}`}
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
{/* Content */}
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onAction("open", review)}>
<EyeIcon aria-hidden="true" />
Open review
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction("approve", review)}>
<CircleCheckIcon aria-hidden="true" />
Approve window
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
copyToClipboard(review.changeKey)
toast.success("Change key copied", {
description: review.changeKey,
})
}}
>
<CopyIcon aria-hidden="true" />
Copy key
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onAction("escalate", review)}>
<FlagIcon aria-hidden="true" />
Escalate risk
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function createReleaseColumns({
onReviewAction,
showChecks,
}: {
onReviewAction: (action: ReviewAction, review: IReleaseReview) => void
showChecks: boolean
}): ColumnDef<IReleaseReview>[] {
return [
{
accessorFn: (review) => review.title,
id: "request",
header: ({ column }) => (
<DataGridColumnHeader title="Request" column={column} />
),
cell: ({ row }) => (
<RequestCell
review={row.original}
showChecks={showChecks}
canExpand={row.getCanExpand()}
isExpanded={row.getIsExpanded()}
onToggleExpand={row.getToggleExpandedHandler()}
/>
),
enableHiding: false,
enableSorting: false,
size: 332,
minSize: 300,
meta: {
headerTitle: "Request",
autoSize: true,
headerClassName: "ps-5!",
cellClassName: "ps-5!",
expandedContent: (review: IReleaseReview) =>
showChecks ? <ReleaseCheckRail review={review} /> : null,
},
},
{
accessorFn: (review) => review.owner.name,
id: "owner",
header: ({ column }) => (
<DataGridColumnHeader title="Owner" column={column} />
),
cell: ({ row }) => <OwnerCell review={row.original} />,
size: 168,
enableSorting: false,
meta: {
headerTitle: "Owner",
},
},
{
accessorFn: (review) => review.statusOrder,
id: "status",
header: ({ column }) => (
<DataGridColumnHeader title="Status" column={column} />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
size: 150,
enableSorting: false,
meta: {
headerTitle: "Status",
},
},
{
accessorFn: (review) => review.riskValue,
id: "risk",
header: ({ column }) => (
<DataGridColumnHeader title="Risk" column={column} />
),
cell: ({ row }) => <RiskBadge risk={row.original.risk} />,
size: 116,
meta: {
headerTitle: "Risk",
},
},
{
accessorFn: (review) => review.windowStart,
id: "window",
header: ({ column }) => (
<DataGridColumnHeader title="Window" column={column} />
),
cell: ({ row }) => <WindowCell review={row.original} />,
size: 200,
meta: {
headerTitle: "Window",
},
},
{
accessorFn: (review) => review.approvalsApproved,
id: "approvals",
header: ({ column }) => (
<DataGridColumnHeader title="Approvals" column={column} />
),
cell: ({ row }) => <ApprovalsCell review={row.original} />,
size: 136,
enableSorting: false,
meta: {
headerTitle: "Approvals",
},
},
{
accessorFn: (review) => review.checksCompleted,
id: "readiness",
header: ({ column }) => (
<DataGridColumnHeader title="Readiness" column={column} />
),
cell: ({ row }) => <ReadinessCell review={row.original} />,
size: 150,
enableSorting: false,
meta: {
headerTitle: "Readiness",
},
},
{
accessorFn: (review) => review.updatedAt,
id: "updated",
header: ({ column }) => (
<DataGridColumnHeader title="Updated" column={column} />
),
cell: ({ row }) => <UpdatedCell review={row.original} />,
size: 120,
meta: {
headerTitle: "Updated",
},
},
{
id: "actions",
header: "",
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<ReviewActionsCell review={row.original} onAction={onReviewAction} />
),
size: 56,
meta: {
headerClassName: "pe-5!",
cellClassName: "pe-5!",
},
},
]
}
@@ -0,0 +1,691 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import {
DataGrid,
DataGridContainer,
getColumnHeaderLabel,
} 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 {
getCoreRowModel,
getExpandedRowModel,
getPaginationRowModel,
useReactTable,
type ExpandedState,
type PaginationState,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@evobgp/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Separator } from "@evobgp/ui/components/separator"
import { Switch } from "@evobgp/ui/components/switch"
import {
ToggleGroup,
ToggleGroupItem,
} from "@evobgp/ui/components/toggle-group"
import { createReleaseColumns, type ReviewAction } from "./columns"
import {
ORDERING_OPTIONS,
RELEASE_REVIEWS,
REVIEW_STATUS_ORDER,
SERVICE_OPTIONS,
type IReleaseReview,
type ReleaseOrdering,
type ReviewStatus,
} from "./data"
import { ReleaseReviewCard } from "./release-review-card"
import { PlusIcon, SearchIcon, XIcon, Settings2Icon } from "lucide-react"
type TableDensity = "compact" | "comfortable"
type OrderDirection = "asc" | "desc"
type ServiceFilter = IReleaseReview["service"]["label"] | "All services"
type StatusFilter = ReviewStatus | "All statuses"
const TABLE_DENSITY_OPTIONS: { value: TableDensity; label: string }[] = [
{ value: "compact", label: "Compact" },
{ value: "comfortable", label: "Comfortable" },
]
const ORDER_DIRECTION_OPTIONS: { value: OrderDirection; label: string }[] = [
{ value: "asc", label: "Asc" },
{ value: "desc", label: "Desc" },
]
function getDefaultExpandedReviewIds(reviews: IReleaseReview[]): ExpandedState {
const firstExpandableRow = reviews.find((review) => review.checks?.length)
if (!firstExpandableRow) {
return {}
}
return { [firstExpandableRow.id]: true }
}
function compareReviews(
reviewA: IReleaseReview,
reviewB: IReleaseReview,
sorting: SortingState
) {
const primarySort = sorting[0]
if (!primarySort) return 0
const direction = primarySort.desc ? -1 : 1
let baseValue = 0
switch (primarySort.id) {
case "window":
baseValue =
reviewA.windowStart.localeCompare(reviewB.windowStart) ||
reviewA.changeKey.localeCompare(reviewB.changeKey)
break
case "updated":
baseValue =
reviewA.updatedAt.localeCompare(reviewB.updatedAt) ||
reviewA.changeKey.localeCompare(reviewB.changeKey)
break
case "risk":
default:
baseValue =
reviewA.riskValue - reviewB.riskValue ||
reviewA.changeKey.localeCompare(reviewB.changeKey)
break
}
return baseValue * direction
}
function sortIdToOrdering(sortId: string | undefined): ReleaseOrdering {
if (sortId === "window") return "window"
if (sortId === "updated") return "updated"
return "risk"
}
function buildSorting(ordering: ReleaseOrdering, direction: OrderDirection) {
const id =
ordering === "window"
? "window"
: ordering === "updated"
? "updated"
: "risk"
return [{ id, desc: direction === "desc" }]
}
function getReviewSearchBlob(review: IReleaseReview) {
return [
review.changeKey,
review.title,
review.service.label,
review.environment,
review.owner.name,
review.status,
review.risk,
review.blastRadiusLabel,
]
.join(" ")
.toLowerCase()
}
export function ReleaseReviewGridView() {
const [reviews, setReviews] = useState<IReleaseReview[]>(RELEASE_REVIEWS)
const [searchQuery, setSearchQuery] = useState("")
const [serviceFilter, setServiceFilter] =
useState<ServiceFilter>("All services")
const [statusFilter, setStatusFilter] = useState<StatusFilter>("All statuses")
const [includeShipped, setIncludeShipped] = useState(false)
const [showChecks, setShowChecks] = useState(true)
const [tableDensity, setTableDensity] = useState<TableDensity>("compact")
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>(
buildSorting("risk", "desc")
)
const [expandedReviewIds, setExpandedReviewIds] = useState<ExpandedState>(
() => getDefaultExpandedReviewIds(RELEASE_REVIEWS)
)
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
owner: true,
risk: true,
window: false,
approvals: true,
updated: false,
})
const resetPagination = useCallback(() => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
)
}, [])
const ordering = sortIdToOrdering(sorting[0]?.id)
const direction: OrderDirection = sorting[0]?.desc ? "desc" : "asc"
const normalizedQuery = searchQuery.trim().toLowerCase()
const hasActiveFilters =
normalizedQuery.length > 0 ||
serviceFilter !== "All services" ||
statusFilter !== "All statuses"
const filteredReviews = useMemo(() => {
return reviews.filter((review) => {
if (!includeShipped && review.status === "Shipped") {
return false
}
if (
serviceFilter !== "All services" &&
review.service.label !== serviceFilter
) {
return false
}
if (statusFilter !== "All statuses" && review.status !== statusFilter) {
return false
}
if (
normalizedQuery.length > 0 &&
!getReviewSearchBlob(review).includes(normalizedQuery)
) {
return false
}
return true
})
}, [includeShipped, normalizedQuery, reviews, serviceFilter, statusFilter])
const sortedReviews = useMemo(
() => [...filteredReviews].sort((a, b) => compareReviews(a, b, sorting)),
[filteredReviews, sorting]
)
const hiddenShippedCount = useMemo(
() =>
includeShipped
? 0
: reviews.filter((review) => review.status === "Shipped").length,
[includeShipped, reviews]
)
const emptyMessage = useMemo(() => {
if (filteredReviews.length > 0) return undefined
if (!includeShipped && hiddenShippedCount > 0 && !hasActiveFilters) {
return `Only shipped reviews remain. Turn on Include shipped to show ${hiddenShippedCount} hidden ${hiddenShippedCount === 1 ? "record" : "records"}.`
}
return "No release reviews match this view."
}, [
filteredReviews.length,
hasActiveFilters,
hiddenShippedCount,
includeShipped,
])
const handleOrderingChange = useCallback(
(value: ReleaseOrdering | null) => {
if (!value) return
setSorting(buildSorting(value, direction))
resetPagination()
},
[direction, resetPagination]
)
const handleDirectionChange = useCallback(
(value: string[]) => {
const nextDirection = value[0] as OrderDirection | undefined
if (!nextDirection) return
setSorting(buildSorting(ordering, nextDirection))
resetPagination()
},
[ordering, resetPagination]
)
const handleShowChecksChange = useCallback(
(checked: boolean) => {
setShowChecks(checked)
setExpandedReviewIds((current) => {
if (!checked) return {}
return Object.keys(current).length > 0
? current
: getDefaultExpandedReviewIds(sortedReviews)
})
},
[sortedReviews]
)
const handleIncludeShippedChange = useCallback(
(checked: boolean) => {
setIncludeShipped(checked)
resetPagination()
},
[resetPagination]
)
const handleClearFilters = useCallback(() => {
setSearchQuery("")
setServiceFilter("All services")
setStatusFilter("All statuses")
resetPagination()
}, [resetPagination])
const handleReviewAction = useCallback(
(action: ReviewAction, review: IReleaseReview) => {
if (action === "open") {
toast.info("Open release review", {
description: `${review.changeKey} · ${review.title}`,
})
return
}
if (action === "approve") {
setReviews((current) =>
current.map((item) =>
item.id === review.id
? {
...item,
status: "Ready",
statusOrder: REVIEW_STATUS_ORDER.indexOf("Ready"),
approvalsApproved: item.approvalsRequired,
updatedAt: "2026-04-14T09:30:00Z",
updatedLabel: "Just now",
}
: item
)
)
resetPagination()
toast.success("Window approved", {
description: `${review.changeKey} is now ready for the scheduled window.`,
})
return
}
setReviews((current) =>
current.map((item) =>
item.id === review.id
? {
...item,
status: "Blocked",
statusOrder: REVIEW_STATUS_ORDER.indexOf("Blocked"),
risk: "Critical",
riskValue: 4,
updatedAt: "2026-04-14T09:30:00Z",
updatedLabel: "Just now",
}
: item
)
)
toast.warning("Risk escalated", {
description: `${review.changeKey} moved to the blocked queue.`,
})
},
[resetPagination]
)
const columns = useMemo(
() =>
createReleaseColumns({
onReviewAction: handleReviewAction,
showChecks,
}),
[handleReviewAction, showChecks]
)
const table = useReactTable({
data: sortedReviews,
columns,
state: {
pagination,
sorting,
expanded: expandedReviewIds,
columnVisibility,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
onExpandedChange: setExpandedReviewIds,
onColumnVisibilityChange: setColumnVisibility,
getRowId: (row) => row.id,
getRowCanExpand: (row) =>
showChecks && Boolean(row.original.checks?.length),
paginateExpandedRows: false,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
return (
<DataGrid
table={table}
recordCount={filteredReviews.length}
emptyMessage={emptyMessage}
tableLayout={{
dense: tableDensity === "compact",
headerSticky: true,
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
width: "auto",
}}
>
{/* Card */}
<ReleaseReviewCard
title="Release Reviews"
description="Upcoming windows, blockers, and checks."
action={
<Button type="button" size="sm">
<PlusIcon aria-hidden="true" />
New review
</Button>
}
footer={
<div className="flex w-full justify-end">
<DataGridPagination />
</div>
}
>
<div className="px-5 py-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-2">
<InputGroup className="w-full min-w-64 sm:w-72">
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search releases..."
aria-label="Search releases"
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value)
resetPagination()
}}
/>
{searchQuery.length > 0 ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
aria-label="Clear search"
size="icon-xs"
onClick={() => {
setSearchQuery("")
resetPagination()
}}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
<Select
value={serviceFilter}
onValueChange={(value) => {
setServiceFilter(value as ServiceFilter)
resetPagination()
}}
>
<SelectTrigger size="sm" className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="start">
<SelectGroup>
<SelectItem value="All services">All services</SelectItem>
{SERVICE_OPTIONS.map((service) => (
<SelectItem key={service.value} value={service.label}>
{service.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select
value={statusFilter}
onValueChange={(value) => {
setStatusFilter(value as StatusFilter)
resetPagination()
}}
>
<SelectTrigger size="sm" className="w-[170px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="start">
<SelectGroup>
<SelectItem value="All statuses">All statuses</SelectItem>
{REVIEW_STATUS_ORDER.map((status) => (
<SelectItem key={status} value={status}>
{status}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{hasActiveFilters ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={handleClearFilters}
>
Clear
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Popover>
<PopoverTrigger
render={
<Button type="button" size="sm" variant="outline">
<Settings2Icon aria-hidden="true" />
View Settings
</Button>
}
/>
<PopoverContent align="end" className="w-[340px] p-0">
<FieldGroup className="gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0">
<Field
orientation="horizontal"
className="min-h-9 items-center justify-between gap-3"
>
<FieldLabel className="text-sm font-normal">
Ordering
</FieldLabel>
<Select
value={ordering}
onValueChange={handleOrderingChange}
>
<SelectTrigger
size="sm"
className="w-[132px] shrink-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{ORDERING_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field
orientation="horizontal"
className="min-h-9 items-center justify-between gap-3"
>
<FieldLabel className="text-sm font-normal">
Direction
</FieldLabel>
<ToggleGroup
multiple={false}
value={[direction]}
onValueChange={handleDirectionChange}
variant="outline"
size="sm"
className="ml-auto w-[132px] shrink-0 justify-end"
>
{ORDER_DIRECTION_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
className="grow"
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</Field>
<Field
orientation="horizontal"
className="min-h-9 items-center justify-between gap-3"
>
<FieldLabel className="text-sm font-normal">
Density
</FieldLabel>
<Select
value={tableDensity}
onValueChange={(value) =>
setTableDensity(value as TableDensity)
}
>
<SelectTrigger
size="sm"
className="w-[132px] shrink-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{TABLE_DENSITY_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</div>
<FieldSeparator />
<div className="flex flex-col gap-0">
<Field
orientation="horizontal"
className="min-h-9 items-center justify-between gap-3"
>
<FieldLabel className="text-sm font-normal">
Check cards
</FieldLabel>
<Switch
size="sm"
checked={showChecks}
onCheckedChange={handleShowChecksChange}
/>
</Field>
<Field
orientation="horizontal"
className="min-h-9 items-center justify-between gap-3"
>
<FieldLabel className="text-sm font-normal">
Include shipped
</FieldLabel>
<Switch
size="sm"
checked={includeShipped}
onCheckedChange={handleIncludeShippedChange}
/>
</Field>
</div>
<FieldSeparator />
<div className="flex flex-col gap-0">
<span className="text-muted-foreground px-0.5 pb-1 text-xs font-medium">
Columns
</span>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<Field
key={column.id}
orientation="horizontal"
className="min-h-9 items-center justify-between gap-3"
>
<FieldLabel className="text-sm font-normal">
{getColumnHeaderLabel(column)}
</FieldLabel>
<Switch
size="sm"
checked={column.getIsVisible()}
onCheckedChange={(checked) =>
column.toggleVisibility(checked)
}
/>
</Field>
))}
</div>
</FieldGroup>
</PopoverContent>
</Popover>
</div>
</div>
</div>
<Separator />
<DataGridContainer border={false}>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
</ReleaseReviewCard>
</DataGrid>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
import { type ComponentProps } from "react"
import { Badge } from "@/components/reui/badge"
import { Rating } from "@/components/reui/rating"
import { cn } from "@evobgp/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
type CheckStatus,
type IReleaseCheck,
type IReleaseReview,
} from "./data"
const MAX_VISIBLE_CHECK_CARDS = 3
const checkStatusConfig: Record<
CheckStatus,
{
variant: ComponentProps<typeof Badge>["variant"]
dotClassName: string
}
> = {
Open: {
variant: "invert-light",
dotClassName: "bg-zinc-400 dark:bg-zinc-500",
},
Done: {
variant: "success-light",
dotClassName: "bg-emerald-500 dark:bg-emerald-400",
},
Flagged: {
variant: "destructive-light",
dotClassName: "bg-rose-500 dark:bg-rose-400",
},
}
function DotSeparator() {
return (
<span
className="size-1 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500"
aria-hidden="true"
/>
)
}
function initials(name: string) {
return name
.split(" ")
.map((part) => part[0])
.join("")
}
function coverageRingColor(coverage: number) {
if (coverage >= 85) return "text-emerald-500"
if (coverage >= 60) return "text-amber-500"
return "text-rose-500"
}
function CheckStatusBadge({ status }: { status: CheckStatus }) {
return (
<Badge variant={checkStatusConfig[status].variant}>
<span
className={`${checkStatusConfig[status].dotClassName} size-1.5 shrink-0 rounded-full`}
aria-hidden="true"
/>
{status}
</Badge>
)
}
function CoverageRing({
coverage,
className,
}: {
coverage: number
className?: string
}) {
const radius = 8
const circumference = 2 * Math.PI * radius
const dashOffset = circumference - (coverage / 100) * circumference
return (
<div className={cn("flex items-center gap-1.5", className)}>
<svg
viewBox="0 0 24 24"
className={cn("size-4.5 shrink-0", coverageRingColor(coverage))}
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r={radius}
fill="none"
className="stroke-border"
strokeWidth="2.5"
/>
<circle
cx="12"
cy="12"
r={radius}
fill="none"
className="stroke-current"
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
transform="rotate(-90 12 12)"
/>
</svg>
<span className="text-muted-foreground text-xs tabular-nums">
{coverage}%
</span>
</div>
)
}
function ReleaseCheckCard({ check }: { check: IReleaseCheck }) {
return (
<Card className="h-full w-full min-w-0 gap-0 p-0 shadow-none! sm:w-[22rem]">
{/* Content */}
<CardContent className="flex flex-col gap-2.5 p-3">
<div className="flex items-start gap-3">
<CheckStatusBadge status={check.status} />
</div>
<div className="flex min-w-0 flex-col gap-1">
<h4 className="text-foreground line-clamp-2 text-sm leading-5 font-medium">
{check.title}
</h4>
<div className="text-muted-foreground flex min-w-0 flex-col gap-1 text-xs sm:flex-row sm:items-center sm:gap-2">
<div className="flex min-w-0 items-center gap-2">
<Avatar className="size-6 shrink-0">
{check.reviewer?.avatar ? (
<AvatarImage
src={check.reviewer.avatar}
alt={check.reviewer.name}
/>
) : null}
<AvatarFallback>
{check.reviewer ? initials(check.reviewer.name) : "--"}
</AvatarFallback>
</Avatar>
<span className="truncate">
{check.reviewer?.name ?? "Reviewer pending"}
</span>
</div>
<span className="hidden sm:inline-flex">
<DotSeparator />
</span>
<span className="shrink-0">{check.dueLabel}</span>
</div>
</div>
<div className="flex items-center justify-between gap-3 pt-3.5">
<div className="flex items-center gap-1.5">
<Rating rating={check.confidence} size="sm" showValue={false} />
<span className="text-muted-foreground text-xs tabular-nums">
{check.confidence.toFixed(1)}
</span>
</div>
<CoverageRing coverage={check.coverage} className="ml-auto" />
</div>
</CardContent>
</Card>
)
}
export function ReleaseCheckRail({ review }: { review: IReleaseReview }) {
const checks = review.checks ?? []
if (checks.length === 0) return null
const visibleChecks = checks.slice(0, MAX_VISIBLE_CHECK_CARDS)
const hiddenCheckCount = checks.length - visibleChecks.length
const openChecks = checks.filter((check) => check.status !== "Done").length
return (
<div>
<div className="flex flex-wrap items-center gap-2 px-5 pt-3 pb-2">
<span className="text-foreground text-xs font-medium">Checklist</span>
<DotSeparator />
<span className="text-muted-foreground text-xs">
{hiddenCheckCount > 0
? `${visibleChecks.length} of ${checks.length} items`
: `${checks.length} items`}
</span>
<DotSeparator />
<span className="text-muted-foreground text-xs">{openChecks} open</span>
{hiddenCheckCount > 0 ? (
<>
<DotSeparator />
<span className="text-muted-foreground text-xs">
+{hiddenCheckCount} more
</span>
</>
) : null}
</div>
{/* Cards */}
<div className="flex w-full max-w-[70rem] flex-wrap gap-3 px-5 pt-1 pb-4">
{visibleChecks.map((check) => (
<ReleaseCheckCard key={check.id} check={check} />
))}
</div>
</div>
)
}
@@ -0,0 +1,60 @@
import { type ReactNode } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@evobgp/ui/components/card"
interface ReleaseReviewCardProps {
title: string
description: ReactNode
action?: ReactNode
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
footerClassName?: string
}
export function ReleaseReviewCard({
title,
description,
action,
children,
footer,
className,
contentClassName,
footerClassName,
}: ReleaseReviewCardProps) {
return (
<Card className={cn("w-full gap-0 p-0", className)}>
{/* Header */}
<CardHeader className="gap-1 border-b px-5 py-3.5">
<CardTitle>{title}</CardTitle>
<CardDescription className="text-xs leading-4">
{description}
</CardDescription>
{action ? (
<CardAction className="self-center">{action}</CardAction>
) : null}
</CardHeader>
{/* Content */}
<CardContent className={cn("p-0", contentClassName)}>
{children}
</CardContent>
{footer ? (
<CardFooter className={cn("border-t px-5 py-2.5", footerClassName)}>
{footer}
</CardFooter>
) : null}
</Card>
)
}
@@ -0,0 +1,15 @@
import { ReleaseReviewGridView } from "./components/data-grid-view"
export function Page() {
return (
<main
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Release review data grid
</h1>
<ReleaseReviewGridView />
</main>
)
}
@@ -0,0 +1,426 @@
import { memo, useState } from "react"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
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 { Row, type ColumnDef } from "@tanstack/react-table"
import { toast } from "sonner"
import { cn } from "@evobgp/ui/lib/utils"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@evobgp/ui/components/alert-dialog"
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 { Skeleton } from "@evobgp/ui/components/skeleton"
import { ITransaction, TxChannel, TxStatus } from "./data"
import { MoreHorizontalIcon, EyeIcon, CopyIcon, DownloadIcon, TriangleAlertIcon, ArrowRightIcon } from "lucide-react"
// ── Formatters ──
function formatAmount(amount: number, currency: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
minimumFractionDigits: 2,
}).format(Math.abs(amount))
}
function formatDate(iso: string) {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(iso))
}
// ── Status (aligned with data-grid-1 badge language) ──
export const StatusBadge = memo(function StatusBadge({
status,
}: {
status: TxStatus
}) {
if (status === "completed")
return <Badge variant="success-outline">Completed</Badge>
if (status === "failed")
return <Badge variant="destructive-outline">Failed</Badge>
if (status === "pending")
return <Badge variant="warning-outline">Pending</Badge>
return <Badge variant="info-outline">Cancelled</Badge>
})
// ── Channel (origin of charge - replaces "method" duplicate logos) ──
const channelLabel: Record<TxChannel, string> = {
checkout: "Checkout",
api: "API",
invoice: "Invoice",
dashboard: "Dashboard",
}
const channelVariant: Record<
TxChannel,
"success-light" | "info-light" | "warning-light"
> = {
checkout: "success-light",
api: "info-light",
invoice: "warning-light",
dashboard: "success-light",
}
export const ChannelBadge = memo(function ChannelBadge({
channel,
}: {
channel: TxChannel
}) {
return <Badge variant="secondary">{channelLabel[channel]}</Badge>
})
// ── Actions ──
export function ActionsCell({ row }: { row: Row<ITransaction> }) {
const { copyToClipboard } = useCopyToClipboard()
const [disputeOpen, setDisputeOpen] = useState(false)
const handleCopyRef = () => {
copyToClipboard(row.original.reference)
toast.success("Reference copied", { description: row.original.reference })
}
const handleDisputeConfirm = () => {
setDisputeOpen(false)
toast.message("Dispute opened", {
description: `${row.original.reference}. Connect your payments API (demo).`,
})
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label="Transaction actions"
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" className="min-w-40">
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() =>
toast.info("Transaction details", {
description: "Demo. Navigate to your detail view.",
})
}
>
<EyeIcon className="size-4" aria-hidden="true" />
View Details
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyRef}>
<CopyIcon className="size-4" aria-hidden="true" />
Copy Reference
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.success("Receipt", {
description: "Demo. Download from your storage.",
})
}
>
<DownloadIcon className="size-4" aria-hidden="true" />
Receipt
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDisputeOpen(true)}
>
<TriangleAlertIcon className="size-4" aria-hidden="true" />
Dispute
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={disputeOpen} onOpenChange={setDisputeOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Open a dispute?</AlertDialogTitle>
<AlertDialogDescription>
This starts a dispute for{""}
<span className="text-foreground font-mono text-xs">
{row.original.reference}
</span>
. In production this is irreversible until resolved. Wire to your
payment provider.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDisputeConfirm}
>
Start dispute
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export const columns: ColumnDef<ITransaction>[] = [
{
accessorKey: "id",
id: "id",
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
enableSorting: false,
size: 35,
enableResizing: false,
enableHiding: false,
meta: {
skeleton: <Skeleton className="size-4 rounded" />,
headerClassName:
"[--data-grid-header-cell-ps:var(--frame-panel-header-px)]",
cellClassName: "[--data-grid-body-cell-ps:var(--frame-panel-px)]",
},
},
{
accessorKey: "reference",
id: "reference",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<div className="flex min-w-0 flex-col gap-0.5">
<span className="group/ref text-foreground hover:text-primary inline-flex min-w-0 cursor-pointer items-center gap-1 truncate font-mono text-xs font-medium transition-colors">
<span className="group-hover/ref:border-primary border-b border-transparent py-0.25 transition-colors">
{row.original.reference}
</span>
<ArrowRightIcon className="size-3 shrink-0 -translate-x-1 opacity-0 transition-all group-hover/ref:translate-x-0 group-hover/ref:opacity-100" aria-hidden="true" />
</span>
<span className="text-muted-foreground text-xs">
{formatDate(row.original.date)}
</span>
</div>
),
size: 150,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Reference",
skeleton: (
<div className="flex min-w-0 flex-col gap-0.5">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3 w-20" />
</div>
),
},
},
{
accessorKey: "description",
id: "description",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<div className="tems-center flex gap-2.5">
<Item className="w-auto shrink-0 border-0 p-0 [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto">
{row.original.merchantLogo}
</ItemMedia>
</Item>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground truncate text-sm font-medium">
{row.original.description}
</span>
<span className="text-muted-foreground truncate text-xs">
{row.original.merchant} · {row.original.category}
</span>
</div>
</div>
),
minSize: 150,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Description",
skeleton: (
<div className="flex items-center gap-2.5">
<Skeleton className="size-5 shrink-0 rounded" />
<div className="flex min-w-0 flex-col gap-0.5">
<Skeleton className="h-3.5 w-36" />
<Skeleton className="h-3 w-28" />
</div>
</div>
),
},
},
{
accessorKey: "country",
id: "region",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<img
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
alt=""
width={16}
height={16}
loading="lazy"
decoding="async"
className="size-4 shrink-0 rounded-full object-cover"
/>
<span className="text-foreground line-clamp-1 text-sm font-medium">
{row.original.country}
</span>
</div>
{row.original.timezone && (
<span className="text-muted-foreground line-clamp-1 text-xs tabular-nums">
{row.original.timezone}
</span>
)}
</div>
),
size: 170,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Region",
skeleton: (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<Skeleton className="size-4 shrink-0 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-3 w-28" />
</div>
),
},
},
{
accessorKey: "channel",
id: "channel",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ChannelBadge channel={row.original.channel} />,
size: 120,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Channel",
skeleton: <Skeleton className="h-6 w-20 rounded-full" />,
},
},
{
accessorKey: "amount",
id: "amount",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => {
const { amount, currency } = row.original
const inflow = amount > 0
return (
<span
className={cn("font-semibold tabular-nums", inflow && "text-success")}
>
{inflow ? "+" : ""}
{formatAmount(amount, currency)}
</span>
)
},
size: 100,
enableSorting: true,
enableHiding: true,
meta: {
headerTitle: "Amount",
skeleton: <Skeleton className="h-4 w-20" />,
},
},
{
accessorKey: "account",
id: "account",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<span className="text-foreground font-mono text-xs">
{row.original.account}
</span>
),
size: 160,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Account",
skeleton: <Skeleton className="h-4 w-24" />,
},
},
{
accessorKey: "status",
id: "status",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
size: 90,
enableSorting: true,
enableHiding: true,
enableResizing: false,
meta: {
headerTitle: "Status",
skeleton: <Skeleton className="h-6 w-20 rounded-full" />,
},
},
{
id: "actions",
header: "",
cell: ({ row }) => <ActionsCell row={row} />,
size: 50,
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: {
skeleton: <Skeleton className="size-7 rounded" />,
headerClassName:
"[--data-grid-header-cell-pe:var(--frame-panel-header-px)]",
cellClassName: "[--data-grid-body-cell-pe:var(--frame-panel-px)]",
},
},
]
@@ -0,0 +1,597 @@
"use client"
import { useCallback, useEffect, useRef, 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 } from "@/components/reui/data-grid/data-grid-table"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
ColumnSizingState,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { Button } from "@evobgp/ui/components/button"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import { Separator } from "@evobgp/ui/components/separator"
import { TooltipProvider } from "@evobgp/ui/components/tooltip"
import { ChannelBadge, columns, StatusBadge } from "./columns"
import {
TRANSACTIONS,
type ITransaction,
type TxChannel,
type TxStatus,
} from "./data"
import { HashIcon, FileTextIcon, Building2Icon, FolderIcon, GlobeIcon, RouteIcon, CircleDotIcon, PlusIcon, FilterIcon, FunnelXIcon } from "lucide-react"
// ── Helpers ──
function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (
values.every((value) => typeof value === "string" && value.trim() === "")
)
return false
if (values.every((value) => value === null || value === undefined))
return false
if (values.every((value) => Array.isArray(value) && value.length === 0))
return false
return true
})
}
/** Stable key for comparing whether applied (active) filters meaningfully changed. */
function serializeActiveFiltersKey(active: Filter[]) {
return JSON.stringify(
active.map((f) => ({
field: f.field,
operator: f.operator,
values: f.values,
}))
)
}
function filterFieldValue(item: ITransaction, field: string): unknown {
if (field === "region") {
return [item.country, item.timezone].filter(Boolean).join(" ")
}
return item[field as keyof ITransaction]
}
function applyFiltersToData(
data: ITransaction[],
filters: Filter[]
): ITransaction[] {
const active = getActiveFilters(filters)
let result = [...data]
active.forEach((filter) => {
const { field, operator, values } = filter
result = result.filter((item) => {
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? raw : ""
switch (operator) {
case "is":
return values.includes(fieldValue)
case "is_not":
return !values.includes(fieldValue)
case "is_any_of":
return values.some((v) => fieldValue === v)
case "is_not_any_of":
return !values.some((v) => fieldValue === v)
case "contains": {
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase())
)
}
case "not_contains":
return !values.some((v) =>
String(fieldValue).toLowerCase().includes(String(v).toLowerCase())
)
case "starts_with":
return values.some((v) =>
String(fieldValue).toLowerCase().startsWith(String(v).toLowerCase())
)
case "ends_with":
return values.some((v) =>
String(fieldValue).toLowerCase().endsWith(String(v).toLowerCase())
)
case "equals":
return fieldValue === values[0]
case "not_equals":
return fieldValue !== values[0]
case "greater_than":
return Number(fieldValue) > Number(values[0])
case "less_than":
return Number(fieldValue) < Number(values[0])
case "greater_than_or_equal":
return Number(fieldValue) >= Number(values[0])
case "less_than_or_equal":
return Number(fieldValue) <= Number(values[0])
case "between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) >= min && Number(fieldValue) <= max
}
return true
case "not_between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) < min || Number(fieldValue) > max
}
return true
case "empty":
return fieldValue === "" || fieldValue == null
case "not_empty":
return fieldValue !== "" && fieldValue != null
default:
return true
}
})
})
return result
}
// ── Filter field config ──
const CATEGORY_OPTIONS = [
"Payments",
"Infrastructure",
"AI / ML",
"Automation",
"Design",
"Documentation",
"Backend",
"Developer tools",
]
const STATUS_OPTIONS: { value: TxStatus; label: string }[] = [
{ value: "completed", label: "Completed" },
{ value: "pending", label: "Pending" },
{ value: "failed", label: "Failed" },
{ value: "cancelled", label: "Cancelled" },
]
const CHANNEL_OPTIONS: { value: TxChannel; label: string }[] = [
{ value: "checkout", label: "Checkout" },
{ value: "api", label: "API" },
{ value: "invoice", label: "Invoice" },
{ value: "dashboard", label: "Dashboard" },
]
const MERCHANT_FILTER_OPTIONS = Array.from(
new Map(
TRANSACTIONS.map((transaction) => [
transaction.merchant,
{
value: transaction.merchant,
label: transaction.merchant,
icon: (
<Item
render={<span />}
className="w-auto shrink-0 border-0 p-0 [&_svg]:size-4"
>
<ItemMedia variant="icon" className="size-auto">
{transaction.merchantLogo}
</ItemMedia>
</Item>
),
},
])
).values()
)
function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return "Select..."
if (values.length > 1) return `${values.length} selected`
return null
}
const filterFields: FilterFieldConfig[] = [
{
key: "reference",
label: "Reference",
icon: (
<HashIcon className="size-3.5" aria-hidden />
),
type: "text",
className: "w-44",
placeholder: "Search...",
},
{
key: "description",
label: "Description",
icon: (
<FileTextIcon className="size-3.5" aria-hidden />
),
type: "text",
className: "w-52",
placeholder: "Search...",
},
{
key: "merchant",
label: "Merchant",
icon: (
<Building2Icon className="size-3.5" aria-hidden />
),
type: "select",
searchable: true,
className: "w-[200px]",
options: MERCHANT_FILTER_OPTIONS,
customValueRenderer: (values, options) => {
const state = renderSelectedCount(values)
if (state) return state
const option = options.find((item) => item.value === values[0])
if (!option) return String(values[0])
return (
<div className="flex items-center gap-2">
{option.icon}
<span className="truncate">{option.label}</span>
</div>
)
},
},
{
key: "category",
label: "Category",
icon: (
<FolderIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: true,
className: "w-[200px]",
options: CATEGORY_OPTIONS.map((category) => ({
value: category,
label: category,
})),
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return <Badge variant="outline">{String(values[0])}</Badge>
},
},
{
key: "region",
label: "Region",
icon: (
<GlobeIcon className="size-3.5" aria-hidden />
),
type: "text",
className: "w-52",
placeholder: "Search...",
},
{
key: "channel",
label: "Channel",
icon: (
<RouteIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[156px]",
options: CHANNEL_OPTIONS,
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return <ChannelBadge channel={values[0] as TxChannel} />
},
},
{
key: "status",
label: "Status",
icon: (
<CircleDotIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[156px]",
options: STATUS_OPTIONS,
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return <StatusBadge status={values[0] as TxStatus} />
},
},
]
/** Single default row: Reference | contains | empty - inactive until user types (see getActiveFilters). */
function createDefaultTransactionFilters(): Filter[] {
return [createFilter("reference", "contains", [""])]
}
const DESCRIPTION_COLUMN_ID = "description"
const DESCRIPTION_COLUMN_DEFAULT_SIZE = 300
function getAutoDescriptionColumnSize(
containerWidth: number,
columnVisibility: VisibilityState
) {
const occupiedWidth = columns.reduce((total, column) => {
if (
!column.id ||
column.id === DESCRIPTION_COLUMN_ID ||
columnVisibility[column.id] === false
) {
return total
}
return total + (typeof column.size === "number" ? column.size : 0)
}, 0)
return Math.max(
DESCRIPTION_COLUMN_DEFAULT_SIZE,
Math.round(containerWidth - occupiedWidth)
)
}
// ── Main component ──
export function DataGridView() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "reference", desc: true },
])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
account: false,
})
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({})
const [filters, setFilters] = useState<Filter[]>(
createDefaultTransactionFilters
)
const [isLoading, setIsLoading] = useState(false)
const [filteredData, setFilteredData] = useState<ITransaction[]>(TRANSACTIONS)
const [gridWidth, setGridWidth] = useState(0)
const isInitialLoad = useRef(true)
const gridWidthRef = useRef<HTMLDivElement>(null)
const hasManualColumnSizing = useRef(false)
const lastAppliedActiveKey = useRef<string>(
serializeActiveFiltersKey(
getActiveFilters(createDefaultTransactionFilters())
)
)
const applyFilters = useCallback((newFilters: Filter[]) => {
return applyFiltersToData(TRANSACTIONS, newFilters)
}, [])
const simulateAsyncFiltering = useCallback(
async (newFilters: Filter[]) => {
setIsLoading(true)
await new Promise((resolve) => setTimeout(resolve, 400))
setFilteredData(applyFilters(newFilters))
setIsLoading(false)
},
[applyFilters]
)
const handleFiltersChange = useCallback(
(newFilters: Filter[]) => {
setFilters(newFilters)
const newActive = getActiveFilters(newFilters)
const nextKey = serializeActiveFiltersKey(newActive)
if (nextKey === lastAppliedActiveKey.current) return
lastAppliedActiveKey.current = nextKey
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
simulateAsyncFiltering(newFilters)
},
[simulateAsyncFiltering]
)
useEffect(() => {
if (isInitialLoad.current) {
setFilteredData(applyFilters(filters))
isInitialLoad.current = false
}
}, [filters, applyFilters])
useEffect(() => {
const element = gridWidthRef.current
if (!element) return
const syncGridWidth = () => {
setGridWidth(element.clientWidth)
}
syncGridWidth()
if (typeof ResizeObserver === "undefined") return
const observer = new ResizeObserver(syncGridWidth)
observer.observe(element)
return () => {
observer.disconnect()
}
}, [])
useEffect(() => {
if (gridWidth <= 0 || hasManualColumnSizing.current) return
const nextDescriptionWidth = getAutoDescriptionColumnSize(
gridWidth,
columnVisibility
)
setColumnSizing((current) =>
current[DESCRIPTION_COLUMN_ID] === nextDescriptionWidth
? current
: {
...current,
[DESCRIPTION_COLUMN_ID]: nextDescriptionWidth,
}
)
}, [columnVisibility, gridWidth])
const [columnOrder, setColumnOrder] = useState<string[]>(
columns.map((c) => c.id as string)
)
const handleColumnSizingChange = useCallback(
(
updater:
| ColumnSizingState
| ((old: ColumnSizingState) => ColumnSizingState)
) => {
hasManualColumnSizing.current = true
setColumnSizing(updater)
},
[]
)
const table = useReactTable({
columns,
data: filteredData,
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
getRowId: (row) => row.id,
state: {
pagination,
sorting,
columnOrder,
columnSizing,
columnVisibility,
},
columnResizeMode: "onChange",
onColumnOrderChange: setColumnOrder,
onColumnSizingChange: handleColumnSizingChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
/** Show Clear whenever any filter row is visible (including empty placeholders). */
const showClearButton = filters.length > 0
return (
<TooltipProvider delay={200}>
{/* Table */}
<DataGrid
table={table}
isLoading={isLoading}
loadingMode="skeleton"
recordCount={filteredData.length}
emptyMessage={
!isLoading && filteredData.length === 0
? "No transactions match your filters."
: undefined
}
tableLayout={{
columnsPinnable: true,
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
dense: true,
}}
>
<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 id="page-heading" className="text-balance">
Transactions
</FrameTitle>
<FrameDescription className="text-xs text-pretty">
Billing ledger(refunds / payouts)
</FrameDescription>
</div>
<Button
type="button"
onClick={() =>
toast.info("New transaction", {
description:
"Wire to your checkout or manual entry flow, demo only.",
})
}
>
<PlusIcon aria-hidden />
New transaction
</Button>
</FrameHeader>
<FramePanel className="p-0! shadow-none!">
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button variant="outline" aria-label="Filters">
<FilterIcon aria-hidden />
Filters
</Button>
}
/>
{showClearButton && (
<Button
variant="outline"
className="shrink-0"
onClick={() => {
const next = createDefaultTransactionFilters()
lastAppliedActiveKey.current = serializeActiveFiltersKey(
getActiveFilters(next)
)
setFilters(next)
simulateAsyncFiltering(next)
}}
disabled={isLoading}
>
<FunnelXIcon className="size-3.5" aria-hidden />
Clear
</Button>
)}
</div>
<Separator />
<div ref={gridWidthRef} className="w-full">
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</div>
</FramePanel>
<FrameFooter>
<DataGridPagination />
</FrameFooter>
</Frame>
</DataGrid>
</TooltipProvider>
)
}
@@ -0,0 +1,263 @@
import { type ReactNode } from "react"
import { AnthropicBlack } from "@evobgp/ui/components/svgs/anthropicBlack"
import { AnthropicWhite } from "@evobgp/ui/components/svgs/anthropicWhite"
import { Convex } from "@evobgp/ui/components/svgs/convex"
import { Mintlify } from "@evobgp/ui/components/svgs/mintlify"
import { N8n } from "@evobgp/ui/components/svgs/n8n"
import { Neon } from "@evobgp/ui/components/svgs/neon"
import { Openai } from "@evobgp/ui/components/svgs/openai"
import { OpenaiDark } from "@evobgp/ui/components/svgs/openaiDark"
import { Paper } from "@evobgp/ui/components/svgs/paper"
import { Prisma } from "@evobgp/ui/components/svgs/prisma"
import { PrismaDark } from "@evobgp/ui/components/svgs/prismaDark"
import { Stripe } from "@evobgp/ui/components/svgs/stripe"
import { Supabase } from "@evobgp/ui/components/svgs/supabase"
// ── Types ──
export type TxType = "payment" | "refund" | "payout" | "charge" | "transfer"
export type TxStatus = "completed" | "pending" | "failed" | "cancelled"
/** Where the charge originated (billing SaaS pattern - replaces duplicate "method" logos). */
export type TxChannel = "checkout" | "api" | "invoice" | "dashboard"
export interface ITransaction {
id: string
reference: string
date: string
description: string
merchant: string
merchantLogo: ReactNode
category: string
type: TxType
channel: TxChannel
amount: number
currency: string
status: TxStatus
account: string
country: string
flag: string
timezone?: string
note?: string
}
// ── Logos (one mark per vendor - @/components/ui/svgs only) ──
const OPENAI_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<Openai className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<OpenaiDark className="size-5" />
</span>
</>
)
const PRISMA_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<Prisma className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<PrismaDark className="size-5" />
</span>
</>
)
const ANTHROPIC_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<AnthropicBlack className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<AnthropicWhite className="size-5" />
</span>
</>
)
// ── Data: 10 rows, unique merchants, inbound + outbound (refunds / payouts) ──
export const TRANSACTIONS: ITransaction[] = [
{
id: "txn_001",
reference: "TXN-2025-000143",
date: "2025-06-12T09:14:00Z",
description: "Pro plan · subscription renewal",
merchant: "Stripe",
merchantLogo: <Stripe className="size-5" aria-hidden="true" />,
category: "Payments",
type: "charge",
channel: "checkout",
amount: 240.0,
currency: "USD",
status: "completed",
account: "•••• 4242",
country: "United States",
flag: "us",
timezone: "PST (UTC8)",
},
{
id: "txn_002",
reference: "TXN-2025-000142",
date: "2025-06-11T16:30:00Z",
description: "Database compute · June cycle",
merchant: "Supabase",
merchantLogo: <Supabase className="size-5" aria-hidden="true" />,
category: "Infrastructure",
type: "charge",
channel: "dashboard",
amount: 75.0,
currency: "USD",
status: "completed",
account: "•••• 4242",
country: "United States",
flag: "us",
timezone: "EST (UTC5)",
},
{
id: "txn_003",
reference: "TXN-2025-000141",
date: "2025-06-11T11:02:00Z",
description: "GPT-4o usage · billing period",
merchant: "OpenAI",
merchantLogo: OPENAI_LOGO,
category: "AI / ML",
type: "charge",
channel: "api",
amount: 312.48,
currency: "USD",
status: "completed",
account: "•••• 4242",
country: "United States",
flag: "us",
timezone: "PST (UTC8)",
},
{
id: "txn_004",
reference: "TXN-2025-000140",
date: "2025-06-10T08:50:00Z",
description: "Workflow credit · billing adjustment (outbound)",
merchant: "N8n",
merchantLogo: <N8n className="size-5" aria-hidden="true" />,
category: "Automation",
type: "refund",
channel: "dashboard",
amount: -21.0,
currency: "USD",
status: "completed",
account: "[email protected]",
country: "Germany",
flag: "de",
timezone: "CET (UTC+1)",
},
{
id: "txn_005",
reference: "TXN-2025-000139",
date: "2025-06-09T14:15:00Z",
description: "Canvas seats · prorated refund to customer",
merchant: "Paper",
merchantLogo: <Paper className="size-5" aria-hidden="true" />,
category: "Design",
type: "refund",
channel: "invoice",
amount: -96.0,
currency: "USD",
status: "pending",
account: "•••• 0011",
country: "United Kingdom",
flag: "gb",
timezone: "GMT (UTC+0)",
},
{
id: "txn_006",
reference: "TXN-2025-000138",
date: "2025-06-08T18:44:00Z",
description: "Docs hosting · team",
merchant: "Mintlify",
merchantLogo: <Mintlify className="size-5" aria-hidden="true" />,
category: "Documentation",
type: "charge",
channel: "checkout",
amount: 45.0,
currency: "USD",
status: "completed",
account: "•••• 4242",
country: "United States",
flag: "us",
timezone: "CST (UTC6)",
},
{
id: "txn_007",
reference: "TXN-2025-000137",
date: "2025-06-08T12:22:00Z",
description: "Function invocations · May",
merchant: "Convex",
merchantLogo: <Convex className="size-5" aria-hidden="true" />,
category: "Backend",
type: "charge",
channel: "api",
amount: 25.0,
currency: "USD",
status: "failed",
account: "•••• 9871",
country: "United States",
flag: "us",
timezone: "PST (UTC8)",
note: "Card declined, retry scheduled",
},
{
id: "txn_008",
reference: "TXN-2025-000136",
date: "2025-06-07T10:05:00Z",
description: "Payout to linked bank · hobby tier balance",
merchant: "Neon",
merchantLogo: <Neon className="size-5" aria-hidden="true" />,
category: "Infrastructure",
type: "payout",
channel: "dashboard",
amount: -250.0,
currency: "USD",
status: "completed",
account: "[email protected]",
country: "Germany",
flag: "de",
timezone: "CET (UTC+1)",
},
{
id: "txn_009",
reference: "TXN-2025-000135",
date: "2025-06-06T15:00:00Z",
description: "ORM · team license",
merchant: "Prisma",
merchantLogo: PRISMA_LOGO,
category: "Developer tools",
type: "charge",
channel: "invoice",
amount: 60.0,
currency: "USD",
status: "completed",
account: "•••• 4242",
country: "Germany",
flag: "de",
timezone: "CET (UTC+1)",
},
{
id: "txn_010",
reference: "TXN-2025-000134",
date: "2025-06-05T11:11:00Z",
description: "Claude API · May usage",
merchant: "Anthropic",
merchantLogo: ANTHROPIC_LOGO,
category: "AI / ML",
type: "payment",
channel: "api",
amount: 2500.0,
currency: "USD",
status: "completed",
account: "[email protected]",
country: "Canada",
flag: "ca",
timezone: "EST (UTC5)",
},
]
@@ -0,0 +1,15 @@
import { DataGridView } from "./components/data-grid-view"
export function Page() {
return (
<main
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Billing ledger data grid
</h1>
<DataGridView />
</main>
)
}
@@ -0,0 +1,408 @@
"use client"
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 { Rating } from "@/components/reui/rating"
import { type ReactNode } from "react"
import { type ColumnDef } from "@tanstack/react-table"
import { format, parseISO } from "date-fns"
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 { Switch } from "@evobgp/ui/components/switch"
import {
type AutomationKind,
type AutomationOwnerAvailability,
type AutomationState,
type IAutomationRecord,
} from "./data"
import { GitBranchIcon, RouteIcon, SparklesIcon, MailIcon, BellRingIcon, EllipsisVerticalIcon, PencilIcon, EyeIcon, CircleCheckIcon, ArchiveIcon } from "lucide-react"
export type AutomationAction = "edit" | "open" | "archive"
const automationKindStyles: Record<
AutomationKind,
{ chipClassName: string; icon: ReactNode }
> = {
sequence: {
chipClassName:
"bg-sky-50 text-sky-600 ring-sky-200 dark:bg-sky-500/15 dark:text-sky-300 dark:ring-sky-400/30",
icon: (
<GitBranchIcon className="size-4" aria-hidden="true" />
),
},
routing: {
chipClassName:
"bg-violet-50 text-violet-600 ring-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:ring-violet-400/30",
icon: (
<RouteIcon className="size-4" aria-hidden="true" />
),
},
enrichment: {
chipClassName:
"bg-emerald-50 text-emerald-600 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-400/30",
icon: (
<SparklesIcon className="size-4" aria-hidden="true" />
),
},
digest: {
chipClassName:
"bg-amber-50 text-amber-600 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:ring-amber-400/30",
icon: (
<MailIcon className="size-4" aria-hidden="true" />
),
},
escalation: {
chipClassName:
"bg-rose-50 text-rose-600 ring-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:ring-rose-400/30",
icon: (
<BellRingIcon className="size-4" aria-hidden="true" />
),
},
}
const stateBadgeStyles: Record<
AutomationState,
{ label: string; dotClassName?: string }
> = {
live: {
label: "Live",
dotClassName: "bg-emerald-500",
},
review: {
label: "Needs approval",
dotClassName: "bg-amber-500",
},
drafts: {
label: "Draft",
dotClassName: "bg-slate-400 dark:bg-slate-300",
},
paused: {
label: "Paused",
dotClassName: "bg-zinc-400 dark:bg-zinc-300",
},
}
const updatedBucketLabel: Record<IAutomationRecord["updatedBucket"], string> = {
today: "Today",
"this-week": "This week",
older: "Older",
}
const availabilityColor: Record<AutomationOwnerAvailability, string> = {
online: "bg-green-500",
away: "bg-yellow-400",
busy: "bg-red-500",
offline: "bg-gray-500",
}
function AutomationKindChip({ kind }: { kind: AutomationKind }) {
const style = automationKindStyles[kind]
return (
<Item
render={<span />}
className={cn(
"p-0",
"inline-flex size-10 items-center justify-center ring-1 ring-inset",
style.chipClassName
)}
aria-hidden="true"
>
<ItemMedia variant="icon" className="size-auto">
{style.icon}
</ItemMedia>
</Item>
)
}
function AutomationNameCell({ automation }: { automation: IAutomationRecord }) {
return (
<div className="flex min-w-0 items-center gap-3">
<AutomationKindChip kind={automation.kind} />
<div className="flex min-w-0 flex-col gap-px">
<span className="text-foreground truncate text-sm font-medium">
{automation.title}
</span>
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
<span className="truncate">{automation.runWindowLabel}</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="truncate">{automation.audienceLabel}</span>
</div>
</div>
</div>
)
}
function OwnerCell({ automation }: { automation: IAutomationRecord }) {
return (
<div className="flex items-center gap-2">
<div className="relative shrink-0">
<Avatar className="size-8">
{automation.owner.avatar ? (
<AvatarImage
src={automation.owner.avatar}
alt={automation.owner.name}
/>
) : null}
<AvatarFallback>{automation.owner.initials}</AvatarFallback>
</Avatar>
<span
className={cn(
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
availabilityColor[automation.owner.availability]
)}
aria-hidden
/>
</div>
<div className="min-w-0">
<div className="text-foreground line-clamp-1 font-medium">
{automation.owner.name}
</div>
<div
className="text-muted-foreground line-clamp-1 text-xs"
title={automation.owner.email}
>
{automation.owner.email}
</div>
</div>
</div>
)
}
function StateCell({ automation }: { automation: IAutomationRecord }) {
const stateStyle = stateBadgeStyles[automation.state]
return (
<div className="flex min-w-0 items-center">
<Badge variant="outline" className="gap-1.5">
{stateStyle.dotClassName ? (
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
stateStyle.dotClassName
)}
aria-hidden="true"
/>
) : null}
{stateStyle.label}
</Badge>
</div>
)
}
function UpdatedCell({ automation }: { automation: IAutomationRecord }) {
return (
<div className="flex min-w-0 flex-col gap-px">
<span className="text-foreground text-sm">
{format(parseISO(automation.updatedAt), "MMM d, yyyy")}
</span>
<span className="text-muted-foreground text-xs">
{updatedBucketLabel[automation.updatedBucket]}
</span>
</div>
)
}
function RatingCell({ automation }: { automation: IAutomationRecord }) {
return <Rating rating={automation.rating} size="sm" showValue={true} />
}
function AutomationActionsCell({
automation,
onAction,
onToggleEnabled,
}: {
automation: IAutomationRecord
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${automation.title}`}
>
<EllipsisVerticalIcon className="size-4" aria-hidden="true" />
</Button>
}
/>
{/* Content */}
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onAction("edit", automation)}>
<PencilIcon className="size-4" aria-hidden="true" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction("open", automation)}>
<EyeIcon className="size-4" aria-hidden="true" />
View Details
</DropdownMenuItem>
<DropdownMenuItem
closeOnClick={false}
onClick={(event) => {
// The Switch toggles itself and its click bubbles here; skip it
// so item-level activation (row click, Enter/Space) toggles once.
if (
event.target instanceof Element &&
event.target.closest('[data-slot="switch"]')
) {
return
}
onToggleEnabled(automation, !automation.enabled)
}}
className="justify-between gap-4"
>
<span className="flex items-center gap-2">
<CircleCheckIcon className="size-4" aria-hidden="true" />
Enabled
</span>
<Switch
size="sm"
aria-label={`Toggle ${automation.title}`}
checked={automation.enabled}
onCheckedChange={(checked) =>
onToggleEnabled(automation, checked)
}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onAction("archive", automation)}
>
<ArchiveIcon className="size-4" aria-hidden="true" />
Archive
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function createAutomationColumns({
onAction,
onToggleEnabled,
}: {
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
}): ColumnDef<IAutomationRecord>[] {
return [
{
id: "select",
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
size: 30,
enableSorting: false,
enableResizing: false,
enableHiding: false,
meta: {
headerClassName:
"[--data-grid-header-cell-ps:var(--frame-panel-header-px)]",
cellClassName: "[--data-grid-body-cell-ps:var(--frame-panel-px)]",
},
},
{
accessorFn: (row) => row.title,
id: "workflow",
header: ({ column }) => (
<DataGridColumnHeader title="Workflow" column={column} />
),
cell: ({ row }) => <AutomationNameCell automation={row.original} />,
size: 340,
minSize: 200,
enableSorting: true,
enableHiding: false,
meta: {
autoSize: true,
headerClassName: "pl-3!",
cellClassName: "pl-3!",
},
},
{
accessorFn: (row) => row.owner.name,
id: "owner",
header: ({ column }) => (
<DataGridColumnHeader title="Owner" column={column} />
),
cell: ({ row }) => <OwnerCell automation={row.original} />,
size: 175,
enableSorting: true,
},
{
accessorFn: (row) => row.rating,
id: "rating",
header: ({ column }) => (
<DataGridColumnHeader title="Score" column={column} />
),
cell: ({ row }) => <RatingCell automation={row.original} />,
size: 150,
enableSorting: true,
},
{
accessorFn: (row) => row.state,
id: "state",
header: ({ column }) => (
<DataGridColumnHeader title="State" column={column} />
),
cell: ({ row }) => <StateCell automation={row.original} />,
size: 150,
enableSorting: true,
},
{
accessorFn: (row) => parseISO(row.updatedAt).getTime(),
id: "updatedAt",
header: ({ column }) => (
<DataGridColumnHeader title="Last updated" column={column} />
),
cell: ({ row }) => <UpdatedCell automation={row.original} />,
size: 125,
enableSorting: true,
},
{
id: "actions",
header: () => null,
cell: ({ row }) => (
<div className="flex items-center justify-end">
<AutomationActionsCell
automation={row.original}
onAction={onAction}
onToggleEnabled={onToggleEnabled}
/>
</div>
),
size: 56,
enableSorting: false,
meta: {
headerClassName:
"[--data-grid-header-cell-pe:var(--frame-panel-header-px)]",
cellClassName: "[--data-grid-body-cell-pe:var(--frame-panel-px)]",
},
},
]
}
@@ -0,0 +1,660 @@
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 } from "@/components/reui/data-grid/data-grid-table"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type PaginationState,
type RowSelectionState,
type SortingState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { cn } from "@evobgp/ui/lib/utils"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@evobgp/ui/components/alert-dialog"
import { Button } from "@evobgp/ui/components/button"
import { Separator } from "@evobgp/ui/components/separator"
import { Tabs, TabsList, TabsTrigger } from "@evobgp/ui/components/tabs"
import { createAutomationColumns } from "./columns"
import {
AUTOMATION_TABS,
AUTOMATIONS,
DELIVERY_FILTER_OPTIONS,
getAutomationTabFromState,
OWNER_FILTER_OPTIONS,
UPDATED_FILTER_OPTIONS,
type AutomationState,
type AutomationTab,
type IAutomationRecord,
} from "./data"
import { SearchIcon, UsersIcon, RouteIcon, ClockIcon, PlusIcon, FilterIcon, FunnelXIcon } from "lucide-react"
type ToastTone = "success" | "neutral" | "destructive"
const toneStyles: Record<ToastTone, { dot: string }> = {
success: { dot: "bg-emerald-500" },
neutral: { dot: "bg-sky-500" },
destructive: { dot: "bg-rose-500" },
}
function showAutomationToast({
tone,
title,
description,
}: {
tone: ToastTone
title: string
description: string
}) {
toast.custom((id) => (
<div className="bg-popover text-popover-foreground border-border flex w-[356px] flex-col gap-3 rounded-md border p-4 shadow-lg">
<div className="flex items-start gap-2">
<span
className={cn(
"mt-1 flex size-2 shrink-0 rounded-full",
toneStyles[tone].dot
)}
aria-hidden="true"
/>
<div className="flex flex-1 flex-col gap-1">
<p className="text-sm font-semibold">{title}</p>
<p className="text-muted-foreground text-sm leading-relaxed text-pretty">
{description}
</p>
</div>
</div>
<div className="flex gap-2">
<Button size="xs" variant="outline" onClick={() => toast.dismiss(id)}>
Done
</Button>
</div>
</div>
))
}
function getAutomationSearchBlob(automation: IAutomationRecord) {
return [
automation.title,
automation.kind,
automation.state,
automation.deliveryMode,
automation.audienceLabel,
automation.runWindowLabel,
automation.owner.name,
automation.owner.email,
automation.owner.teamLabel,
automation.approvalRequired ? "approval required" : "auto-approved",
automation.enabled ? "enabled" : "disabled",
]
.join(" ")
.toLowerCase()
}
function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (
values.every((value) => typeof value === "string" && value.trim() === "")
) {
return false
}
if (values.every((value) => value === null || value === undefined)) {
return false
}
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
return false
}
return true
})
}
function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return "Select..."
if (values.length > 1) return `${values.length} selected`
return null
}
function renderSingleSelectedLabel(
values: unknown[],
options: { value: string; label: string }[]
) {
const state = renderSelectedCount(values)
if (state) return state
const option = options.find((item) => item.value === values[0])
return option?.label ?? String(values[0])
}
function filterFieldValue(
automation: IAutomationRecord,
field: string
): unknown {
switch (field) {
case "workflow":
return getAutomationSearchBlob(automation)
case "ownerTeam":
return automation.owner.team
case "deliveryMode":
return automation.deliveryMode
case "updatedBucket":
return automation.updatedBucket
default:
return ""
}
}
function applyFiltersToData(
data: IAutomationRecord[],
filters: Filter[]
): IAutomationRecord[] {
const active = getActiveFilters(filters)
let result = [...data]
active.forEach((filter) => {
const { field, operator, values } = filter
result = result.filter((item) => {
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? raw : ""
switch (operator) {
case "is":
return values.includes(fieldValue)
case "is_not":
return !values.includes(fieldValue)
case "is_any_of":
return values.some((value) => fieldValue === value)
case "is_not_any_of":
return !values.some((value) => fieldValue === value)
case "contains": {
const tokens = values
.map((value) => String(value).trim())
.filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase())
)
}
case "not_contains":
return !values.some((value) =>
String(fieldValue)
.toLowerCase()
.includes(String(value).toLowerCase())
)
case "starts_with":
return values.some((value) =>
String(fieldValue)
.toLowerCase()
.startsWith(String(value).toLowerCase())
)
case "ends_with":
return values.some((value) =>
String(fieldValue)
.toLowerCase()
.endsWith(String(value).toLowerCase())
)
case "empty":
return fieldValue === "" || fieldValue == null
case "not_empty":
return fieldValue !== "" && fieldValue != null
default:
return true
}
})
})
return result
}
const OWNER_TEAM_FILTER_OPTIONS = OWNER_FILTER_OPTIONS.filter(
(option) => option.value !== "everyone"
).map((option) => ({
value: option.value,
label: option.label,
}))
const DELIVERY_MODE_FILTER_OPTIONS = DELIVERY_FILTER_OPTIONS.filter(
(option) => option.value !== "any"
).map((option) => ({
value: option.value,
label: option.label,
}))
const UPDATED_BUCKET_FILTER_OPTIONS = UPDATED_FILTER_OPTIONS.filter(
(option) => option.value !== "any"
).map((option) => ({
value: option.value,
label: option.label,
}))
const filterFields: FilterFieldConfig[] = [
{
key: "workflow",
label: "Workflow",
icon: (
<SearchIcon className="size-3.5" aria-hidden />
),
type: "text",
className: "w-52",
placeholder: "Search...",
},
{
key: "ownerTeam",
label: "Owner team",
icon: (
<UsersIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[168px]",
options: OWNER_TEAM_FILTER_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, OWNER_TEAM_FILTER_OPTIONS),
},
{
key: "deliveryMode",
label: "Delivery mode",
icon: (
<RouteIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[168px]",
options: DELIVERY_MODE_FILTER_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, DELIVERY_MODE_FILTER_OPTIONS),
},
{
key: "updatedBucket",
label: "Last updated",
icon: (
<ClockIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[160px]",
options: UPDATED_BUCKET_FILTER_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, UPDATED_BUCKET_FILTER_OPTIONS),
},
]
function createDefaultAutomationFilters(): Filter[] {
return [createFilter("workflow", "contains", [""])]
}
function getTabCounts(records: IAutomationRecord[]) {
return {
all: records.length,
live: records.filter((record) => record.state === "live").length,
review: records.filter((record) => record.state === "review").length,
drafts: records.filter((record) => record.state === "drafts").length,
paused: records.filter((record) => record.state === "paused").length,
} satisfies Record<AutomationTab, number>
}
export function AutomationLibraryGridView() {
const [automations, setAutomations] =
useState<IAutomationRecord[]>(AUTOMATIONS)
const [activeTab, setActiveTab] = useState<AutomationTab>("all")
const [filters, setFilters] = useState<Filter[]>(
createDefaultAutomationFilters
)
const [sorting, setSorting] = useState<SortingState>([
{ id: "updatedAt", desc: true },
])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [automationPendingArchive, setAutomationPendingArchive] =
useState<IAutomationRecord | null>(null)
const resetPagination = useCallback(() => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
)
}, [])
const filteredBaseAutomations = useMemo(() => {
return applyFiltersToData(automations, filters)
}, [automations, filters])
const filteredAutomations = useMemo(
() =>
filteredBaseAutomations.filter((automation) =>
activeTab === "all"
? true
: getAutomationTabFromState(automation.state) === activeTab
),
[activeTab, filteredBaseAutomations]
)
const tabCounts = useMemo(
() => getTabCounts(filteredBaseAutomations),
[filteredBaseAutomations]
)
const filteredLiveCount = useMemo(
() =>
filteredAutomations.filter((automation) => automation.state === "live")
.length,
[filteredAutomations]
)
const filteredReviewCount = useMemo(
() =>
filteredAutomations.filter((automation) => automation.state === "review")
.length,
[filteredAutomations]
)
const selectedCount = useMemo(
() => Object.keys(rowSelection).length,
[rowSelection]
)
const columns = useMemo(
() =>
createAutomationColumns({
onAction: (action, automation) => {
if (action === "archive") {
setAutomationPendingArchive(automation)
return
}
if (action === "edit") {
showAutomationToast({
tone: "neutral",
title: "Workflow editor",
description: `Connect "${automation.title}" to your builder, side panel, or automation step editor.`,
})
return
}
showAutomationToast({
tone: "success",
title: "Workflow details",
description: `"${automation.title}" is ready for a detail route, run history drawer, or audit panel.`,
})
},
onToggleEnabled: (automation, nextValue) => {
const nextState: AutomationState =
nextValue && automation.state === "paused"
? "live"
: nextValue && automation.state === "drafts"
? "review"
: !nextValue && automation.state === "live"
? "paused"
: automation.state
setAutomations((current) =>
current.map((item) =>
item.id === automation.id
? {
...item,
enabled: nextValue,
state: nextState,
}
: item
)
)
showAutomationToast({
tone: nextValue ? "success" : "neutral",
title: nextValue ? "Workflow enabled" : "Workflow paused",
description: nextValue
? `"${automation.title}" is ready to run in the ${nextState === "review" ? "review" : "live"} queue.`
: `"${automation.title}" will stay available but will not continue running until resumed.`,
})
},
}),
[]
)
const table = useReactTable({
data: filteredAutomations,
columns,
getRowId: (row) => row.id,
state: {
sorting,
rowSelection,
pagination,
},
enableRowSelection: true,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const handleClearControls = useCallback(() => {
setFilters(createDefaultAutomationFilters())
resetPagination()
}, [resetPagination])
const handleFiltersChange = useCallback(
(nextFilters: Filter[]) => {
setFilters(nextFilters)
resetPagination()
},
[resetPagination]
)
const handleArchiveAutomation = useCallback(() => {
if (!automationPendingArchive) return
const automationToArchive = automationPendingArchive
setAutomations((current) =>
current.filter(
(automation) => automation.id !== automationPendingArchive.id
)
)
setRowSelection((current) => {
const next = { ...current }
delete next[automationPendingArchive.id]
return next
})
setAutomationPendingArchive(null)
resetPagination()
showAutomationToast({
tone: "destructive",
title: "Workflow archived",
description: `"${automationToArchive.title}" was removed from this automation library.`,
})
}, [automationPendingArchive, resetPagination])
const emptyMessage =
"No workflows match this automation slice. Switch tabs or clear the filters."
return (
<>
{/* Table */}
<DataGrid
table={table}
recordCount={filteredAutomations.length}
emptyMessage={emptyMessage}
tableLayout={{
dense: true,
}}
>
<Frame dense variant="default" spacing="sm" className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex flex-col gap-px">
<FrameTitle className="text-balance">
Automation Library
</FrameTitle>
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>
{filteredAutomations.length} workflow
{filteredAutomations.length === 1 ? "" : "s"}
</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{filteredLiveCount} live</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{filteredReviewCount} review</span>
{selectedCount > 0 ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{selectedCount} selected</span>
</>
) : null}
</FrameDescription>
</div>
<Button type="button" className="shrink-0">
<PlusIcon className="size-4" aria-hidden="true" />
New workflow
</Button>
</FrameHeader>
<FramePanel className="p-0 shadow-none!">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<Tabs
value={activeTab}
onValueChange={(value) => {
setActiveTab(value as AutomationTab)
resetPagination()
}}
>
<TabsList variant="line" className="gap-5">
{AUTOMATION_TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="gap-2 px-0 pb-3 text-sm"
>
<span>{tab.label}</span>
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{tabCounts[tab.value]}
</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<Separator />
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button variant="outline" aria-label="Filters">
<FilterIcon className="size-4" aria-hidden="true" />
Filters
</Button>
}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} selected
</Badge>
) : null}
<Button
type="button"
variant="outline"
onClick={handleClearControls}
>
<FunnelXIcon className="size-4" aria-hidden="true" />
Clear
</Button>
</div>
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
<AlertDialog
open={automationPendingArchive != null}
onOpenChange={(open) => {
if (!open) {
setAutomationPendingArchive(null)
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive workflow?</AlertDialogTitle>
<AlertDialogDescription>
{automationPendingArchive
? `Archive "${automationPendingArchive.title}" from this automation library. Run history and ownership context can stay available in your backend, but this row will disappear from the grid preview.`
: "Archive this workflow from the automation library."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleArchiveAutomation}
render={
<Button type="button" variant="destructive">
Archive
</Button>
}
/>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,384 @@
export type AutomationTab = "all" | "live" | "review" | "drafts" | "paused"
export type AutomationKind =
| "sequence"
| "routing"
| "enrichment"
| "digest"
| "escalation"
export type OwnerFilter =
| "everyone"
| "product"
| "engineering"
| "operations"
| "revenue"
| "support"
export type DeliveryFilter =
| "any"
| "scheduled"
| "event-driven"
| "manual"
| "hybrid"
export type UpdatedFilter = "any" | "today" | "this-week" | "older"
export type AutomationState = Exclude<AutomationTab, "all">
export type AutomationOwnerAvailability = "online" | "away" | "busy" | "offline"
export interface IAutomationOwner {
id: string
name: string
email: string
initials: string
avatar?: string
availability: AutomationOwnerAvailability
team: Exclude<OwnerFilter, "everyone">
teamLabel: string
}
export interface IAutomationRecord {
id: string
title: string
kind: AutomationKind
state: AutomationState
rating: number
deliveryMode: Exclude<DeliveryFilter, "any">
owner: IAutomationOwner
updatedAt: string
updatedBucket: Exclude<UpdatedFilter, "any">
enabled: boolean
approvalRequired: boolean
audienceLabel: string
runWindowLabel: string
}
const OWNERS: Record<string, IAutomationOwner> = {
maya: {
id: "maya-patel",
name: "Maya Patel",
email: "[email protected]",
initials: "MP",
avatar:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
availability: "online",
team: "product",
teamLabel: "Product",
},
jonas: {
id: "jonas-reed",
name: "Jonas Reed",
email: "[email protected]",
initials: "JR",
avatar:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
availability: "busy",
team: "engineering",
teamLabel: "Engineering",
},
priya: {
id: "priya-nair",
name: "Priya Nair",
email: "[email protected]",
initials: "PN",
avatar:
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
availability: "away",
team: "operations",
teamLabel: "Operations",
},
emil: {
id: "emil-novak",
name: "Emil Novak",
email: "[email protected]",
initials: "EN",
avatar:
"https://images.unsplash.com/photo-1560250097-0b93528c311a?w=96&h=96&dpr=2&q=80",
availability: "offline",
team: "revenue",
teamLabel: "Revenue",
},
nora: {
id: "nora-ibrahim",
name: "Nora Ibrahim",
email: "[email protected]",
initials: "NI",
avatar:
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
availability: "online",
team: "support",
teamLabel: "Support",
},
}
function automation(
input: Omit<IAutomationRecord, "owner"> & {
owner: keyof typeof OWNERS
}
): IAutomationRecord {
return {
...input,
owner: OWNERS[input.owner],
}
}
export const AUTOMATION_TABS: { value: AutomationTab; label: string }[] = [
{ value: "all", label: "All" },
{ value: "live", label: "Live" },
{ value: "review", label: "Needs Review" },
{ value: "drafts", label: "Drafts" },
{ value: "paused", label: "Paused" },
]
export const OWNER_FILTER_OPTIONS: {
value: OwnerFilter
label: string
}[] = [
{ value: "everyone", label: "Owner team" },
{ value: "product", label: "Product" },
{ value: "engineering", label: "Engineering" },
{ value: "operations", label: "Operations" },
{ value: "revenue", label: "Revenue" },
{ value: "support", label: "Support" },
]
export const DELIVERY_FILTER_OPTIONS: {
value: DeliveryFilter
label: string
}[] = [
{ value: "any", label: "Delivery mode" },
{ value: "scheduled", label: "Scheduled" },
{ value: "event-driven", label: "Event-driven" },
{ value: "manual", label: "Manual" },
{ value: "hybrid", label: "Hybrid" },
]
export const UPDATED_FILTER_OPTIONS: {
value: UpdatedFilter
label: string
}[] = [
{ value: "any", label: "Last updated" },
{ value: "today", label: "Today" },
{ value: "this-week", label: "This week" },
{ value: "older", label: "Older" },
]
export function getAutomationTabFromState(
state: AutomationState
): Exclude<AutomationTab, "all"> {
return state
}
export const AUTOMATIONS: IAutomationRecord[] = [
automation({
id: "renewal-touchpoint-orchestration",
title: "Renewal touchpoint orchestration",
kind: "sequence",
state: "live",
rating: 4.8,
deliveryMode: "scheduled",
owner: "emil",
updatedAt: "2026-04-11",
updatedBucket: "today",
enabled: true,
approvalRequired: false,
audienceLabel: "Renewal accounts",
runWindowLabel: "Weekdays 09:00",
}),
automation({
id: "delegated-sender-review-route",
title: "Delegated sender review route",
kind: "routing",
state: "review",
rating: 4.2,
deliveryMode: "manual",
owner: "priya",
updatedAt: "2026-04-11",
updatedBucket: "today",
enabled: false,
approvalRequired: true,
audienceLabel: "Delegated senders",
runWindowLabel: "Queue-based release",
}),
automation({
id: "launch-handoff-digest",
title: "Launch handoff digest",
kind: "digest",
state: "live",
rating: 4.7,
deliveryMode: "scheduled",
owner: "maya",
updatedAt: "2026-04-10",
updatedBucket: "this-week",
enabled: true,
approvalRequired: false,
audienceLabel: "Launch squad",
runWindowLabel: "Daily 08:30",
}),
automation({
id: "sla-escalation-watch",
title: "SLA escalation watch",
kind: "escalation",
state: "live",
rating: 4.9,
deliveryMode: "event-driven",
owner: "nora",
updatedAt: "2026-04-10",
updatedBucket: "this-week",
enabled: true,
approvalRequired: false,
audienceLabel: "Priority tickets",
runWindowLabel: "On trigger",
}),
automation({
id: "lead-enrichment-pass",
title: "Lead enrichment pass",
kind: "enrichment",
state: "paused",
rating: 3.9,
deliveryMode: "hybrid",
owner: "jonas",
updatedAt: "2026-04-09",
updatedBucket: "this-week",
enabled: false,
approvalRequired: false,
audienceLabel: "Inbound pipeline",
runWindowLabel: "Hourly batch",
}),
automation({
id: "sandbox-onboarding-sequence",
title: "Sandbox onboarding sequence",
kind: "sequence",
state: "drafts",
rating: 4.1,
deliveryMode: "scheduled",
owner: "priya",
updatedAt: "2026-04-08",
updatedBucket: "this-week",
enabled: false,
approvalRequired: false,
audienceLabel: "Trial workspaces",
runWindowLabel: "Pending QA",
}),
automation({
id: "partner-routing-fallback",
title: "Partner routing fallback",
kind: "routing",
state: "live",
rating: 4.4,
deliveryMode: "hybrid",
owner: "emil",
updatedAt: "2026-04-07",
updatedBucket: "this-week",
enabled: true,
approvalRequired: false,
audienceLabel: "Partner renewals",
runWindowLabel: "Live + nightly",
}),
automation({
id: "weekly-adoption-digest",
title: "Weekly adoption digest",
kind: "digest",
state: "paused",
rating: 3.8,
deliveryMode: "scheduled",
owner: "maya",
updatedAt: "2026-04-06",
updatedBucket: "this-week",
enabled: false,
approvalRequired: false,
audienceLabel: "Workspace champions",
runWindowLabel: "Fridays 16:00",
}),
automation({
id: "enterprise-risk-escalation",
title: "Enterprise risk escalation",
kind: "escalation",
state: "review",
rating: 4.3,
deliveryMode: "manual",
owner: "nora",
updatedAt: "2026-04-05",
updatedBucket: "older",
enabled: false,
approvalRequired: true,
audienceLabel: "Enterprise accounts",
runWindowLabel: "Manual release",
}),
automation({
id: "crm-enrichment-backfill",
title: "CRM enrichment backfill",
kind: "enrichment",
state: "live",
rating: 4.6,
deliveryMode: "scheduled",
owner: "jonas",
updatedAt: "2026-04-04",
updatedBucket: "older",
enabled: true,
approvalRequired: false,
audienceLabel: "Open opportunities",
runWindowLabel: "Nightly 01:00",
}),
automation({
id: "trial-conversion-follow-up",
title: "Trial conversion follow-up",
kind: "sequence",
state: "drafts",
rating: 4.0,
deliveryMode: "scheduled",
owner: "emil",
updatedAt: "2026-04-03",
updatedBucket: "older",
enabled: false,
approvalRequired: false,
audienceLabel: "Product-led signups",
runWindowLabel: "Awaiting copy",
}),
automation({
id: "owner-assignment-router",
title: "Owner assignment router",
kind: "routing",
state: "live",
rating: 4.5,
deliveryMode: "event-driven",
owner: "priya",
updatedAt: "2026-04-02",
updatedBucket: "older",
enabled: true,
approvalRequired: false,
audienceLabel: "Workspace requests",
runWindowLabel: "Immediate",
}),
automation({
id: "ops-exception-digest",
title: "Ops exception digest",
kind: "digest",
state: "review",
rating: 4.1,
deliveryMode: "scheduled",
owner: "priya",
updatedAt: "2026-04-11",
updatedBucket: "today",
enabled: false,
approvalRequired: true,
audienceLabel: "Ops leadership",
runWindowLabel: "Daily 18:00",
}),
automation({
id: "billing-retry-escalation",
title: "Billing retry escalation",
kind: "escalation",
state: "live",
rating: 4.7,
deliveryMode: "event-driven",
owner: "nora",
updatedAt: "2026-04-01",
updatedBucket: "older",
enabled: true,
approvalRequired: false,
audienceLabel: "Recovery queue",
runWindowLabel: "On failure",
}),
]
@@ -0,0 +1,15 @@
import { AutomationLibraryGridView } from "./components/data-grid-view"
export function Page() {
return (
<main
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Automation library data grid
</h1>
<AutomationLibraryGridView />
</main>
)
}
@@ -0,0 +1,685 @@
import { memo, useMemo, useState, type ComponentProps } 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 { Row, type ColumnDef } from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@evobgp/ui/components/alert-dialog"
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 {
Progress,
ProgressLabel,
ProgressValue,
} from "@evobgp/ui/components/progress"
import { Skeleton } from "@evobgp/ui/components/skeleton"
import {
type IRenewalRecord,
type RenewalInvoiceStatus,
type RenewalRisk,
type RenewalSponsorStatus,
type RenewalStage,
} from "./data"
import { MoreHorizontalIcon, EyeIcon, FileTextIcon, TriangleAlertIcon } from "lucide-react"
// Intl formatters are costly to construct; build them once and reuse for every
// cell render.
const CURRENCY_FORMATTER = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
})
const RENEWAL_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
function formatCurrency(value: number) {
return CURRENCY_FORMATTER.format(value)
}
function formatRenewalDate(iso: string) {
return RENEWAL_DATE_FORMATTER.format(new Date(iso))
}
const stageVariant: Record<
RenewalStage,
ComponentProps<typeof Badge>["variant"]
> = {
Preparing: "outline",
"Commercial review": "outline",
"Legal review": "outline",
Committed: "outline",
}
const stageDot = {
Preparing: "bg-muted-foreground",
"Commercial review": "bg-sky-500",
"Legal review": "bg-amber-500",
Committed: "bg-emerald-500",
} satisfies Record<RenewalStage, string>
const riskVariant: Record<
RenewalRisk,
ComponentProps<typeof Badge>["variant"]
> = {
Low: "secondary",
Medium: "info-light",
High: "warning-light",
Critical: "destructive-light",
}
const invoiceVariant: Record<
RenewalInvoiceStatus,
ComponentProps<typeof Badge>["variant"]
> = {
Ready: "success-light",
"Finance review": "warning-light",
Blocked: "destructive-light",
}
const sponsorVariant: Record<
RenewalSponsorStatus,
ComponentProps<typeof Badge>["variant"]
> = {
Confirmed: "success-outline",
"At risk": "warning-outline",
Missing: "destructive-outline",
}
export const StageBadge = memo(function StageBadge({
stage,
}: {
stage: RenewalStage
}) {
return (
<Badge variant={stageVariant[stage]}>
<span
className={cn("size-1.5 shrink-0 rounded-full", stageDot[stage])}
aria-hidden="true"
/>
{stage}
</Badge>
)
})
export const RiskBadge = memo(function RiskBadge({
risk,
}: {
risk: RenewalRisk
}) {
return <Badge variant={riskVariant[risk]}>{risk}</Badge>
})
export const InvoiceStatusBadge = memo(function InvoiceStatusBadge({
status,
}: {
status: RenewalInvoiceStatus
}) {
return <Badge variant={invoiceVariant[status]}>{status}</Badge>
})
export const SponsorStatusBadge = memo(function SponsorStatusBadge({
status,
}: {
status: RenewalSponsorStatus
}) {
return <Badge variant={sponsorVariant[status]}>{status}</Badge>
})
function initials(name: string) {
return name
.split(" ")
.map((part) => part[0])
.join("")
}
const AccountCell = memo(function AccountCell({
row,
}: {
row: Row<IRenewalRecord>
}) {
return (
<div className="flex min-w-0 items-center gap-2">
<Item className="flex size-6 shrink-0 items-center justify-center border-0 p-0 [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto">
{row.original.accountLogo}
</ItemMedia>
</Item>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="text-foreground truncate font-medium">
{row.original.accountName}
</div>
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
<Avatar className="size-4">
<AvatarImage src={row.original.ownerAvatar} alt="" />
<AvatarFallback className="text-[9px] font-medium">
{initials(row.original.ownerName)}
</AvatarFallback>
</Avatar>
<span className="truncate">{row.original.ownerName}</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="truncate">{row.original.segment}</span>
</div>
</div>
</div>
)
})
const RenewalWindowCell = memo(function RenewalWindowCell({
row,
}: {
row: Row<IRenewalRecord>
}) {
const dueSoon = row.original.daysToRenewal <= 30
const urgencyTone =
row.original.daysToRenewal <= 14
? "text-destructive"
: row.original.daysToRenewal <= 30
? "text-warning"
: "text-foreground"
return (
<div className="flex min-w-0 flex-col gap-0.5">
<span className={cn("font-medium tabular-nums", urgencyTone)}>
{dueSoon
? `Due in ${row.original.daysToRenewal}d`
: `${row.original.daysToRenewal}d out`}
</span>
<span className="text-muted-foreground text-xs">
{formatRenewalDate(row.original.renewalDate)}
</span>
</div>
)
})
const RevenueCell = memo(function RevenueCell({
row,
}: {
row: Row<IRenewalRecord>
}) {
return (
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground font-medium tabular-nums">
{formatCurrency(row.original.arr)}
</span>
<span
className={cn(
"text-xs tabular-nums",
row.original.expansionPotential > 0
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground"
)}
>
{row.original.expansionPotential > 0 ? "+" : ""}
{formatCurrency(row.original.expansionPotential)} upside
</span>
</div>
)
})
const HealthCell = memo(function HealthCell({
row,
}: {
row: Row<IRenewalRecord>
}) {
const score = row.original.healthScore
const label = score >= 75 ? "Healthy" : score >= 55 ? "Watchlist" : "At risk"
const indicatorClass =
score >= 75
? "**:data-[slot=progress-indicator]:bg-emerald-500"
: score >= 55
? "**:data-[slot=progress-indicator]:bg-amber-500"
: "**:data-[slot=progress-indicator]:bg-red-500"
return (
<div className="flex min-w-0 flex-col gap-1">
<Progress
value={score}
className={cn(
"min-w-0 flex-1 flex-row flex-nowrap items-center gap-2 **:data-[slot=progress-track]:order-1 **:data-[slot=progress-track]:min-w-14 **:data-[slot=progress-track]:flex-1 **:data-[slot=progress-value]:order-2",
indicatorClass
)}
>
<ProgressLabel className="sr-only">Health score</ProgressLabel>
<ProgressValue className="text-muted-foreground shrink-0 text-[10px] leading-none tabular-nums">
{(_, value) => `${value ?? score}%`}
</ProgressValue>
</Progress>
<span className="text-muted-foreground text-xs">{label}</span>
</div>
)
})
function buildSmoothLinePath(points: { x: number; y: number }[]) {
if (points.length === 0) return ""
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`
let path = `M ${points[0].x} ${points[0].y}`
for (let index = 0; index < points.length - 1; index++) {
const p0 = points[Math.max(0, index - 1)]
const p1 = points[index]
const p2 = points[index + 1]
const p3 = points[Math.min(points.length - 1, index + 2)]
const cp1x = p1.x + (p2.x - p0.x) / 6
const cp1y = p1.y + (p2.y - p0.y) / 6
const cp2x = p2.x - (p3.x - p1.x) / 6
const cp2y = p2.y - (p3.y - p1.y) / 6
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`
}
return path
}
const UsageTrendCell = memo(function UsageTrendCell({
row,
}: {
row: Row<IRenewalRecord>
}) {
const data = row.original.usageTrend
const width = 92
const height = 26
const padX = 2
const padY = 2
const innerW = width - padX * 2
const innerH = height - padY * 2
const { linePath, strokeClass, deltaLabel } = useMemo(() => {
const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min || 1
const step = innerW / Math.max(1, data.length - 1)
const points = data.map((value, index) => ({
x: padX + index * step,
y: padY + ((max - value) / range) * innerH,
}))
const delta = data[data.length - 1] - data[0]
return {
linePath: buildSmoothLinePath(points),
strokeClass:
delta > 0
? "stroke-emerald-600 dark:stroke-emerald-400"
: delta < 0
? "stroke-red-600 dark:stroke-red-400"
: "stroke-muted-foreground",
deltaLabel:
delta > 0
? `Expanding ${delta} pts`
: delta < 0
? `Contracting ${Math.abs(delta)} pts`
: "Stable",
}
}, [data, innerH, innerW, padX, padY])
return (
<div className="flex min-w-0 flex-col gap-1">
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="shrink-0"
aria-hidden="true"
>
<path
d={linePath}
fill="none"
className={strokeClass}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className="text-muted-foreground text-xs">{deltaLabel}</span>
</div>
)
})
function RenewalActionsCell({
row,
onOpenAccount,
onCreateBrief,
onEscalateReview,
}: {
row: Row<IRenewalRecord>
onOpenAccount: (renewal: IRenewalRecord) => void
onCreateBrief: (renewal: IRenewalRecord) => void
onEscalateReview: (renewal: IRenewalRecord) => void
}) {
const [escalateOpen, setEscalateOpen] = useState(false)
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
aria-label="Renewal actions"
/>
}
>
<MoreHorizontalIcon className="size-4" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onOpenAccount(row.original)}>
<EyeIcon className="size-4" aria-hidden="true" />
Open account
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onCreateBrief(row.original)}>
<FileTextIcon className="size-4" aria-hidden="true" />
{row.original.stage === "Legal review"
? "Legal redlines"
: row.original.invoiceStatus !== "Ready"
? "Finance sign-off"
: "Renewal brief"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setEscalateOpen(true)}
>
<TriangleAlertIcon className="size-4" aria-hidden="true" />
Escalate
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={escalateOpen} onOpenChange={setEscalateOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Create an exec escalation?</AlertDialogTitle>
<AlertDialogDescription>
This will push{" "}
<span className="text-foreground font-medium">
{row.original.accountName}
</span>{" "}
into an urgent board-review path in a real revenue-ops workspace.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => onEscalateReview(row.original)}>
Create escalation
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export function createRenewalColumns({
onOpenAccount,
onCreateBrief,
onEscalateReview,
}: {
onOpenAccount: (renewal: IRenewalRecord) => void
onCreateBrief: (renewal: IRenewalRecord) => void
onEscalateReview: (renewal: IRenewalRecord) => void
}) {
return [
{
accessorKey: "id",
id: "select",
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
enableSorting: false,
enableResizing: false,
enableHiding: false,
size: 36,
meta: {
headerClassName: "ps-4!",
cellClassName: "ps-4!",
},
},
{
accessorKey: "accountName",
id: "account",
header: ({ column }) => (
<DataGridColumnHeader
title="Account"
visibility={true}
column={column}
/>
),
cell: ({ row }) => <AccountCell row={row} />,
minSize: 280,
size: 320,
enableSorting: true,
enableResizing: true,
enableHiding: false,
meta: {
autoSize: true,
skeleton: (
<div className="flex min-w-0 items-start gap-2">
<Skeleton className="mt-0.5 size-5 shrink-0 rounded" />
<div className="flex min-w-0 flex-col gap-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-40" />
</div>
</div>
),
},
},
{
accessorFn: (row) => row.daysToRenewal,
id: "renewalWindow",
header: ({ column }) => (
<DataGridColumnHeader
title="Renewal"
visibility={true}
column={column}
/>
),
cell: ({ row }) => <RenewalWindowCell row={row} />,
size: 150,
minSize: 140,
enableSorting: true,
enableResizing: true,
enableHiding: true,
meta: {
skeleton: (
<div className="flex flex-col gap-0.5">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-24" />
</div>
),
},
},
{
accessorKey: "arr",
id: "arr",
header: ({ column }) => (
<DataGridColumnHeader title="ARR" visibility={true} column={column} />
),
cell: ({ row }) => <RevenueCell row={row} />,
size: 150,
minSize: 140,
enableSorting: true,
enableResizing: true,
enableHiding: true,
meta: {
skeleton: (
<div className="flex flex-col gap-0.5">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-24" />
</div>
),
},
},
{
accessorKey: "healthScore",
id: "health",
header: ({ column }) => (
<DataGridColumnHeader
title="Health"
visibility={true}
column={column}
/>
),
cell: ({ row }) => <HealthCell row={row} />,
size: 140,
minSize: 130,
enableSorting: true,
enableResizing: true,
enableHiding: true,
},
{
accessorKey: "usageTrend",
id: "usage",
header: ({ column }) => (
<DataGridColumnHeader title="Usage" visibility={true} column={column} />
),
cell: ({ row }) => <UsageTrendCell row={row} />,
size: 120,
minSize: 112,
enableSorting: false,
enableResizing: true,
enableHiding: true,
},
{
accessorKey: "stage",
id: "stage",
header: ({ column }) => (
<DataGridColumnHeader title="Stage" visibility={true} column={column} />
),
cell: ({ row }) => <StageBadge stage={row.original.stage} />,
size: 168,
minSize: 160,
enableSorting: true,
enableResizing: true,
enableHiding: true,
},
{
accessorKey: "risk",
id: "risk",
header: ({ column }) => (
<DataGridColumnHeader title="Risk" visibility={true} column={column} />
),
cell: ({ row }) => <RiskBadge risk={row.original.risk} />,
size: 116,
minSize: 108,
enableSorting: true,
enableResizing: true,
enableHiding: true,
},
{
accessorKey: "invoiceStatus",
id: "invoiceStatus",
header: ({ column }) => (
<DataGridColumnHeader
title="Invoice"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<InvoiceStatusBadge status={row.original.invoiceStatus} />
),
size: 148,
minSize: 138,
enableSorting: true,
enableResizing: true,
enableHiding: true,
},
{
accessorKey: "sponsorStatus",
id: "sponsorStatus",
header: ({ column }) => (
<DataGridColumnHeader
title="Sponsor"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<SponsorStatusBadge status={row.original.sponsorStatus} />
),
size: 136,
minSize: 128,
enableSorting: true,
enableResizing: true,
enableHiding: true,
},
{
accessorKey: "region",
id: "region",
header: ({ column }) => (
<DataGridColumnHeader
title="Region"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<span className="text-foreground text-sm">{row.original.region}</span>
),
size: 124,
minSize: 116,
enableSorting: true,
enableResizing: true,
enableHiding: true,
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<RenewalActionsCell
row={row}
onOpenAccount={onOpenAccount}
onCreateBrief={onCreateBrief}
onEscalateReview={onEscalateReview}
/>
),
enableSorting: false,
enableResizing: false,
enableHiding: false,
size: 56,
meta: {
cellClassName: "pe-4!",
headerClassName: "pe-4!",
},
},
] satisfies ColumnDef<IRenewalRecord>[]
}
@@ -0,0 +1,766 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import { DataGrid } from "@/components/reui/data-grid/data-grid"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import {
getCoreRowModel,
getSortedRowModel,
useReactTable,
type RowSelectionState,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@evobgp/ui/components/field"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Separator } from "@evobgp/ui/components/separator"
import { Switch } from "@evobgp/ui/components/switch"
import { TooltipProvider } from "@evobgp/ui/components/tooltip"
import { createRenewalColumns, RiskBadge, StageBadge } from "./columns"
import {
getRenewalWindowValue,
RENEWAL_OWNERS,
RENEWAL_RECORDS,
RENEWAL_RISK_ORDER,
RENEWAL_SEGMENT_ORDER,
RENEWAL_STAGE_ORDER,
RENEWAL_WINDOW_OPTIONS,
type IRenewalRecord,
type RenewalRisk,
type RenewalStage,
} from "./data"
import { RenewalSelectionBar } from "./renewal-selection-bar"
import { RenewalsCommandCard } from "./renewals-command-card"
import { FilterIcon, Settings2Icon, CheckIcon, Building2Icon, LayersIcon, GitBranchIcon, TriangleAlertIcon, CalendarClockIcon, UserRoundIcon, PlusIcon } from "lucide-react"
function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (
values.every((value) => typeof value === "string" && value.trim() === "")
) {
return false
}
if (values.every((value) => value === null || value === undefined)) {
return false
}
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
return false
}
return true
})
}
function filterFieldValue(item: IRenewalRecord, field: string): unknown {
if (field === "renewalWindow") {
return getRenewalWindowValue(item.daysToRenewal)
}
return item[field as keyof IRenewalRecord]
}
function applyFiltersToData(
data: IRenewalRecord[],
filters: Filter[]
): IRenewalRecord[] {
const active = getActiveFilters(filters)
let result = [...data]
active.forEach((filter) => {
const { field, operator, values } = filter
result = result.filter((item) => {
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? raw : ""
switch (operator) {
case "is":
return values.includes(fieldValue)
case "is_not":
return !values.includes(fieldValue)
case "is_any_of":
return values.some((value) => fieldValue === value)
case "is_not_any_of":
return !values.some((value) => fieldValue === value)
case "contains": {
const tokens = values
.map((value) => String(value).trim())
.filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase())
)
}
case "not_contains":
return !values.some((value) =>
String(fieldValue)
.toLowerCase()
.includes(String(value).toLowerCase())
)
case "starts_with":
return values.some((value) =>
String(fieldValue)
.toLowerCase()
.startsWith(String(value).toLowerCase())
)
case "ends_with":
return values.some((value) =>
String(fieldValue)
.toLowerCase()
.endsWith(String(value).toLowerCase())
)
case "equals":
return fieldValue === values[0]
case "not_equals":
return fieldValue !== values[0]
case "greater_than":
return Number(fieldValue) > Number(values[0])
case "less_than":
return Number(fieldValue) < Number(values[0])
case "greater_than_or_equal":
return Number(fieldValue) >= Number(values[0])
case "less_than_or_equal":
return Number(fieldValue) <= Number(values[0])
case "between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) >= min && Number(fieldValue) <= max
}
return true
case "not_between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) < min || Number(fieldValue) > max
}
return true
case "empty":
return fieldValue === "" || fieldValue == null
case "not_empty":
return fieldValue !== "" && fieldValue != null
default:
return true
}
})
})
return result
}
function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return "Select..."
if (values.length > 1) return `${values.length} selected`
return null
}
function formatCompactCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
notation: "compact",
maximumFractionDigits: 1,
}).format(value)
}
function createDefaultRenewalFilters(): Filter[] {
return [createFilter("accountName", "contains", [""])]
}
const DEFAULT_COLUMN_ORDER = [
"select",
"account",
"renewalWindow",
"arr",
"health",
"usage",
"stage",
"risk",
"invoiceStatus",
"sponsorStatus",
"region",
"actions",
]
type TableDensity = "compact" | "comfortable"
type DisplayColumn =
| "health"
| "usage"
| "stage"
| "risk"
| "invoiceStatus"
| "sponsorStatus"
| "region"
const TABLE_DENSITY_OPTIONS: { value: TableDensity; label: string }[] = [
{ value: "compact", label: "Compact" },
{ value: "comfortable", label: "Comfortable" },
]
const DISPLAY_COLUMNS: { key: DisplayColumn; label: string }[] = [
{ key: "health", label: "Health" },
{ key: "usage", label: "Usage" },
{ key: "stage", label: "Stage" },
{ key: "risk", label: "Risk" },
{ key: "invoiceStatus", label: "Invoice" },
{ key: "sponsorStatus", label: "Sponsor" },
{ key: "region", label: "Region" },
]
interface ToolbarProps {
filters: Filter[]
fields: FilterFieldConfig[]
onFiltersChange: (filters: Filter[]) => void
onClearFilters: () => void
showClearButton: boolean
tableDensity: TableDensity
onTableDensityChange: (value: TableDensity) => void
columnsResizable: boolean
onColumnsResizableChange: (value: boolean) => void
columnsMovable: boolean
onColumnsMovableChange: (value: boolean) => void
visibleColumns: Record<DisplayColumn, boolean>
onToggleColumn: (columnId: DisplayColumn) => void
}
function Toolbar({
filters,
fields,
onFiltersChange,
onClearFilters,
showClearButton,
tableDensity,
onTableDensityChange,
columnsResizable,
onColumnsResizableChange,
columnsMovable,
onColumnsMovableChange,
visibleColumns,
onToggleColumn,
}: ToolbarProps) {
return (
<div className="flex flex-col gap-3 px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
{/* Actions */}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Filters
filters={filters}
fields={fields}
onChange={onFiltersChange}
size="default"
trigger={
<Button
type="button"
variant="outline"
aria-label="Renewal filters"
>
<FilterIcon aria-hidden="true" />
Filters
</Button>
}
/>
{showClearButton ? (
<Button type="button" variant="outline" onClick={onClearFilters}>
Clear filters
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
<Popover>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
aria-label="Table settings"
>
<Settings2Icon className="size-4" aria-hidden="true" />
Settings
</Button>
}
/>
<PopoverContent align="end" className="w-[320px] p-0">
<FieldGroup className="gap-3 px-3.5 py-3">
<div className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">
Table
</div>
<div className="space-y-0">
<Field
orientation="horizontal"
className="min-h-9 items-center justify-between gap-3"
>
<FieldLabel className="text-sm font-normal">
Density
</FieldLabel>
<Select
value={tableDensity}
onValueChange={(value) =>
onTableDensityChange(value as TableDensity)
}
items={TABLE_DENSITY_OPTIONS}
>
<SelectTrigger size="sm" className="w-[132px] shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{TABLE_DENSITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field
orientation="horizontal"
className="min-h-9 items-center justify-between gap-3"
>
<FieldLabel className="text-sm font-normal">
Resizable columns
</FieldLabel>
<Switch
size="sm"
checked={columnsResizable}
onCheckedChange={onColumnsResizableChange}
/>
</Field>
<Field
orientation="horizontal"
className="min-h-9 items-center justify-between gap-3"
>
<FieldLabel className="text-sm font-normal">
Movable columns
</FieldLabel>
<Switch
size="sm"
checked={columnsMovable}
onCheckedChange={onColumnsMovableChange}
/>
</Field>
</div>
</div>
<FieldSeparator className="-mx-3.5" />
<div className="space-y-2.5">
<div className="text-muted-foreground text-xs font-medium">
Display columns
</div>
<div className="flex flex-wrap gap-1.5">
{DISPLAY_COLUMNS.map((column) => {
const active = visibleColumns[column.key]
return (
<Button
key={column.key}
type="button"
size="xs"
variant={active ? "secondary" : "outline"}
className={cn(
"rounded-full",
active && "border-foreground/10"
)}
onClick={() => onToggleColumn(column.key)}
>
{active ? (
<CheckIcon className="size-3.5" aria-hidden="true" />
) : null}
{column.label}
</Button>
)
})}
</div>
</div>
</FieldGroup>
</PopoverContent>
</Popover>
</div>
</div>
)
}
export function RenewalsCommandGridView() {
const [renewals, setRenewals] = useState<IRenewalRecord[]>(RENEWAL_RECORDS)
const [tableDensity, setTableDensity] = useState<TableDensity>("compact")
const [columnsResizable, setColumnsResizable] = useState(true)
const [columnsMovable, setColumnsMovable] = useState(true)
const [sorting, setSorting] = useState<SortingState>([
{ id: "renewalWindow", desc: false },
{ id: "arr", desc: true },
])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
health: true,
stage: true,
risk: true,
usage: false,
invoiceStatus: false,
sponsorStatus: false,
region: false,
})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [columnOrder, setColumnOrder] = useState(DEFAULT_COLUMN_ORDER)
const [filters, setFilters] = useState<Filter[]>(createDefaultRenewalFilters)
const [bulkOwnerValue, setBulkOwnerValue] = useState(RENEWAL_OWNERS[0].value)
const [bulkStageValue, setBulkStageValue] =
useState<RenewalStage>("Commercial review")
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: "accountName",
label: "Account",
icon: (
<Building2Icon className="size-3.5" aria-hidden="true" />
),
type: "text",
className: "w-[200px]",
placeholder: "Search...",
},
{
key: "segment",
label: "Segment",
icon: (
<LayersIcon className="size-3.5" aria-hidden="true" />
),
type: "select",
searchable: false,
className: "w-[160px]",
options: RENEWAL_SEGMENT_ORDER.map((segment) => ({
value: segment,
label: segment,
})),
},
{
key: "stage",
label: "Stage",
icon: (
<GitBranchIcon className="size-3.5" aria-hidden="true" />
),
type: "select",
searchable: false,
className: "w-[180px]",
options: RENEWAL_STAGE_ORDER.map((stage) => ({
value: stage,
label: stage,
})),
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return <StageBadge stage={values[0] as RenewalStage} />
},
},
{
key: "risk",
label: "Risk",
icon: (
<TriangleAlertIcon className="size-3.5" aria-hidden="true" />
),
type: "select",
searchable: false,
className: "w-[140px]",
options: RENEWAL_RISK_ORDER.map((risk) => ({
value: risk,
label: risk,
})),
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return <RiskBadge risk={values[0] as RenewalRisk} />
},
},
{
key: "renewalWindow",
label: "Renewal window",
icon: (
<CalendarClockIcon className="size-3.5" aria-hidden="true" />
),
type: "select",
searchable: false,
className: "w-[170px]",
options: RENEWAL_WINDOW_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
})),
},
{
key: "ownerName",
label: "Owner",
icon: (
<UserRoundIcon className="size-3.5" aria-hidden="true" />
),
type: "select",
searchable: false,
className: "w-[170px]",
options: RENEWAL_OWNERS.map((owner) => ({
value: owner.label,
label: owner.label,
})),
},
],
[]
)
const filteredData = useMemo(
() => applyFiltersToData(renewals, filters),
[filters, renewals]
)
const visibleColumns = useMemo<Record<DisplayColumn, boolean>>(
() => ({
health: columnVisibility.health !== false,
usage: columnVisibility.usage !== false,
stage: columnVisibility.stage !== false,
risk: columnVisibility.risk !== false,
invoiceStatus: columnVisibility.invoiceStatus !== false,
sponsorStatus: columnVisibility.sponsorStatus !== false,
region: columnVisibility.region !== false,
}),
[columnVisibility]
)
const dueInThirtyCount = useMemo(
() => filteredData.filter((renewal) => renewal.daysToRenewal <= 30).length,
[filteredData]
)
const blockerCount = useMemo(
() =>
filteredData.filter(
(renewal) =>
renewal.invoiceStatus !== "Ready" ||
renewal.sponsorStatus !== "Confirmed"
).length,
[filteredData]
)
const arrAtRisk = useMemo(
() =>
filteredData.reduce(
(total, renewal) =>
renewal.risk === "High" || renewal.risk === "Critical"
? total + renewal.arr
: total,
0
),
[filteredData]
)
const handleOpenAccount = useCallback((renewal: IRenewalRecord) => {
toast.info("Open account workspace", {
description: `${renewal.accountName} · ${renewal.ownerName}`,
})
}, [])
const handleCreateBrief = useCallback((renewal: IRenewalRecord) => {
toast.success("Renewal brief created", {
description: `${renewal.accountName} is ready for executive review.`,
})
}, [])
const handleEscalateReview = useCallback((renewal: IRenewalRecord) => {
setRenewals((current) =>
current.map((record) =>
record.id === renewal.id
? {
...record,
risk: "Critical",
stage:
record.stage === "Committed" ? record.stage : "Legal review",
}
: record
)
)
toast.success("Exec escalation created", {
description: `${renewal.accountName} moved into an urgent board-review track.`,
})
}, [])
const columns = useMemo(
() =>
createRenewalColumns({
onOpenAccount: handleOpenAccount,
onCreateBrief: handleCreateBrief,
onEscalateReview: handleEscalateReview,
}),
[handleCreateBrief, handleEscalateReview, handleOpenAccount]
)
const table = useReactTable({
columns,
data: filteredData,
getRowId: (row) => row.id,
state: {
sorting,
columnVisibility,
columnOrder,
rowSelection,
},
enableRowSelection: true,
columnResizeMode: "onChange",
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
onColumnOrderChange: setColumnOrder,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const selectedRows = table
.getSelectedRowModel()
.rows.map((row) => row.original.id)
const selectedCount = selectedRows.length
const showClearButton = filters.length > 0
const handleFiltersChange = useCallback((nextFilters: Filter[]) => {
setFilters(nextFilters)
}, [])
const handleClearFilters = useCallback(() => {
setFilters(createDefaultRenewalFilters())
}, [])
const handleClearSelection = useCallback(() => {
setRowSelection({})
}, [])
const handleToggleColumn = useCallback((columnId: DisplayColumn) => {
setColumnVisibility((current) => ({
...current,
[columnId]: current[columnId] === false,
}))
}, [])
const handleApplySelected = useCallback(() => {
if (selectedRows.length === 0) return
const nextOwner = RENEWAL_OWNERS.find(
(owner) => owner.value === bulkOwnerValue
)
if (!nextOwner) return
setRenewals((current) =>
current.map((renewal) =>
selectedRows.includes(renewal.id)
? {
...renewal,
ownerName: nextOwner.label,
ownerAvatar: nextOwner.avatar,
stage: bulkStageValue,
}
: renewal
)
)
setRowSelection({})
toast.success("Renewals updated", {
description: `${selectedRows.length} account${selectedRows.length === 1 ? "" : "s"} moved to ${nextOwner.label} and ${bulkStageValue}.`,
})
}, [bulkOwnerValue, bulkStageValue, selectedRows])
return (
<TooltipProvider delay={200}>
{/* Table */}
<DataGrid
table={table}
recordCount={filteredData.length}
emptyMessage="No renewals match this view. Clear filters or widen the search."
tableLayout={{
columnsResizable,
columnsMovable,
columnsVisibility: true,
dense: tableDensity === "compact",
width: "auto",
}}
>
<RenewalsCommandCard
title="Renewals Review"
description={`${dueInThirtyCount} due in 30d, ${blockerCount} blocked, ${formatCompactCurrency(arrAtRisk)} ARR at risk.`}
action={
<Button
type="button"
size="sm"
onClick={() =>
toast.success("Transaction started", {
description: "New renewal entry opened.",
})
}
>
<PlusIcon aria-hidden="true" />
New transaction
</Button>
}
>
<Toolbar
filters={filters}
fields={filterFields}
onFiltersChange={handleFiltersChange}
onClearFilters={handleClearFilters}
showClearButton={showClearButton}
tableDensity={tableDensity}
onTableDensityChange={setTableDensity}
columnsResizable={columnsResizable}
onColumnsResizableChange={setColumnsResizable}
columnsMovable={columnsMovable}
onColumnsMovableChange={setColumnsMovable}
visibleColumns={visibleColumns}
onToggleColumn={handleToggleColumn}
/>
{selectedCount > 0 ? (
<RenewalSelectionBar
selectedCount={selectedCount}
ownerValue={bulkOwnerValue}
stageValue={bulkStageValue}
ownerOptions={RENEWAL_OWNERS}
onOwnerChange={setBulkOwnerValue}
onStageChange={setBulkStageValue}
onApply={handleApplySelected}
onClear={handleClearSelection}
/>
) : (
<Separator />
)}
<DataGridScrollArea className="h-[540px]">
<DataGridTable />
</DataGridScrollArea>
</RenewalsCommandCard>
</DataGrid>
</TooltipProvider>
)
}
@@ -0,0 +1,738 @@
import { type ReactNode } from "react"
import { AnthropicBlack } from "@evobgp/ui/components/svgs/anthropicBlack"
import { AnthropicWhite } from "@evobgp/ui/components/svgs/anthropicWhite"
import { Convex } from "@evobgp/ui/components/svgs/convex"
import { GoogleCloud } from "@evobgp/ui/components/svgs/googleCloud"
import { Hono } from "@evobgp/ui/components/svgs/hono"
import { Mintlify } from "@evobgp/ui/components/svgs/mintlify"
import { N8n } from "@evobgp/ui/components/svgs/n8n"
import { Neon } from "@evobgp/ui/components/svgs/neon"
import { Openai } from "@evobgp/ui/components/svgs/openai"
import { OpenaiDark } from "@evobgp/ui/components/svgs/openaiDark"
import { Paper } from "@evobgp/ui/components/svgs/paper"
import { Planetscale } from "@evobgp/ui/components/svgs/planetscale"
import { PlanetscaleDark } from "@evobgp/ui/components/svgs/planetscaleDark"
import { Prisma } from "@evobgp/ui/components/svgs/prisma"
import { PrismaDark } from "@evobgp/ui/components/svgs/prismaDark"
import { RemixDark } from "@evobgp/ui/components/svgs/remixDark"
import { RemixLight } from "@evobgp/ui/components/svgs/remixLight"
import { ResendIconBlack } from "@evobgp/ui/components/svgs/resendIconBlack"
import { ResendIconWhite } from "@evobgp/ui/components/svgs/resendIconWhite"
import { Slack } from "@evobgp/ui/components/svgs/slack"
import { Stripe } from "@evobgp/ui/components/svgs/stripe"
import { Supabase } from "@evobgp/ui/components/svgs/supabase"
import { Zoom } from "@evobgp/ui/components/svgs/zoom"
export type RenewalStage =
| "Preparing"
| "Commercial review"
| "Legal review"
| "Committed"
export type RenewalRisk = "Low" | "Medium" | "High" | "Critical"
export type RenewalSegment = "Enterprise" | "Scale" | "Mid-market"
export type RenewalInvoiceStatus = "Ready" | "Finance review" | "Blocked"
export type RenewalSponsorStatus = "Confirmed" | "At risk" | "Missing"
export interface RenewalOwnerOption {
value: string
label: string
avatar: string
teamLabel: string
}
export interface IRenewalRecord {
id: string
accountName: string
accountLogo: ReactNode
ownerName: string
ownerAvatar: string
segment: RenewalSegment
region: string
renewalDate: string
daysToRenewal: number
arr: number
expansionPotential: number
healthScore: number
usageTrend: number[]
stage: RenewalStage
risk: RenewalRisk
invoiceStatus: RenewalInvoiceStatus
sponsorStatus: RenewalSponsorStatus
tags: string[]
}
export const RENEWAL_STAGE_ORDER: RenewalStage[] = [
"Preparing",
"Commercial review",
"Legal review",
"Committed",
]
export const RENEWAL_RISK_ORDER: RenewalRisk[] = [
"Low",
"Medium",
"High",
"Critical",
]
export const RENEWAL_SEGMENT_ORDER: RenewalSegment[] = [
"Enterprise",
"Scale",
"Mid-market",
]
export const RENEWAL_WINDOW_OPTIONS = [
{ value: "next-30", label: "Next 30 days" },
{ value: "31-60", label: "31-60 days" },
{ value: "61-90", label: "61-90 days" },
{ value: "90-plus", label: "90+ days" },
] as const
const OPENAI_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<Openai className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<OpenaiDark className="size-5" />
</span>
</>
)
const ANTHROPIC_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<AnthropicBlack className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<AnthropicWhite className="size-5" />
</span>
</>
)
const PRISMA_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<Prisma className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<PrismaDark className="size-5" />
</span>
</>
)
const PLANETSCALE_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<Planetscale className="text-foreground size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<PlanetscaleDark className="size-5" />
</span>
</>
)
const RESEND_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<ResendIconBlack className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<ResendIconWhite className="size-5" />
</span>
</>
)
const REMIX_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<RemixLight className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<RemixDark className="size-5" />
</span>
</>
)
function VercelMark() {
return (
<svg
viewBox="0 0 76 65"
className="text-foreground size-5 shrink-0"
aria-hidden="true"
>
<path fill="currentColor" d="M37.5274 0L75.0548 65H0L37.5274 0Z" />
</svg>
)
}
export function getRenewalWindowValue(daysToRenewal: number) {
if (daysToRenewal <= 30) return "next-30"
if (daysToRenewal <= 60) return "31-60"
if (daysToRenewal <= 90) return "61-90"
return "90-plus"
}
export function getRenewalWindowLabel(daysToRenewal: number) {
const match = RENEWAL_WINDOW_OPTIONS.find(
(option) => option.value === getRenewalWindowValue(daysToRenewal)
)
return match?.label ?? "90+ days"
}
export const RENEWAL_OWNERS: RenewalOwnerOption[] = [
{
value: "maya-patel",
label: "Maya Patel",
avatar:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
teamLabel: "Enterprise coverage",
},
{
value: "jonah-lee",
label: "Jonah Lee",
avatar:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
teamLabel: "Strategic expansion",
},
{
value: "nina-santos",
label: "Nina Santos",
avatar:
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
teamLabel: "Commercial renewals",
},
{
value: "omar-haddad",
label: "Omar Haddad",
avatar:
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
teamLabel: "Expansion programs",
},
{
value: "priya-menon",
label: "Priya Menon",
avatar:
"https://images.unsplash.com/photo-1557296387-5358ad7997bb?w=96&h=96&dpr=2&q=80",
teamLabel: "Finance alignment",
},
]
const RENEWAL_RECORD_SEEDS: IRenewalRecord[] = [
{
id: "REN-1842",
accountName: "Stripe",
accountLogo: <Stripe className="size-5" aria-hidden="true" />,
ownerName: "Maya Patel",
ownerAvatar: RENEWAL_OWNERS[0].avatar,
segment: "Enterprise",
region: "North America",
renewalDate: "2026-05-12",
daysToRenewal: 7,
arr: 420000,
expansionPotential: 95000,
healthScore: 46,
usageTrend: [86, 84, 82, 79, 74, 68, 63, 57],
stage: "Legal review",
risk: "Critical",
invoiceStatus: "Blocked",
sponsorStatus: "Missing",
tags: ["Usage drop", "Legal redlines"],
},
{
id: "REN-1837",
accountName: "Supabase",
accountLogo: <Supabase className="size-5" aria-hidden="true" />,
ownerName: "Nina Santos",
ownerAvatar: RENEWAL_OWNERS[2].avatar,
segment: "Enterprise",
region: "North America",
renewalDate: "2026-05-17",
daysToRenewal: 12,
arr: 365000,
expansionPotential: 120000,
healthScore: 58,
usageTrend: [72, 73, 71, 69, 66, 63, 61, 60],
stage: "Commercial review",
risk: "High",
invoiceStatus: "Finance review",
sponsorStatus: "At risk",
tags: ["Procurement", "Price sensitivity"],
},
{
id: "REN-1831",
accountName: "OpenAI",
accountLogo: OPENAI_LOGO,
ownerName: "Jonah Lee",
ownerAvatar: RENEWAL_OWNERS[1].avatar,
segment: "Scale",
region: "EMEA",
renewalDate: "2026-05-29",
daysToRenewal: 24,
arr: 182000,
expansionPotential: 42000,
healthScore: 63,
usageTrend: [68, 69, 70, 71, 70, 68, 66, 64],
stage: "Preparing",
risk: "High",
invoiceStatus: "Ready",
sponsorStatus: "Confirmed",
tags: ["Champion change", "Board visibility"],
},
{
id: "REN-1828",
accountName: "Mintlify",
accountLogo: <Mintlify className="size-5" aria-hidden="true" />,
ownerName: "Omar Haddad",
ownerAvatar: RENEWAL_OWNERS[3].avatar,
segment: "Scale",
region: "North America",
renewalDate: "2026-06-04",
daysToRenewal: 30,
arr: 148000,
expansionPotential: 86000,
healthScore: 74,
usageTrend: [64, 66, 68, 70, 73, 76, 79, 82],
stage: "Preparing",
risk: "Medium",
invoiceStatus: "Ready",
sponsorStatus: "Confirmed",
tags: ["Expansion ready", "Multi-team rollout"],
},
{
id: "REN-1822",
accountName: "Anthropic",
accountLogo: ANTHROPIC_LOGO,
ownerName: "Priya Menon",
ownerAvatar: RENEWAL_OWNERS[4].avatar,
segment: "Enterprise",
region: "North America",
renewalDate: "2026-06-11",
daysToRenewal: 37,
arr: 515000,
expansionPotential: 0,
healthScore: 54,
usageTrend: [78, 76, 74, 71, 69, 66, 64, 61],
stage: "Commercial review",
risk: "High",
invoiceStatus: "Blocked",
sponsorStatus: "At risk",
tags: ["Budget freeze", "CFO review"],
},
{
id: "REN-1819",
accountName: "Convex",
accountLogo: <Convex className="size-5" aria-hidden="true" />,
ownerName: "Maya Patel",
ownerAvatar: RENEWAL_OWNERS[0].avatar,
segment: "Mid-market",
region: "APAC",
renewalDate: "2026-06-18",
daysToRenewal: 44,
arr: 92000,
expansionPotential: 18000,
healthScore: 71,
usageTrend: [62, 63, 64, 64, 65, 67, 68, 69],
stage: "Preparing",
risk: "Medium",
invoiceStatus: "Ready",
sponsorStatus: "Confirmed",
tags: ["Stable adoption", "Good champion"],
},
{
id: "REN-1814",
accountName: "Neon",
accountLogo: <Neon className="size-5" aria-hidden="true" />,
ownerName: "Jonah Lee",
ownerAvatar: RENEWAL_OWNERS[1].avatar,
segment: "Scale",
region: "North America",
renewalDate: "2026-06-26",
daysToRenewal: 52,
arr: 206000,
expansionPotential: 112000,
healthScore: 67,
usageTrend: [59, 60, 61, 60, 62, 64, 67, 70],
stage: "Commercial review",
risk: "Medium",
invoiceStatus: "Finance review",
sponsorStatus: "Confirmed",
tags: ["Cross-sell", "Security add-on"],
},
{
id: "REN-1810",
accountName: "PlanetScale",
accountLogo: PLANETSCALE_LOGO,
ownerName: "Nina Santos",
ownerAvatar: RENEWAL_OWNERS[2].avatar,
segment: "Enterprise",
region: "EMEA",
renewalDate: "2026-07-02",
daysToRenewal: 58,
arr: 438000,
expansionPotential: 64000,
healthScore: 81,
usageTrend: [74, 75, 77, 79, 81, 83, 84, 86],
stage: "Preparing",
risk: "Low",
invoiceStatus: "Ready",
sponsorStatus: "Confirmed",
tags: ["Healthy usage", "Multi-year ask"],
},
{
id: "REN-1806",
accountName: "Prisma",
accountLogo: PRISMA_LOGO,
ownerName: "Omar Haddad",
ownerAvatar: RENEWAL_OWNERS[3].avatar,
segment: "Mid-market",
region: "North America",
renewalDate: "2026-07-08",
daysToRenewal: 64,
arr: 124000,
expansionPotential: 52000,
healthScore: 77,
usageTrend: [61, 63, 65, 68, 70, 73, 76, 78],
stage: "Preparing",
risk: "Low",
invoiceStatus: "Ready",
sponsorStatus: "Confirmed",
tags: ["Expansion champion", "Product depth"],
},
{
id: "REN-1801",
accountName: "Resend",
accountLogo: RESEND_LOGO,
ownerName: "Priya Menon",
ownerAvatar: RENEWAL_OWNERS[4].avatar,
segment: "Scale",
region: "North America",
renewalDate: "2026-07-13",
daysToRenewal: 69,
arr: 164000,
expansionPotential: 0,
healthScore: 52,
usageTrend: [67, 66, 64, 62, 59, 57, 54, 52],
stage: "Commercial review",
risk: "High",
invoiceStatus: "Finance review",
sponsorStatus: "At risk",
tags: ["Seat contraction", "Procurement loop"],
},
{
id: "REN-1798",
accountName: "Slack",
accountLogo: <Slack className="size-5 shrink-0" aria-hidden="true" />,
ownerName: "Maya Patel",
ownerAvatar: RENEWAL_OWNERS[0].avatar,
segment: "Mid-market",
region: "EMEA",
renewalDate: "2026-07-18",
daysToRenewal: 74,
arr: 88000,
expansionPotential: 24000,
healthScore: 69,
usageTrend: [54, 55, 56, 58, 60, 61, 63, 64],
stage: "Preparing",
risk: "Medium",
invoiceStatus: "Ready",
sponsorStatus: "Confirmed",
tags: ["Team growth", "Regional rollout"],
},
{
id: "REN-1794",
accountName: "Zoom",
accountLogo: <Zoom className="size-5 shrink-0" aria-hidden="true" />,
ownerName: "Jonah Lee",
ownerAvatar: RENEWAL_OWNERS[1].avatar,
segment: "Scale",
region: "North America",
renewalDate: "2026-07-24",
daysToRenewal: 80,
arr: 212000,
expansionPotential: 135000,
healthScore: 83,
usageTrend: [70, 72, 74, 77, 80, 83, 86, 88],
stage: "Preparing",
risk: "Low",
invoiceStatus: "Ready",
sponsorStatus: "Confirmed",
tags: ["Expansion approved", "Executive sponsor"],
},
{
id: "REN-1791",
accountName: "Hono",
accountLogo: <Hono className="size-5" aria-hidden="true" />,
ownerName: "Nina Santos",
ownerAvatar: RENEWAL_OWNERS[2].avatar,
segment: "Enterprise",
region: "North America",
renewalDate: "2026-07-31",
daysToRenewal: 87,
arr: 610000,
expansionPotential: 180000,
healthScore: 79,
usageTrend: [73, 75, 77, 78, 80, 82, 83, 85],
stage: "Commercial review",
risk: "Medium",
invoiceStatus: "Finance review",
sponsorStatus: "Confirmed",
tags: ["Security pack", "Platform standard"],
},
{
id: "REN-1787",
accountName: "Remix",
accountLogo: REMIX_LOGO,
ownerName: "Omar Haddad",
ownerAvatar: RENEWAL_OWNERS[3].avatar,
segment: "Mid-market",
region: "APAC",
renewalDate: "2026-08-05",
daysToRenewal: 92,
arr: 76000,
expansionPotential: 21000,
healthScore: 72,
usageTrend: [58, 59, 60, 62, 64, 65, 67, 68],
stage: "Preparing",
risk: "Low",
invoiceStatus: "Ready",
sponsorStatus: "Confirmed",
tags: ["Partner motion", "Upsell path"],
},
{
id: "REN-1783",
accountName: "Paper",
accountLogo: <Paper className="size-5" aria-hidden="true" />,
ownerName: "Priya Menon",
ownerAvatar: RENEWAL_OWNERS[4].avatar,
segment: "Scale",
region: "EMEA",
renewalDate: "2026-08-11",
daysToRenewal: 98,
arr: 154000,
expansionPotential: 0,
healthScore: 57,
usageTrend: [63, 62, 60, 58, 57, 55, 54, 53],
stage: "Preparing",
risk: "Medium",
invoiceStatus: "Finance review",
sponsorStatus: "At risk",
tags: ["Utilization drift", "Renewal watch"],
},
{
id: "REN-1778",
accountName: "n8n",
accountLogo: <N8n className="size-5" aria-hidden="true" />,
ownerName: "Maya Patel",
ownerAvatar: RENEWAL_OWNERS[0].avatar,
segment: "Enterprise",
region: "North America",
renewalDate: "2026-08-20",
daysToRenewal: 107,
arr: 448000,
expansionPotential: 132000,
healthScore: 85,
usageTrend: [79, 80, 82, 84, 86, 87, 89, 90],
stage: "Preparing",
risk: "Low",
invoiceStatus: "Ready",
sponsorStatus: "Confirmed",
tags: ["Expansion mapped", "Board champion"],
},
{
id: "REN-1772",
accountName: "Vercel",
accountLogo: <VercelMark />,
ownerName: "Jonah Lee",
ownerAvatar: RENEWAL_OWNERS[1].avatar,
segment: "Scale",
region: "LATAM",
renewalDate: "2026-08-29",
daysToRenewal: 116,
arr: 132000,
expansionPotential: 34000,
healthScore: 68,
usageTrend: [60, 61, 62, 63, 65, 66, 66, 67],
stage: "Preparing",
risk: "Medium",
invoiceStatus: "Ready",
sponsorStatus: "Confirmed",
tags: ["Regional growth", "Elastic usage"],
},
{
id: "REN-1768",
accountName: "Google Cloud",
accountLogo: <GoogleCloud className="size-5" aria-hidden="true" />,
ownerName: "Nina Santos",
ownerAvatar: RENEWAL_OWNERS[2].avatar,
segment: "Enterprise",
region: "North America",
renewalDate: "2026-09-09",
daysToRenewal: 127,
arr: 388000,
expansionPotential: 74000,
healthScore: 64,
usageTrend: [66, 67, 68, 68, 67, 66, 65, 64],
stage: "Preparing",
risk: "Medium",
invoiceStatus: "Finance review",
sponsorStatus: "Confirmed",
tags: ["Compliance review", "Migration timing"],
},
]
const REGIONS = ["North America", "EMEA", "APAC", "LATAM"] as const
const TAG_VARIANTS = [
"Budget review",
"Champion shift",
"Upsell path",
"Contract cleanup",
"Security review",
"Procurement lane",
"Usage rebound",
"Expansion plan",
] as const
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function roundCurrency(value: number) {
return Math.round(value / 1000) * 1000
}
function addDays(isoDate: string, days: number) {
const nextDate = new Date(`${isoDate}T00:00:00`)
nextDate.setDate(nextDate.getDate() + days)
return nextDate.toISOString().slice(0, 10)
}
function shiftUsageTrend(values: number[], cycle: number, risk: RenewalRisk) {
const directionalOffset = risk === "Low" ? 1 : risk === "Critical" ? -2 : 0
const cycleOffset = ((cycle % 4) - 1.5) * 1.5
return values.map((value, index) =>
clamp(Math.round(value + directionalOffset * index + cycleOffset), 34, 96)
)
}
function resolveOwner(seed: IRenewalRecord, cycle: number, index: number) {
const baseOwnerIndex = RENEWAL_OWNERS.findIndex(
(owner) => owner.label === seed.ownerName
)
return RENEWAL_OWNERS[
(Math.max(baseOwnerIndex, 0) + cycle + index) % RENEWAL_OWNERS.length
]
}
function resolveRisk(score: number, daysToRenewal: number): RenewalRisk {
if (score < 50 || daysToRenewal <= 14) return "Critical"
if (score < 62 || daysToRenewal <= 35) return "High"
if (score < 78) return "Medium"
return "Low"
}
function resolveStage(
baseStage: RenewalStage,
risk: RenewalRisk,
daysToRenewal: number
): RenewalStage {
if (risk === "Critical" || daysToRenewal <= 14) return "Legal review"
if (risk === "High") return "Commercial review"
if (baseStage === "Committed" && risk === "Low") return "Committed"
return "Preparing"
}
function resolveInvoiceStatus(
baseStatus: RenewalInvoiceStatus,
risk: RenewalRisk
): RenewalInvoiceStatus {
if (risk === "Critical") return "Blocked"
if (risk === "High") return "Finance review"
return baseStatus === "Blocked" ? "Finance review" : "Ready"
}
function resolveSponsorStatus(risk: RenewalRisk): RenewalSponsorStatus {
if (risk === "Critical") return "Missing"
if (risk === "High") return "At risk"
return "Confirmed"
}
function buildRenewalRecord(
seed: IRenewalRecord,
cycle: number,
index: number
) {
if (cycle === 0) {
return seed
}
const owner = resolveOwner(seed, cycle, index)
const daysOffset = cycle * 14 + (index % 3) * 3
const daysToRenewal = seed.daysToRenewal + daysOffset
const healthScore = clamp(
seed.healthScore + (((cycle + index) % 5) - 2) * 4,
42,
92
)
const risk = resolveRisk(healthScore, daysToRenewal)
const stage = resolveStage(seed.stage, risk, daysToRenewal)
const invoiceStatus = resolveInvoiceStatus(seed.invoiceStatus, risk)
const sponsorStatus = resolveSponsorStatus(risk)
const arrFactor = 1 + cycle * 0.045 + ((index % 4) - 1.5) * 0.03
const expansionFactor =
seed.expansionPotential === 0
? risk === "Low"
? 0.12
: 0
: 1 + ((cycle % 3) - 1) * 0.12 + (risk === "Low" ? 0.14 : 0)
return {
...seed,
id: `REN-${2000 + cycle * 100 + index * 3}`,
accountName: seed.accountName,
ownerName: owner.label,
ownerAvatar: owner.avatar,
segment:
RENEWAL_SEGMENT_ORDER[(cycle + index) % RENEWAL_SEGMENT_ORDER.length],
region: REGIONS[(cycle + index) % REGIONS.length],
renewalDate: addDays(seed.renewalDate, daysOffset),
daysToRenewal,
arr: roundCurrency(seed.arr * Math.max(0.68, arrFactor)),
expansionPotential: roundCurrency(
Math.max(0, seed.expansionPotential * expansionFactor)
),
healthScore,
usageTrend: shiftUsageTrend(seed.usageTrend, cycle + index, risk),
stage,
risk,
invoiceStatus,
sponsorStatus,
tags: [seed.tags[0], TAG_VARIANTS[(cycle + index) % TAG_VARIANTS.length]],
}
}
export const RENEWAL_RECORDS: IRenewalRecord[] = Array.from(
{ length: 2 },
(_, cycle) =>
RENEWAL_RECORD_SEEDS.map((seed, index) =>
buildRenewalRecord(seed, cycle, index)
)
)
.flat()
.sort((left, right) => {
if (left.daysToRenewal !== right.daysToRenewal) {
return left.daysToRenewal - right.daysToRenewal
}
return right.arr - left.arr
})
@@ -0,0 +1,108 @@
import { Badge } from "@/components/reui/badge"
import { Button } from "@evobgp/ui/components/button"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import {
RENEWAL_STAGE_ORDER,
type RenewalOwnerOption,
type RenewalStage,
} from "./data"
interface RenewalSelectionBarProps {
selectedCount: number
ownerValue: string
stageValue: RenewalStage
ownerOptions: RenewalOwnerOption[]
onOwnerChange: (value: string) => void
onStageChange: (value: RenewalStage) => void
onApply: () => void
onClear: () => void
}
export function RenewalSelectionBar({
selectedCount,
ownerValue,
stageValue,
ownerOptions,
onOwnerChange,
onStageChange,
onApply,
onClear,
}: RenewalSelectionBarProps) {
return (
<div className="bg-muted/30 flex flex-col gap-3 border-y px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-center gap-2">
<Badge size="sm" variant="outline" radius="full">
{selectedCount} selected
</Badge>
<span className="text-muted-foreground text-xs">
Update owner or stage.
</span>
</div>
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
<Select
value={ownerValue}
onValueChange={(value) => {
if (!value) return
onOwnerChange(value)
}}
>
<SelectTrigger
size="sm"
className="w-full sm:w-[190px]"
aria-label="Assign owner"
>
<SelectValue placeholder="Assign owner" />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{ownerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select
value={stageValue}
onValueChange={(value) => onStageChange(value as RenewalStage)}
>
<SelectTrigger
size="sm"
className="w-full sm:w-[190px]"
aria-label="Set stage"
>
<SelectValue placeholder="Set stage" />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{RENEWAL_STAGE_ORDER.map((stage) => (
<SelectItem key={stage} value={stage}>
{stage}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button type="button" size="sm" variant="ghost" onClick={onClear}>
Deselect
</Button>
<Button type="button" size="sm" onClick={onApply}>
Apply updates
</Button>
</div>
</div>
)
}
@@ -0,0 +1,63 @@
import { type ReactNode } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@evobgp/ui/components/card"
interface RenewalsCommandCardProps {
title: string
description?: ReactNode
meta?: ReactNode
action?: ReactNode
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
}
export function RenewalsCommandCard({
title,
description,
meta,
action,
children,
footer,
className,
contentClassName,
}: RenewalsCommandCardProps) {
return (
<Card className={cn("w-full gap-0 p-0", className)}>
{/* Header */}
<CardHeader className="flex flex-row items-center justify-between gap-4 border-b px-5 py-4">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<CardTitle>{title}</CardTitle>
{description ? (
<CardDescription className="text-xs">{description}</CardDescription>
) : null}
{meta ? (
<div className="flex flex-wrap items-center gap-2">{meta}</div>
) : null}
</div>
{action ? (
<CardAction className="self-center">{action}</CardAction>
) : null}
</CardHeader>
{/* Content */}
<CardContent className={cn("p-0", contentClassName)}>
{children}
</CardContent>
{footer ? (
<CardFooter className="border-t px-5 py-3">{footer}</CardFooter>
) : null}
</Card>
)
}
@@ -0,0 +1,33 @@
"use client"
import { useEffect, useState } from "react"
import { RenewalsCommandGridView } from "./components/data-grid-view"
export function Page() {
const [isReady, setIsReady] = useState(false)
useEffect(() => {
setIsReady(true)
}, [])
return (
<main
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Renewals command data grid
</h1>
{isReady ? (
<RenewalsCommandGridView />
) : (
<div
className="bg-card border-border w-full rounded-xl border"
style={{ height: 640 }}
aria-hidden="true"
/>
)}
</main>
)
}
@@ -0,0 +1,58 @@
import { IconStack } from "@/components/reui/icon-stack"
import { Button } from "@evobgp/ui/components/button"
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@evobgp/ui/components/empty"
import { RouteIcon } from "lucide-react"
export function EmptyState() {
return (
<div className="flex w-full max-w-4xl flex-col gap-4">
{/* Heading */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-semibold tracking-tight">
Routing Signals
</h2>
<p className="text-muted-foreground text-sm">
Shape incoming work before it reaches the roadmap.
</p>
</div>
<Button type="button" size="sm" className="w-full sm:w-auto">
Add signal
</Button>
</div>
{/* Card */}
<Card className="bg-muted min-h-[330px] p-0 shadow-none">
<CardContent className="flex min-h-[330px] items-center justify-center p-6 sm:p-10">
<Empty className="max-w-md gap-5 bg-transparent p-0">
<EmptyHeader className="items-center gap-5 text-center">
<EmptyMedia className="mb-0">
<IconStack aria-hidden="true">
<RouteIcon strokeWidth="1.9" aria-hidden="true" />
</IconStack>
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">
Create signals to route new work
</EmptyTitle>
<EmptyDescription className="max-w-sm text-sm/relaxed">
Define signals so every intake item starts with context.
</EmptyDescription>
</div>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,15 @@
import { EmptyState } from "./components/empty-state"
export function Page() {
return (
<main
className="flex min-h-svh w-full items-center justify-center p-4 sm:p-8 md:p-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Signal routing empty state
</h1>
<EmptyState />
</main>
)
}
@@ -0,0 +1,55 @@
import { Button } from "@evobgp/ui/components/button"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@evobgp/ui/components/empty"
import { ProjectsEmptyIllustration } from "./projects-empty-illustration"
import { QuickStartTemplates } from "./quick-start-templates"
import { PlusIcon, LayoutTemplateIcon } from "lucide-react"
export function EmptyState() {
return (
<div className="flex min-h-[820px] w-full max-w-[52rem] flex-col items-center justify-center gap-24 py-8 sm:gap-32 md:py-12">
{/* Empty State */}
<Empty className="flex-none gap-7 rounded-none border-0 p-0 md:p-0">
<EmptyHeader className="max-w-[28rem] gap-4">
<EmptyMedia className="mb-0">
<ProjectsEmptyIllustration />
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-2xl font-semibold tracking-tight">
No projects to show
</EmptyTitle>
<EmptyDescription className="max-w-[25rem] text-sm/relaxed">
Start a project from scratch or pick a template to launch your
first workspace and begin tracking tasks, goals, and progress.
</EmptyDescription>
</div>
</EmptyHeader>
<EmptyContent className="max-w-none gap-0">
<div className="flex w-full flex-wrap items-center justify-center gap-2">
<Button type="button">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
New project
</Button>
<Button type="button" variant="outline">
<LayoutTemplateIcon data-icon="inline-start" aria-hidden="true" />
Explore templates
</Button>
</div>
</EmptyContent>
</Empty>
<div className="w-full max-w-md">
<QuickStartTemplates />
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More