Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55eb2a6c89 | ||
|
|
a3f3ffd672 |
@@ -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: "nick@reui.io",
|
||||
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'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,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,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: "lena@northline.app",
|
||||
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: "marcus@northline.app",
|
||||
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: "imani@northline.app",
|
||||
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: "theo@northline.app",
|
||||
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: "noor@northline.app",
|
||||
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: "avery@northline.app",
|
||||
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: "sofia@northline.app",
|
||||
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: "dev@northline.app",
|
||||
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
+212
@@ -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>
|
||||
)
|
||||
}
|
||||
+60
@@ -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 (UTC−8)",
|
||||
},
|
||||
{
|
||||
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 (UTC−5)",
|
||||
},
|
||||
{
|
||||
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 (UTC−8)",
|
||||
},
|
||||
{
|
||||
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: "org@acme.io",
|
||||
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 (UTC−6)",
|
||||
},
|
||||
{
|
||||
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 (UTC−8)",
|
||||
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: "dev@acme.io",
|
||||
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: "dev@acme.io",
|
||||
country: "Canada",
|
||||
flag: "ca",
|
||||
timezone: "EST (UTC−5)",
|
||||
},
|
||||
]
|
||||
@@ -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: "maya@reui.io",
|
||||
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: "jonas@reui.io",
|
||||
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: "priya@reui.io",
|
||||
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: "emil@reui.io",
|
||||
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: "nora@reui.io",
|
||||
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
|
||||
})
|
||||
+108
@@ -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>
|
||||
)
|
||||
}
|
||||
+63
@@ -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>
|
||||
)
|
||||
}
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
export function ProjectsEmptyIllustration({
|
||||
variant = "hero",
|
||||
}: {
|
||||
variant?: "hero" | "compact"
|
||||
}) {
|
||||
const isCompact = variant === "compact"
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"relative isolate",
|
||||
isCompact ? "h-11 w-20" : "h-32 w-[13.5rem]"
|
||||
)}
|
||||
>
|
||||
{isCompact ? <CompactView /> : <HeroView />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeroView() {
|
||||
return (
|
||||
<svg viewBox="0 0 220 140" fill="none" className="h-full w-full">
|
||||
<line
|
||||
x1="40"
|
||||
y1="0"
|
||||
x2="40"
|
||||
y2="140"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="110"
|
||||
y1="0"
|
||||
x2="110"
|
||||
y2="140"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="180"
|
||||
y1="0"
|
||||
x2="180"
|
||||
y2="140"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="25"
|
||||
x2="220"
|
||||
y2="25"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="70"
|
||||
x2="220"
|
||||
y2="70"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="115"
|
||||
x2="220"
|
||||
y2="115"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Cube A - bottom step */}
|
||||
<polygon
|
||||
points="89,58 110,70 110,94 89,106 68,94 68,70"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.28"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="89"
|
||||
y1="82"
|
||||
x2="68"
|
||||
y2="70"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="89"
|
||||
y1="82"
|
||||
x2="110"
|
||||
y2="70"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="89"
|
||||
y1="82"
|
||||
x2="89"
|
||||
y2="106"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Cube B - middle step */}
|
||||
<polygon
|
||||
points="110,46 131,58 131,82 110,94 89,82 89,58"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.38"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="110"
|
||||
y1="70"
|
||||
x2="89"
|
||||
y2="58"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="110"
|
||||
y1="70"
|
||||
x2="131"
|
||||
y2="58"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="110"
|
||||
y1="70"
|
||||
x2="110"
|
||||
y2="94"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Cube C - top step */}
|
||||
<polygon
|
||||
points="131,34 152,46 152,70 131,82 110,70 110,46"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.5"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="131"
|
||||
y1="58"
|
||||
x2="110"
|
||||
y2="46"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="131"
|
||||
y1="58"
|
||||
x2="152"
|
||||
y2="46"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="131"
|
||||
y1="58"
|
||||
x2="131"
|
||||
y2="82"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Floating diamond - ghost block suggesting growth */}
|
||||
<path
|
||||
d="M148 22 L155 26 L148 30 L141 26 Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
|
||||
{/* Accent dots */}
|
||||
<circle cx="40" cy="70" r="1.5" fill="currentColor" opacity="0.1" />
|
||||
<circle cx="180" cy="70" r="1.5" fill="currentColor" opacity="0.1" />
|
||||
<circle cx="110" cy="25" r="1.5" fill="currentColor" opacity="0.07" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactView() {
|
||||
return (
|
||||
<svg viewBox="0 0 80 44" fill="none" className="h-full w-full">
|
||||
{/* Cube A - bottom step */}
|
||||
<polygon
|
||||
points="33,18 40,22 40,30 33,34 26,30 26,22"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.28"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="33"
|
||||
y1="26"
|
||||
x2="26"
|
||||
y2="22"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="33"
|
||||
y1="26"
|
||||
x2="40"
|
||||
y2="22"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="33"
|
||||
y1="26"
|
||||
x2="33"
|
||||
y2="34"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Cube B - middle step */}
|
||||
<polygon
|
||||
points="40,14 47,18 47,26 40,30 33,26 33,18"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.38"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="40"
|
||||
y1="22"
|
||||
x2="33"
|
||||
y2="18"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="40"
|
||||
y1="22"
|
||||
x2="47"
|
||||
y2="18"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="40"
|
||||
y1="22"
|
||||
x2="40"
|
||||
y2="30"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Cube C - top step */}
|
||||
<polygon
|
||||
points="47,10 54,14 54,22 47,26 40,22 40,14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.5"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="47"
|
||||
y1="18"
|
||||
x2="40"
|
||||
y2="14"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="47"
|
||||
y1="18"
|
||||
x2="54"
|
||||
y2="14"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="47"
|
||||
y1="18"
|
||||
x2="47"
|
||||
y2="26"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@evobgp/ui/components/item"
|
||||
import { KanbanIcon, ChevronRightIcon, TimerIcon } from "lucide-react"
|
||||
|
||||
export function QuickStartTemplates() {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground text-sm">Quick starts</p>
|
||||
{/* Grid */}
|
||||
<div className="grid w-full gap-3 sm:grid-cols-2">
|
||||
<Item
|
||||
variant="outline"
|
||||
render={<a href="#" aria-label="Task board" />}
|
||||
className="hover:bg-muted/30 min-w-0 transition-colors"
|
||||
>
|
||||
<ItemMedia className="text-muted-foreground">
|
||||
<KanbanIcon className="size-3.5" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="truncate">Task board</ItemTitle>
|
||||
</ItemContent>
|
||||
|
||||
<ItemActions className="text-muted-foreground ml-auto">
|
||||
<ChevronRightIcon className="size-4" aria-hidden="true" />
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<Item
|
||||
variant="outline"
|
||||
render={<a href="#" aria-label="Sprint tracker" />}
|
||||
className="hover:bg-muted/30 min-w-0 transition-colors"
|
||||
>
|
||||
<ItemMedia className="text-muted-foreground">
|
||||
<TimerIcon className="size-3.5" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="truncate">Sprint tracker</ItemTitle>
|
||||
</ItemContent>
|
||||
|
||||
<ItemActions className="text-muted-foreground ml-auto">
|
||||
<ChevronRightIcon className="size-4" aria-hidden="true" />
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { EmptyState } from "./components/empty-state"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12">
|
||||
<EmptyState />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import type { BadgeProps } from "@/components/reui/badge"
|
||||
|
||||
export type EventStatus = "Upcoming" | "Cancelled" | "Pending" | "Completed"
|
||||
|
||||
export interface Attendee {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export interface MockEvent {
|
||||
id: string
|
||||
title: string
|
||||
status: EventStatus
|
||||
date: string
|
||||
time: string
|
||||
location: string
|
||||
attendees: Attendee[]
|
||||
}
|
||||
|
||||
export interface ScheduleCalendarProps {
|
||||
selected: Date | undefined
|
||||
onSelect: (date: Date | undefined) => void
|
||||
datesWithEvents?: Set<string>
|
||||
className?: string
|
||||
}
|
||||
|
||||
export type EventsFilter =
|
||||
| "all"
|
||||
| "upcoming"
|
||||
| "completed"
|
||||
| "cancelled"
|
||||
| "pending"
|
||||
|
||||
export interface EventsListProps {
|
||||
selectedDate?: Date
|
||||
filter?: EventsFilter
|
||||
onFilterChange?: (filter: EventsFilter) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface EventCardProps {
|
||||
event: MockEvent
|
||||
}
|
||||
|
||||
// ── Attendee pool ──
|
||||
|
||||
const POOL: Attendee[] = [
|
||||
{
|
||||
id: "u01",
|
||||
name: "Sarah Chen",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u02",
|
||||
name: "Michael Torres",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u03",
|
||||
name: "Emma Wilson",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u04",
|
||||
name: "James Park",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1463453091185-61582044d556?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u05",
|
||||
name: "Olivia Nguyen",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u06",
|
||||
name: "Alex Johnson",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u07",
|
||||
name: "Nina Patel",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1531746020798-e6953c6e8e04?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u08",
|
||||
name: "Ryan Murphy",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u09",
|
||||
name: "Zoe Martinez",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1580489944761-15a19d654956?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u10",
|
||||
name: "Tom Anderson",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u11",
|
||||
name: "Priya Sharma",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u12",
|
||||
name: "Lucas Ferreira",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1560250097-0b93528c311a?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
]
|
||||
|
||||
// ── Event template pool ──
|
||||
|
||||
const TEMPLATES: Array<{ title: string; location: string }> = [
|
||||
{ title: "Daily Standup", location: "Slack Huddle" },
|
||||
{ title: "Design System Review", location: "Figma / Remote" },
|
||||
{ title: "Sprint Planning", location: "Conference Room A" },
|
||||
{ title: "API Contract Review", location: "Remote" },
|
||||
{ title: "Investor Briefing", location: "22nd Floor Boardroom" },
|
||||
{ title: "Product Roadmap Review", location: "Conference Room B" },
|
||||
{ title: "UX Research Session", location: "Lab 2" },
|
||||
{ title: "Security Briefing", location: "Remote" },
|
||||
{ title: "Analytics Kick-off", location: "Google Meet" },
|
||||
{ title: "Backend Guild", location: "Meeting Room A" },
|
||||
{ title: "Frontend Perf Deep-dive", location: "Remote" },
|
||||
{ title: "Mobile QA Handoff", location: "Remote" },
|
||||
{ title: "Hiring Interview · Senior FE", location: "Zoom" },
|
||||
{ title: "Q2 OKR Planning", location: "Conference Room A" },
|
||||
{ title: "Tech Talk: Next.js 15", location: "Main Hall" },
|
||||
{ title: "Release Planning v3.0", location: "Zoom" },
|
||||
{ title: "Accessibility Workshop", location: "Lab 1" },
|
||||
{ title: "Partner API Workshop", location: "HQ · Floor 3" },
|
||||
{ title: "Quarterly All-Hands", location: "Main Hall" },
|
||||
{ title: "Customer Feedback Review", location: "Google Meet" },
|
||||
{ title: "Board Meeting", location: "HQ · Floor 3" },
|
||||
{ title: "Brand Identity Refresh", location: "Figma / Remote" },
|
||||
{ title: "Infrastructure Cost Review", location: "Remote" },
|
||||
{ title: "Database Migration Planning", location: "Remote" },
|
||||
{ title: "Figma Handoff · Billing", location: "Figma / Remote" },
|
||||
{ title: "Marketing Campaign Review", location: "Google Meet" },
|
||||
{ title: "Platform Architecture Review", location: "HQ · Floor 3" },
|
||||
{ title: "Payment Gateway Review", location: "Zoom" },
|
||||
{ title: "Email A/B Results 2", location: "Google Meet" },
|
||||
{ title: "Weekly Retro", location: "Slack Huddle" },
|
||||
{ title: "Security Audit Debrief", location: "Remote" },
|
||||
{ title: "Contractor Onboarding", location: "Zoom" },
|
||||
{ title: "Hiring Panel · Design", location: "Zoom" },
|
||||
{ title: "Growth Strategy Workshop", location: "HQ · Floor 2" },
|
||||
{ title: "i18n Sprint Kick-off", location: "Remote" },
|
||||
{ title: "Product Launch Q1", location: "1200 Innovation Way" },
|
||||
{ title: "Partnership Summit", location: "Grand Plaza Hotel" },
|
||||
{ title: "Tech Debt Triage", location: "Remote" },
|
||||
{ title: "1:1 with Engineering Lead", location: "Zoom" },
|
||||
{ title: "Release Sign-off", location: "Zoom" },
|
||||
]
|
||||
|
||||
const TIMES = [
|
||||
"8:00 am",
|
||||
"8:30 am",
|
||||
"9:00 am",
|
||||
"9:30 am",
|
||||
"10:00 am",
|
||||
"10:30 am",
|
||||
"11:00 am",
|
||||
"11:30 am",
|
||||
"12:00 pm",
|
||||
"1:00 pm",
|
||||
"1:30 pm",
|
||||
"2:00 pm",
|
||||
"2:30 pm",
|
||||
"3:00 pm",
|
||||
"3:30 pm",
|
||||
"4:00 pm",
|
||||
"4:30 pm",
|
||||
"5:00 pm",
|
||||
"5:30 pm",
|
||||
]
|
||||
|
||||
// ── Calendar data ──
|
||||
|
||||
export const MONTHS = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
]
|
||||
|
||||
// Fixed demo clock: every "today" bucket derives from this reference date.
|
||||
const DEMO_REFERENCE_DATE_ISO = "2026-06-10"
|
||||
// Noon keeps UTC-based date keys (toISOString) on the same calendar day across timezones.
|
||||
export const DEMO_REFERENCE_DATE = new Date(
|
||||
`${DEMO_REFERENCE_DATE_ISO}T12:00:00`
|
||||
)
|
||||
|
||||
export const CURRENT_YEAR = DEMO_REFERENCE_DATE.getFullYear()
|
||||
export const YEARS = Array.from({ length: 21 }, (_, i) => CURRENT_YEAR - 10 + i)
|
||||
|
||||
// ── Event card data ──
|
||||
|
||||
export const STATUS_VARIANT: Record<EventStatus, BadgeProps["variant"]> = {
|
||||
Upcoming: "info-light",
|
||||
Cancelled: "destructive-light",
|
||||
Pending: "warning-light",
|
||||
Completed: "success-light",
|
||||
}
|
||||
|
||||
export const MAX_VISIBLE = 3
|
||||
|
||||
export function parseDateParts(dateStr: string) {
|
||||
const d = new Date(dateStr + "T12:00:00")
|
||||
return {
|
||||
day: d.getDate().toString().padStart(2, "0"),
|
||||
monthShort: d.toLocaleString("en-US", { month: "short" }),
|
||||
}
|
||||
}
|
||||
|
||||
export function initials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
// ── Events list data ──
|
||||
|
||||
export const FILTER_ITEMS = [
|
||||
{ label: "All events", value: "all" },
|
||||
{ label: "Upcoming", value: "upcoming" },
|
||||
{ label: "Completed", value: "completed" },
|
||||
{ label: "Cancelled", value: "cancelled" },
|
||||
{ label: "Pending", value: "pending" },
|
||||
]
|
||||
|
||||
export function toDateKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function matchesFilter(event: MockEvent, filter: EventsFilter): boolean {
|
||||
if (filter === "all") return true
|
||||
return event.status.toLowerCase() === filter
|
||||
}
|
||||
|
||||
// ── Mock event generation ──
|
||||
|
||||
function hash(n: number): number {
|
||||
let x = n
|
||||
x = ((x >> 16) ^ x) * 0x45d9f3b
|
||||
x = ((x >> 16) ^ x) * 0x45d9f3b
|
||||
x = (x >> 16) ^ x
|
||||
return Math.abs(x)
|
||||
}
|
||||
|
||||
function toDateStr(d: Date): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function buildMockEvents(ref: Date = DEMO_REFERENCE_DATE): MockEvent[] {
|
||||
const Y = ref.getFullYear()
|
||||
const M = ref.getMonth()
|
||||
const todayNum = ref.getDate()
|
||||
const daysInMonth = new Date(Y, M + 1, 0).getDate()
|
||||
|
||||
// Current week's Monday day-of-month (may be <= 0 → last month, skip)
|
||||
const dow = ref.getDay() // 0=Sun … 6=Sat
|
||||
const mondayNum = todayNum - (dow === 0 ? 6 : dow - 1)
|
||||
|
||||
const seed = Y * 100 + M // stable per month
|
||||
|
||||
const events: MockEvent[] = []
|
||||
|
||||
for (let dayNum = 1; dayNum <= daysInMonth; dayNum++) {
|
||||
const isToday = dayNum === todayNum
|
||||
const isMonday = dayNum === mondayNum
|
||||
const dayHash = hash(seed + dayNum)
|
||||
|
||||
// Decide whether this day gets events
|
||||
const guaranteed = isToday || isMonday
|
||||
const randomlyChosen = dayHash % 10 < 4 // ~40 %
|
||||
if (!guaranteed && !randomlyChosen) continue
|
||||
|
||||
// How many events (4-9)
|
||||
const baseCount = isToday ? 5 : 4
|
||||
const count = baseCount + (dayHash % (isToday ? 3 : 6))
|
||||
|
||||
const date = toDateStr(new Date(Y, M, dayNum, 12, 0, 0, 0))
|
||||
|
||||
// Status for each slot
|
||||
const defaultStatus: EventStatus =
|
||||
dayNum < todayNum ? "Completed" : "Upcoming"
|
||||
|
||||
// Spread events through the day using distinct templates and times
|
||||
for (let i = 0; i < count; i++) {
|
||||
const slot = hash(seed + dayNum * 37 + i * 13)
|
||||
const template = TEMPLATES[(dayHash + i * 7) % TEMPLATES.length]
|
||||
const time = TIMES[(slot + i * 3) % TIMES.length]
|
||||
|
||||
// Occasionally vary status for non-today days
|
||||
let status: EventStatus = defaultStatus
|
||||
if (!isToday) {
|
||||
const r = (slot * 31 + i * 17) % 10
|
||||
if (dayNum < todayNum) {
|
||||
status = r < 2 ? "Cancelled" : "Completed"
|
||||
} else {
|
||||
status = r < 3 ? "Pending" : "Upcoming"
|
||||
}
|
||||
}
|
||||
|
||||
// Pick 2-6 attendees deterministically
|
||||
const attendeeCount = 2 + (slot % 5)
|
||||
const attendees = Array.from(
|
||||
{ length: attendeeCount },
|
||||
(_, k) => POOL[(slot + k * 3) % POOL.length]
|
||||
)
|
||||
// Deduplicate
|
||||
const seen = new Set<string>()
|
||||
const uniqueAttendees = attendees.filter((a) => {
|
||||
if (seen.has(a.id)) return false
|
||||
seen.add(a.id)
|
||||
return true
|
||||
})
|
||||
|
||||
events.push({
|
||||
id: `${Y}-${M}-${dayNum}-${i}`,
|
||||
title: template.title,
|
||||
status,
|
||||
date,
|
||||
time,
|
||||
location: template.location,
|
||||
attendees: uniqueAttendees,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
export const MOCK_EVENTS: MockEvent[] = buildMockEvents()
|
||||
|
||||
export function getDatesWithEvents(events: MockEvent[]): Set<string> {
|
||||
return new Set(events.map((e) => e.date))
|
||||
}
|
||||
|
||||
export function getEventsForDate(
|
||||
events: MockEvent[],
|
||||
date: Date | undefined,
|
||||
filter: "all" | "upcoming" | "completed" | "cancelled" | "pending"
|
||||
): MockEvent[] {
|
||||
if (!date) return events
|
||||
const dateStr = toDateStr(date)
|
||||
let list = events.filter((e) => e.date === dateStr)
|
||||
if (filter === "upcoming") list = list.filter((e) => e.status === "Upcoming")
|
||||
else if (filter === "completed")
|
||||
list = list.filter((e) => e.status === "Completed")
|
||||
else if (filter === "cancelled")
|
||||
list = list.filter((e) => e.status === "Cancelled")
|
||||
else if (filter === "pending")
|
||||
list = list.filter((e) => e.status === "Pending")
|
||||
return list
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Item } from "@evobgp/ui/components/item"
|
||||
import {
|
||||
initials,
|
||||
MAX_VISIBLE,
|
||||
parseDateParts,
|
||||
STATUS_VARIANT,
|
||||
type Attendee,
|
||||
type EventCardProps,
|
||||
} from "./data"
|
||||
import { ClockIcon, MapPinIcon } from "lucide-react"
|
||||
|
||||
function AttendeeGroup({ attendees }: { attendees: Attendee[] }) {
|
||||
if (attendees.length === 0) return null
|
||||
const visible = attendees.slice(0, MAX_VISIBLE)
|
||||
const overflow = attendees.length - MAX_VISIBLE
|
||||
|
||||
return (
|
||||
<AvatarGroup className="-space-x-1.5">
|
||||
{visible.map((a) => (
|
||||
<Avatar key={a.id} className="size-4!" title={a.name}>
|
||||
<AvatarImage src={a.avatar} alt={a.name} />
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{initials(a.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<AvatarGroupCount className="size-4! text-[10px]">
|
||||
+{overflow}
|
||||
</AvatarGroupCount>
|
||||
)}
|
||||
</AvatarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export function EventCard({ event }: EventCardProps) {
|
||||
const { day, monthShort } = parseDateParts(event.date)
|
||||
const variant = STATUS_VARIANT[event.status]
|
||||
|
||||
return (
|
||||
<Item variant="outline" size="xs" className="flex items-start gap-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<a
|
||||
href="#"
|
||||
className="text-foreground hover:text-primary text-sm leading-tight font-medium"
|
||||
>
|
||||
{event.title}
|
||||
</a>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<Badge variant={variant} size="sm">
|
||||
{event.status}
|
||||
</Badge>
|
||||
|
||||
<AttendeeGroup attendees={event.attendees} />
|
||||
|
||||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<ClockIcon className="size-3 shrink-0" aria-hidden="true" />
|
||||
{monthShort} {day} · {event.time}
|
||||
</span>
|
||||
|
||||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<MapPinIcon className="size-3 shrink-0" aria-hidden="true" />
|
||||
{event.location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import {
|
||||
FILTER_ITEMS,
|
||||
matchesFilter,
|
||||
MOCK_EVENTS,
|
||||
toDateKey,
|
||||
type EventsFilter,
|
||||
type EventsListProps,
|
||||
type MockEvent,
|
||||
} from "./data"
|
||||
import { EventCard } from "./event-card"
|
||||
import { PlusIcon, CalendarIcon } from "lucide-react"
|
||||
|
||||
export function EventsList({
|
||||
selectedDate,
|
||||
filter = "all",
|
||||
onFilterChange,
|
||||
}: EventsListProps) {
|
||||
const dateKey = selectedDate ? toDateKey(selectedDate) : null
|
||||
|
||||
const events = MOCK_EVENTS.filter((event) => {
|
||||
if (dateKey && event.date !== dateKey) return false
|
||||
return matchesFilter(event, filter)
|
||||
})
|
||||
|
||||
const headingLabel = selectedDate
|
||||
? selectedDate.toLocaleString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
: "All Events"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
{/* Heading */}
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-foreground text-sm font-semibold">
|
||||
{headingLabel}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{events.length > 0
|
||||
? `${events.length} event${events.length !== 1 ? "s" : ""}`
|
||||
: "No events found"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex shrink-0 items-center gap-2 sm:w-auto">
|
||||
<Select
|
||||
value={filter}
|
||||
onValueChange={(v) => onFilterChange?.(v as EventsFilter)}
|
||||
items={FILTER_ITEMS}
|
||||
>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue placeholder="All events" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button size="sm">
|
||||
<PlusIcon className="size-4" aria-hidden="true" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
<div className="min-h-[320px]">
|
||||
<div className="relative flex max-h-full">
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"-mr-3.5 max-h-[320px] grow pr-3.5",
|
||||
"**:data-[slot=scroll-area-thumb]:bg-foreground/15 **:data-[slot=scroll-area-thumb]:rounded-full",
|
||||
"**:data-[slot=scroll-area-viewport]:mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))]",
|
||||
"**:data-[slot=scroll-area-viewport]:mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))]",
|
||||
"**:data-[slot=scroll-area-viewport]:mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))]",
|
||||
"**:data-[slot=scroll-area-viewport]:mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))]",
|
||||
"**:data-[slot=scroll-area-viewport]:[--fade-size:1.5rem]"
|
||||
)}
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<Item
|
||||
render={<span />}
|
||||
className="bg-muted flex size-10 items-center justify-center rounded-full p-0"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<CalendarIcon className="text-muted-foreground size-5" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="space-y-1">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
No events found
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{dateKey
|
||||
? "Nothing scheduled for this day."
|
||||
: "Try changing the filter."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{events.map((event: MockEvent) => (
|
||||
<li key={event.id}>
|
||||
<EventCard event={event} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState } from "react"
|
||||
import { DayButton } from "react-day-picker"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Calendar, CalendarDayButton } from "@evobgp/ui/components/calendar"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import {
|
||||
DEMO_REFERENCE_DATE,
|
||||
MONTHS,
|
||||
YEARS,
|
||||
type ScheduleCalendarProps,
|
||||
} from "./data"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
// Weekday header highlight tracks the fixed demo clock, not the live date.
|
||||
const TODAY_WEEKDAY_NAME = DEMO_REFERENCE_DATE.toLocaleString("en-US", {
|
||||
weekday: "short",
|
||||
}).toUpperCase()
|
||||
|
||||
export function ScheduleCalendar({
|
||||
selected,
|
||||
onSelect,
|
||||
datesWithEvents = new Set(),
|
||||
}: ScheduleCalendarProps) {
|
||||
const [month, setMonth] = useState<Date>(selected ?? DEMO_REFERENCE_DATE)
|
||||
|
||||
const stepMonth = (delta: number) =>
|
||||
setMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + delta, 1))
|
||||
|
||||
const handleMonthSelect = (value: string) => {
|
||||
const i = MONTHS.indexOf(value)
|
||||
if (i >= 0) setMonth(new Date(month.getFullYear(), i, 1))
|
||||
}
|
||||
|
||||
const handleYearSelect = (value: string) => {
|
||||
const y = parseInt(value, 10)
|
||||
if (!isNaN(y)) setMonth(new Date(y, month.getMonth(), 1))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 select-none">
|
||||
{/* ── Custom header ── */}
|
||||
<div className="flex w-full grow items-center justify-between gap-1">
|
||||
{/* Prev month */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 shrink-0 p-0"
|
||||
onClick={() => stepMonth(-1)}
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<ChevronLeftIcon className="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
{/* Month select */}
|
||||
<Select
|
||||
value={MONTHS[month.getMonth()]}
|
||||
onValueChange={(value) => handleMonthSelect(value ?? "")}
|
||||
>
|
||||
<SelectTrigger size="sm" className="min-w-0 flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Year select */}
|
||||
<Select
|
||||
value={String(month.getFullYear())}
|
||||
onValueChange={(value) => handleYearSelect(value ?? "")}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-22 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{YEARS.map((y) => (
|
||||
<SelectItem key={y} value={String(y)}>
|
||||
{y}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Next month */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 shrink-0 p-0"
|
||||
onClick={() => stepMonth(1)}
|
||||
aria-label="Next month"
|
||||
>
|
||||
<ChevronRightIcon className="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Calendar ── */}
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
showOutsideDays
|
||||
hideNavigation
|
||||
className="w-full bg-transparent p-0 md:[--cell-size:--spacing(11)]"
|
||||
formatters={{
|
||||
formatWeekdayName: (date) =>
|
||||
date.toLocaleString("en-US", { weekday: "short" }).toUpperCase(),
|
||||
}}
|
||||
classNames={{
|
||||
month_caption: "hidden",
|
||||
nav: "hidden",
|
||||
weekdays: "flex gap-1",
|
||||
weekday:
|
||||
"flex-1 flex items-center justify-center h-14 text-[0.65rem] font-medium text-muted-foreground",
|
||||
week: "flex gap-1 mt-1",
|
||||
day: "flex-1 aspect-square p-0",
|
||||
day_button: cn(
|
||||
"bg-muted/50 hover:bg-muted",
|
||||
" rounded-md ",
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[selected-single=true]:hover:bg-primary data-[selected-single=true]:hover:text-primary-foreground!"
|
||||
),
|
||||
outside: "opacity-60",
|
||||
disabled: "opacity-60",
|
||||
today: cn("bg-accent text-foreground", " rounded-md "),
|
||||
}}
|
||||
components={{
|
||||
Weekday: ({
|
||||
children,
|
||||
className: cls,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"th">) => {
|
||||
const isToday = children === TODAY_WEEKDAY_NAME
|
||||
return (
|
||||
<th
|
||||
scope="col"
|
||||
className={cn(
|
||||
"flex h-6! flex-1 items-center justify-center rounded-md text-xs font-medium",
|
||||
isToday
|
||||
? "bg-accent text-foreground!"
|
||||
: "text-muted-foreground",
|
||||
cls
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
},
|
||||
|
||||
DayButton: ({
|
||||
children,
|
||||
modifiers,
|
||||
day,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) => {
|
||||
const dateKey = day.date.toISOString().slice(0, 10)
|
||||
const hasEvents = !modifiers.outside && datesWithEvents.has(dateKey)
|
||||
|
||||
return (
|
||||
<CalendarDayButton day={day} modifiers={modifiers} {...props}>
|
||||
{hasEvents ? (
|
||||
<span
|
||||
className="bg-primary text-primary-foreground in-data-[selected-single=true]:bg-primary-foreground! size-1 rounded-full"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span className="size-1" aria-hidden />
|
||||
)}
|
||||
{children}
|
||||
</CalendarDayButton>
|
||||
)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import {
|
||||
DEMO_REFERENCE_DATE,
|
||||
EventsFilter,
|
||||
getDatesWithEvents,
|
||||
MOCK_EVENTS,
|
||||
} from "./data"
|
||||
import { EventsList } from "./events-list"
|
||||
import { ScheduleCalendar } from "./schedule-calendar"
|
||||
|
||||
export function Schedule() {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
|
||||
DEMO_REFERENCE_DATE
|
||||
)
|
||||
const [filter, setFilter] = useState<EventsFilter>("all")
|
||||
const datesWithEvents = getDatesWithEvents(MOCK_EVENTS)
|
||||
|
||||
return (
|
||||
<Frame className="w-full max-w-4xl">
|
||||
<FramePanel className="flex flex-col p-0! lg:flex-row">
|
||||
{/* Left - calendar */}
|
||||
<div className="lg:border-border shrink-0 p-5 pt-5.5 lg:w-[370px] lg:border-r">
|
||||
<ScheduleCalendar
|
||||
selected={selectedDate}
|
||||
onSelect={setSelectedDate}
|
||||
datesWithEvents={datesWithEvents}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right - events */}
|
||||
<div className="flex-1 p-5">
|
||||
<EventsList
|
||||
selectedDate={selectedDate}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Schedule } from "./components/schedule"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-10 md:p-20">
|
||||
<Schedule />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
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 {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
import { TooltipProvider } from "@evobgp/ui/components/tooltip"
|
||||
import { createColumns } from "./columns"
|
||||
import { API_INTEGRATIONS } from "./data"
|
||||
import { BriefcaseBusinessIcon } from "lucide-react"
|
||||
|
||||
// ── Main component ──
|
||||
|
||||
export function ApiIntegrationsGrid() {
|
||||
const [rows, setRows] = useState(API_INTEGRATIONS)
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
|
||||
const pauseAll = rows.every((row) => !row.enabled)
|
||||
|
||||
const handleToggle = (id: string, enabled: boolean) => {
|
||||
setRows((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, enabled } : row))
|
||||
)
|
||||
}
|
||||
|
||||
const handlePauseAllChange = (checked: boolean) => {
|
||||
const nextEnabled = !checked
|
||||
setRows((current) =>
|
||||
current.map((row) => ({ ...row, enabled: nextEnabled }))
|
||||
)
|
||||
toast.message(
|
||||
checked ? "All integrations paused" : "Integrations resumed",
|
||||
{
|
||||
description: checked
|
||||
? "API traffic is temporarily disabled for every listed integration."
|
||||
: "The listed integrations can receive traffic again.",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const columns = useMemo(() => createColumns({ onToggle: handleToggle }), [])
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={200}>
|
||||
{/* Table */}
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
tableLayout={{
|
||||
columnsResizable: true,
|
||||
columnsVisibility: true,
|
||||
headerSticky: true,
|
||||
dense: true,
|
||||
}}
|
||||
>
|
||||
<Frame className="w-full max-w-6xl">
|
||||
<FrameHeader className="flex-row items-center justify-between px-2! py-2.5!">
|
||||
<div className="space-y-px">
|
||||
<FrameTitle>API Integrations</FrameTitle>
|
||||
<FrameDescription>
|
||||
Oversee endpoints, key rotation, and traffic availability.
|
||||
</FrameDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground hidden text-sm md:inline">
|
||||
Pause all
|
||||
</span>
|
||||
<Switch
|
||||
checked={pauseAll}
|
||||
onCheckedChange={handlePauseAllChange}
|
||||
aria-label="Pause all integrations"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
toast.info("Positions", {
|
||||
description:
|
||||
"Review which integration seats are currently assigned across the workspace.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<BriefcaseBusinessIcon aria-hidden="true" />
|
||||
<span className="hidden md:inline">Positions</span>
|
||||
</Button>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0!">
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="px-2! py-2.5!">
|
||||
<DataGridPagination />
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { memo } 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 type { ColumnDef, Row } from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
import type { ApiIntegration } from "./data"
|
||||
import { CheckIcon, CopyIcon, SquarePenIcon } from "lucide-react"
|
||||
|
||||
// ── Formatters ──
|
||||
|
||||
function formatCalls(value: number) {
|
||||
return new Intl.NumberFormat("en-US").format(value)
|
||||
}
|
||||
|
||||
// ── Cell components ──
|
||||
|
||||
const StatusSwitch = memo(function StatusSwitch({
|
||||
row,
|
||||
onToggle,
|
||||
}: {
|
||||
row: Row<ApiIntegration>
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Switch
|
||||
checked={row.original.enabled}
|
||||
onCheckedChange={(checked) => onToggle(row.original.id, checked)}
|
||||
aria-label={`Toggle ${row.original.name}`}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const ApiKeyCell = memo(function ApiKeyCell({ value }: { value: string }) {
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard({
|
||||
onCopy: () => {
|
||||
toast.success("API key copied", { description: value })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={isCopied ? "API key copied" : "Copy API key"}
|
||||
onClick={() => copyToClipboard(value)}
|
||||
title={value}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="max-w-full min-w-0 gap-1.5"
|
||||
>
|
||||
<span className="min-w-0 truncate font-mono tabular-nums">{value}</span>
|
||||
{isCopied ? (
|
||||
<CheckIcon className="text-green-500" aria-hidden="true" />
|
||||
) : (
|
||||
<CopyIcon aria-hidden="true" />
|
||||
)}
|
||||
</Badge>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
|
||||
const ActionsCell = memo(function ActionsCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<ApiIntegration>
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${row.original.name}`}
|
||||
onClick={() =>
|
||||
toast.message("Edit integration", {
|
||||
description: `Open ${row.original.name} settings in your configuration flow.`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SquarePenIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
|
||||
// ── Column definitions ──
|
||||
|
||||
export function createColumns({
|
||||
onToggle,
|
||||
}: {
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
}): ColumnDef<ApiIntegration>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "select",
|
||||
id: "select",
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
size: 38,
|
||||
meta: {
|
||||
headerClassName: "ps-4!",
|
||||
cellClassName: "ps-4!",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Integration" column={column} visibility />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Item className="flex size-9 shrink-0 items-center justify-center border-0 p-0 [&_svg]:size-5">
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{row.original.logo}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate text-sm font-medium">
|
||||
{row.original.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{row.original.provider} · {row.original.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size: 450,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
headerTitle: "Integration",
|
||||
skeleton: (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Skeleton className="size-9 shrink-0 rounded" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<Skeleton className="h-3.5 w-28" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "apiKey",
|
||||
id: "apiKey",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="API Key" column={column} visibility />
|
||||
),
|
||||
cell: ({ row }) => <ApiKeyCell value={row.original.apiKey} />,
|
||||
size: 300,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
headerTitle: "API Key",
|
||||
skeleton: <Skeleton className="h-5 w-44 rounded-md" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "dailyCalls",
|
||||
id: "dailyCalls",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Daily Calls" column={column} visibility />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm tabular-nums">
|
||||
{formatCalls(row.original.dailyCalls)}
|
||||
</span>
|
||||
),
|
||||
size: 140,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
headerTitle: "Daily Calls",
|
||||
skeleton: <Skeleton className="h-4 w-16" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
id: "enabled",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Status" column={column} visibility />
|
||||
),
|
||||
cell: ({ row }) => <StatusSwitch row={row} onToggle={onToggle} />,
|
||||
size: 110,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
headerTitle: "Status",
|
||||
skeleton: <Skeleton className="h-5 w-9 rounded-full" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => <ActionsCell row={row} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
size: 60,
|
||||
meta: {
|
||||
skeleton: <Skeleton className="size-7 rounded" />,
|
||||
headerClassName: "",
|
||||
cellClassName: "",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { Convex } from "@evobgp/ui/components/svgs/convex"
|
||||
import { Discord } from "@evobgp/ui/components/svgs/discord"
|
||||
import { Gemini } from "@evobgp/ui/components/svgs/gemini"
|
||||
import { Loom } from "@evobgp/ui/components/svgs/loom"
|
||||
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 { 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"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface ApiIntegration {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
description: string
|
||||
logo: ReactNode
|
||||
apiKey: string
|
||||
dailyCalls: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
const logoClassName = "size-5"
|
||||
|
||||
export const API_INTEGRATIONS: ApiIntegration[] = [
|
||||
{
|
||||
id: "auth-relay",
|
||||
name: "User Auth System",
|
||||
provider: "Supabase",
|
||||
description: "Session webhooks and identity sync",
|
||||
logo: <Supabase className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "f6g7Z8h9R0TfUaSdTf",
|
||||
dailyCalls: 15000,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "social-mgr",
|
||||
name: "Social Media Manager",
|
||||
provider: "Slack",
|
||||
description: "Content scheduling and post distribution",
|
||||
logo: <Slack className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "s1t2u3v4w5x6y7z8a9",
|
||||
dailyCalls: 13000,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "sms-notify",
|
||||
name: "SMS Notification Service",
|
||||
provider: "n8n",
|
||||
description: "Transactional message delivery pipeline",
|
||||
logo: <N8n className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "t2u3v4w5x6y7z8a9b1",
|
||||
dailyCalls: 19000,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "ship-coord",
|
||||
name: "Shipping Coordinator",
|
||||
provider: "Convex",
|
||||
description: "Carrier rate lookups and label generation",
|
||||
logo: <Convex className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "t6u7v8w9x0CvBnNlSc",
|
||||
dailyCalls: 14000,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "seo-scan",
|
||||
name: "SEO Analyzer",
|
||||
provider: "Mintlify",
|
||||
description: "Site audit scores and keyword tracking",
|
||||
logo: <Mintlify className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "b1c2d3e4f5g6h7i8j9",
|
||||
dailyCalls: 6000,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "sales-fc",
|
||||
name: "Sales Forecasting",
|
||||
provider: "Stripe",
|
||||
description: "Revenue trend projections and pipeline modeling",
|
||||
logo: <Stripe className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "z8a9b1c2d3e4f5g6h7",
|
||||
dailyCalls: 11500,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "quick-pay",
|
||||
name: "Quick Pay Service",
|
||||
provider: "Stripe",
|
||||
description: "One-tap checkout and express payment links",
|
||||
logo: <Stripe className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "a1b2Xc3dY4ZxQvPlQp",
|
||||
dailyCalls: 10000,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "proj-mgmt",
|
||||
name: "Project Management",
|
||||
provider: "Loom",
|
||||
description: "Sprint planning and milestone handoff recordings",
|
||||
logo: <Loom className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "v4w5x6y7z8a9b1c2d3",
|
||||
dailyCalls: 14500,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "pay-gate",
|
||||
name: "Payment Gateway",
|
||||
provider: "Stripe",
|
||||
description: "Card processing and dispute lifecycle events",
|
||||
logo: <Stripe className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "1p2q3r4s5DfGhPgPy",
|
||||
dailyCalls: 25000,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "order-track",
|
||||
name: "Order Tracking Sys",
|
||||
provider: "Neon",
|
||||
description: "Shipment status queries and delivery confirmations",
|
||||
logo: <Neon className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "e1E2gH3hB4iYtUvOtS",
|
||||
dailyCalls: 9500,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "ops-notifier",
|
||||
name: "Ops Notifier",
|
||||
provider: "Slack",
|
||||
description: "High-priority workspace alerts",
|
||||
logo: <Slack className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "slk_live_p2d7n4w8v5q1m6r3",
|
||||
dailyCalls: 9600,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "workflow-bridge",
|
||||
name: "Workflow Bridge",
|
||||
provider: "n8n",
|
||||
description: "Automation run intake and callbacks",
|
||||
logo: <N8n className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "n8n_live_v7x2m5q9a4c1p8d6",
|
||||
dailyCalls: 13750,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "community-sync",
|
||||
name: "Community Sync",
|
||||
provider: "Discord",
|
||||
description: "Community reports and moderation escalations",
|
||||
logo: <Discord className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "dsc_live_m3q9v2k7r5n1x8c4",
|
||||
dailyCalls: 6400,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "review-clips",
|
||||
name: "Review Clips",
|
||||
provider: "Loom",
|
||||
description: "Async review uploads and callback events",
|
||||
logo: <Loom className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "lom_live_r8t4m1c6p9v2x5q7",
|
||||
dailyCalls: 7100,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "meeting-webhooks",
|
||||
name: "Meeting Webhooks",
|
||||
provider: "Zoom",
|
||||
description: "Recording ready and host events",
|
||||
logo: <Zoom className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "zom_live_q5n8r2m4v7c1p9d3",
|
||||
dailyCalls: 11300,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "edge-cache-sync",
|
||||
name: "Edge Cache Sync",
|
||||
provider: "Convex",
|
||||
description: "State snapshots for frontend refreshes",
|
||||
logo: <Convex className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "cvx_live_t1p6m9q3r7v2x4c8",
|
||||
dailyCalls: 15400,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "support-routing",
|
||||
name: "Support Routing",
|
||||
provider: "Slack",
|
||||
description: "Escalation queue dispatch automation",
|
||||
logo: <Slack className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "slk_live_x9c4m2p7v1q8r5n6",
|
||||
dailyCalls: 8900,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "revenue-monitor",
|
||||
name: "Revenue Monitor",
|
||||
provider: "Stripe",
|
||||
description: "Charge anomalies and recovery alerts",
|
||||
logo: <Stripe className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "stp_live_c6v2m8q1r4n7p5x9",
|
||||
dailyCalls: 12850,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "member-audit",
|
||||
name: "Member Audit",
|
||||
provider: "Supabase",
|
||||
description: "Role changes and access review logs",
|
||||
logo: <Supabase className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "sbp_live_n4r7m1q8x5c2v9p6",
|
||||
dailyCalls: 5800,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "retention-flows",
|
||||
name: "Retention Flows",
|
||||
provider: "n8n",
|
||||
description: "Churn prevention automations and retries",
|
||||
logo: <N8n className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "n8n_live_m2v5q8p1c7r4x9d6",
|
||||
dailyCalls: 14600,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "content-gen",
|
||||
name: "Content Generator",
|
||||
provider: "Gemini",
|
||||
description: "Draft generation and content summarization",
|
||||
logo: <Gemini className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "gem_live_k4r9m2q7v1p8x3n5",
|
||||
dailyCalls: 8200,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "db-replication",
|
||||
name: "DB Replication",
|
||||
provider: "Neon",
|
||||
description: "Cross-region read replica synchronization",
|
||||
logo: <Neon className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "neo_live_p7x3m1q9v4r2n8c6",
|
||||
dailyCalls: 22100,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "docs-webhook",
|
||||
name: "Docs Webhook",
|
||||
provider: "Mintlify",
|
||||
description: "Documentation deploy and page-view events",
|
||||
logo: <Mintlify className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "mnt_live_v2q8m5r1p9x4n7c3",
|
||||
dailyCalls: 4300,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "incident-bot",
|
||||
name: "Incident Bot",
|
||||
provider: "Discord",
|
||||
description: "Automated incident threads and status page sync",
|
||||
logo: <Discord className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "dsc_live_r6n1m4q9v7p2x8c5",
|
||||
dailyCalls: 3200,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "video-analytics",
|
||||
name: "Video Analytics",
|
||||
provider: "Zoom",
|
||||
description: "Participation heatmaps and engagement scoring",
|
||||
logo: <Zoom className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "zom_live_m8v3q1r5p9x2n4c7",
|
||||
dailyCalls: 5600,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "signup-funnel",
|
||||
name: "Signup Funnel",
|
||||
provider: "Supabase",
|
||||
description: "Registration step tracking and drop-off alerts",
|
||||
logo: <Supabase className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "sbp_live_q3r7m9v2p1x5n8c4",
|
||||
dailyCalls: 17800,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "async-reviews",
|
||||
name: "Async Reviews",
|
||||
provider: "Loom",
|
||||
description: "Code review recordings and feedback collection",
|
||||
logo: <Loom className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "lom_live_x5n2m8q4v1r9p7c3",
|
||||
dailyCalls: 2900,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "pipeline-monitor",
|
||||
name: "Pipeline Monitor",
|
||||
provider: "n8n",
|
||||
description: "CI/CD build status and deployment tracking",
|
||||
logo: <N8n className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "n8n_live_r4v7m2q9p1x8n5c3",
|
||||
dailyCalls: 16200,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "real-time-sync",
|
||||
name: "Real-time Sync",
|
||||
provider: "Convex",
|
||||
description: "Live data propagation for collaborative editors",
|
||||
logo: <Convex className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "cvx_live_m9q2r5v1p4x7n3c8",
|
||||
dailyCalls: 19400,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "invoice-relay",
|
||||
name: "Invoice Relay",
|
||||
provider: "Stripe",
|
||||
description: "Automated invoice generation and delivery tracking",
|
||||
logo: <Stripe className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "stp_live_v3m8q1r6p9x2n4c7",
|
||||
dailyCalls: 7800,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "ai-summarizer",
|
||||
name: "AI Summarizer",
|
||||
provider: "Gemini",
|
||||
description: "Meeting transcript distillation and action extraction",
|
||||
logo: <Gemini className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "gem_live_r1m5q8v3p9x7n2c4",
|
||||
dailyCalls: 4100,
|
||||
enabled: false,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiIntegrationsGrid } from "./components/api-integrations-grid"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12">
|
||||
<ApiIntegrationsGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { FieldLegend, FieldSet } from "@evobgp/ui/components/field"
|
||||
import { ACCENT_COLORS } from "./data"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
// ── Color Picker ──
|
||||
|
||||
export function ColorPicker() {
|
||||
const [selected, setSelected] = useState(ACCENT_COLORS[0].value)
|
||||
|
||||
return (
|
||||
<FieldSet className="gap-0">
|
||||
<FieldLegend className="sr-only">Accent color</FieldLegend>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{ACCENT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
aria-label={color.name}
|
||||
onClick={() => setSelected(color.value)}
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full transition-shadow",
|
||||
selected === color.value &&
|
||||
"ring-ring ring-offset-background ring-2 ring-offset-2"
|
||||
)}
|
||||
style={{ backgroundColor: color.value }}
|
||||
>
|
||||
{selected === color.value ? (
|
||||
<CheckIcon className="size-3 text-white" aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type SelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type Preference = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
defaultChecked: boolean
|
||||
}
|
||||
|
||||
export type AccentColorOption = {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type SettingBadgeVariant =
|
||||
| "primary-light"
|
||||
| "destructive-light"
|
||||
| "info-light"
|
||||
|
||||
export type SettingFieldProps = {
|
||||
title: string
|
||||
description: string
|
||||
badge?: { label: string; variant: SettingBadgeVariant }
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
}
|
||||
|
||||
export type DateFormatOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const DAYS: SelectOption[] = [
|
||||
{ value: "monday", label: "Monday" },
|
||||
{ value: "tuesday", label: "Tuesday" },
|
||||
{ value: "wednesday", label: "Wednesday" },
|
||||
{ value: "thursday", label: "Thursday" },
|
||||
{ value: "friday", label: "Friday" },
|
||||
{ value: "saturday", label: "Saturday" },
|
||||
{ value: "sunday", label: "Sunday" },
|
||||
]
|
||||
|
||||
export const CURRENCIES: SelectOption[] = [
|
||||
{ value: "usd", label: "USD · US Dollar" },
|
||||
{ value: "eur", label: "EUR · Euro" },
|
||||
{ value: "gbp", label: "GBP · British Pound" },
|
||||
{ value: "jpy", label: "JPY · Japanese Yen" },
|
||||
]
|
||||
|
||||
export const REGIONS: SelectOption[] = [
|
||||
{ value: "us", label: "United States" },
|
||||
{ value: "eu", label: "European Union" },
|
||||
{ value: "uk", label: "United Kingdom" },
|
||||
{ value: "jp", label: "Japan" },
|
||||
{ value: "au", label: "Australia" },
|
||||
]
|
||||
|
||||
export const ACCENT_COLORS: AccentColorOption[] = [
|
||||
{ name: "Slate", value: "#64748b" },
|
||||
{ name: "Rose", value: "#f43f5e" },
|
||||
{ name: "Orange", value: "#f97316" },
|
||||
{ name: "Violet", value: "#8b5cf6" },
|
||||
{ name: "Emerald", value: "#10b981" },
|
||||
{ name: "Sky", value: "#0ea5e9" },
|
||||
]
|
||||
|
||||
export const PREFERENCES: Preference[] = [
|
||||
{
|
||||
id: "keyboard-shortcuts",
|
||||
label: "Enable keyboard shortcuts.",
|
||||
description: "Use shortcuts to speed up your workflow.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "spellcheck",
|
||||
label: "Auto spell-check.",
|
||||
description: "Highlight spelling errors in text fields.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "compact-mode",
|
||||
label: "Compact Mode.",
|
||||
description: "Reduce spacing for a denser interface layout.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: "animations",
|
||||
label: "Reduce animations.",
|
||||
description: "Minimize motion effects throughout the UI.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const DATE_FORMAT_OPTIONS: DateFormatOption[] = [
|
||||
{ value: "mdy", label: "MM/DD/YYYY" },
|
||||
{ value: "dmy", label: "DD/MM/YYYY" },
|
||||
{ value: "ymd", label: "YYYY/MM/DD" },
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
|
||||
import { PREFERENCES } from "./data"
|
||||
|
||||
// ── Editor Preferences ──
|
||||
|
||||
export function EditorPreferences() {
|
||||
const [values, setValues] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(PREFERENCES.map((p) => [p.id, p.defaultChecked]))
|
||||
)
|
||||
|
||||
return (
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Editor preferences</FieldLegend>
|
||||
{/* Description */}
|
||||
<FieldDescription className="sr-only">
|
||||
Configure editing behavior and interface density.
|
||||
</FieldDescription>
|
||||
|
||||
{/* List */}
|
||||
<FieldGroup className="gap-3">
|
||||
{PREFERENCES.map((pref) => (
|
||||
<Field key={pref.id} orientation="horizontal" className="gap-3">
|
||||
<Switch
|
||||
id={`settings-3-${pref.id}`}
|
||||
checked={values[pref.id]}
|
||||
onCheckedChange={(value) =>
|
||||
setValues((prev) => ({ ...prev, [pref.id]: value }))
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
|
||||
<FieldContent className="gap-0.5">
|
||||
<FieldLabel htmlFor={`settings-3-${pref.id}`}>
|
||||
{pref.label}
|
||||
</FieldLabel>
|
||||
<FieldDescription className="text-xs">
|
||||
{pref.description}
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@evobgp/ui/components/toggle-group"
|
||||
import { ColorPicker } from "./color-picker"
|
||||
import { CURRENCIES, DAYS, REGIONS } from "./data"
|
||||
import { EditorPreferences } from "./editor-preferences"
|
||||
import { SettingField } from "./setting-field"
|
||||
import { PlusIcon, UploadIcon, GlobeIcon, CircleDollarSignIcon } from "lucide-react"
|
||||
|
||||
export function GeneralSettings() {
|
||||
const [dateFormat, setDateFormat] = useState(["mdy"])
|
||||
const [startOfWeek, setStartOfWeek] = useState("monday")
|
||||
const [region, setRegion] = useState("us")
|
||||
const [currency, setCurrency] = useState("usd")
|
||||
const handleStartOfWeekChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setStartOfWeek(value)
|
||||
}
|
||||
}
|
||||
const handleRegionChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setRegion(value)
|
||||
}
|
||||
}
|
||||
const handleCurrencyChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setCurrency(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className="w-full max-w-3xl">
|
||||
<FrameHeader className="px-2! py-2.5!">
|
||||
<FrameTitle>General Settings</FrameTitle>
|
||||
<FrameDescription>Core app preferences.</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0">
|
||||
<FieldGroup className="gap-0">
|
||||
{/* ── Project name ── */}
|
||||
<SettingField
|
||||
title="Project name"
|
||||
description="The display name for your project across the platform."
|
||||
labelFor="settings-3-project-name"
|
||||
>
|
||||
<Input id="settings-3-project-name" defaultValue="Acme Dashboard" />
|
||||
</SettingField>
|
||||
|
||||
{/* ── API endpoint ── */}
|
||||
<SettingField
|
||||
title="API endpoint"
|
||||
description="Set the base URL for your project API."
|
||||
badge={{ label: "Required", variant: "destructive-light" }}
|
||||
labelFor="settings-3-api-endpoint"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>https://</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-3-api-endpoint"
|
||||
defaultValue="api.acme.io"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Start of week ── */}
|
||||
<SettingField
|
||||
title="Start of week"
|
||||
description="Choose which day marks the start of your week."
|
||||
labelFor="settings-3-start-of-week"
|
||||
>
|
||||
<Select value={startOfWeek} onValueChange={handleStartOfWeekChange}>
|
||||
<SelectTrigger id="settings-3-start-of-week" className="w-full">
|
||||
<SelectValue>{getOptionLabel(DAYS, startOfWeek)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{DAYS.map((day) => (
|
||||
<SelectItem key={day.value} value={day.value}>
|
||||
{day.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Allowed origins ── */}
|
||||
<SettingField
|
||||
title="Allowed origins"
|
||||
description="Define the trusted domains for CORS requests."
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add origin
|
||||
</Button>
|
||||
|
||||
<Button variant="outline">
|
||||
<UploadIcon aria-hidden="true" />
|
||||
Import from file
|
||||
</Button>
|
||||
</div>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Accent color ── */}
|
||||
<SettingField
|
||||
title="Accent color"
|
||||
description="Select a color to represent your brand."
|
||||
badge={{ label: "Upgrade to Pro", variant: "primary-light" }}
|
||||
>
|
||||
<ColorPicker />
|
||||
</SettingField>
|
||||
|
||||
{/* ── Region & currency ── */}
|
||||
<SettingField
|
||||
title="Region & currency"
|
||||
description="Adjust your regional preferences and currency."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Region and currency</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Regional and financial formatting preferences.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-3-region">Region</FieldLabel>
|
||||
<Select value={region} onValueChange={handleRegionChange}>
|
||||
<SelectTrigger id="settings-3-region" className="w-full">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
<SelectValue>
|
||||
{getOptionLabel(REGIONS, region)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{REGIONS.map((region) => (
|
||||
<SelectItem key={region.value} value={region.value}>
|
||||
{region.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-3-currency">
|
||||
Currency
|
||||
</FieldLabel>
|
||||
<Select value={currency} onValueChange={handleCurrencyChange}>
|
||||
<SelectTrigger id="settings-3-currency" className="w-full">
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
<SelectValue>
|
||||
{getOptionLabel(CURRENCIES, currency)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<SelectItem
|
||||
key={currency.value}
|
||||
value={currency.value}
|
||||
>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Date display format ── */}
|
||||
<SettingField
|
||||
title="Date display format"
|
||||
description="Choose your preferred format for dates."
|
||||
>
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={dateFormat}
|
||||
onValueChange={(value) => {
|
||||
if (value.length > 0) setDateFormat(value)
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Date display format"
|
||||
>
|
||||
<ToggleGroupItem value="mdy">MM/DD/YYYY</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dmy">DD/MM/YYYY</ToggleGroupItem>
|
||||
<ToggleGroupItem value="ymd">YYYY/MM/DD</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Editor preferences ── */}
|
||||
<SettingField
|
||||
title="Editor preferences"
|
||||
description="Adjust your editing environment and display options."
|
||||
last
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<EditorPreferences />
|
||||
</SettingField>
|
||||
</FieldGroup>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="flex flex-row justify-end gap-3">
|
||||
<Button variant="outline">Reset</Button>
|
||||
<Button>Save changes</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helper functions ──
|
||||
|
||||
function getOptionLabel(
|
||||
options: Array<{ value: string; label: string }>,
|
||||
value: string
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client"
|
||||
|
||||
import { type ComponentProps, type ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from "@evobgp/ui/components/field"
|
||||
|
||||
interface SettingFieldProps {
|
||||
title: string
|
||||
description: string
|
||||
badge?: {
|
||||
label: string
|
||||
variant: ComponentProps<typeof Badge>["variant"]
|
||||
}
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
// ── Setting Field ──
|
||||
|
||||
export function SettingField({
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
children,
|
||||
last,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
}: SettingFieldProps) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
)}
|
||||
|
||||
{badge ? (
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FieldDescription className="text-xs">{description}</FieldDescription>
|
||||
</div>
|
||||
|
||||
<FieldContent
|
||||
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
||||
>
|
||||
{children}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralSettings } from "./components/general-settings"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-8 md:p-16">
|
||||
<GeneralSettings />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const leadsData = {
|
||||
newLeads: 54,
|
||||
returningLeads: 198,
|
||||
newPercent: 21.43,
|
||||
returningPercent: 78.57,
|
||||
topSource: "LinkedIn",
|
||||
conversionRate: 12.8,
|
||||
}
|
||||
|
||||
export const rangeOptions = [
|
||||
{ label: "All Time", value: "all-time" },
|
||||
{ label: "This Month", value: "this-month" },
|
||||
{ label: "Last Month", value: "last-month" },
|
||||
{ label: "This Year", value: "this-year" },
|
||||
{ label: "Last Year", value: "last-year" },
|
||||
]
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Progress } from "@evobgp/ui/components/progress"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import { leadsData, rangeOptions } from "./data"
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
export function Stats() {
|
||||
return (
|
||||
<Frame className="w-full sm:max-w-md">
|
||||
{/* Content */}
|
||||
<FramePanel>
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<FrameTitle>Leads Overview</FrameTitle>
|
||||
<Select defaultValue="this-month" items={rangeOptions}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
align="start"
|
||||
alignItemWithTrigger={false}
|
||||
className="w-32"
|
||||
>
|
||||
{rangeOptions.map((r) => (
|
||||
<SelectItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-stretch gap-x-6">
|
||||
<div className="flex flex-1 flex-col items-start gap-1">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<span className="text-foreground text-2xl font-bold">
|
||||
{leadsData.newLeads}
|
||||
</span>
|
||||
<Badge variant="info-light">{leadsData.newPercent}%</Badge>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
New leads
|
||||
</span>
|
||||
<div className="mt-1 w-full">
|
||||
<Progress
|
||||
value={leadsData.newPercent}
|
||||
className="**:data-[slot=progress-track]:h-2.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-muted-foreground/10 flex flex-1 flex-col items-start gap-1 border-s ps-6">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<span className="text-foreground text-2xl font-bold">
|
||||
{leadsData.returningLeads}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
Returning leads
|
||||
</span>
|
||||
<div className="mt-1 flex w-full gap-0.5">
|
||||
{Array.from({ length: 30 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-2.5 w-0.5 flex-1 rounded-md",
|
||||
i < Math.round((leadsData.returningPercent / 100) * 30)
|
||||
? "bg-emerald-500"
|
||||
: "bg-muted"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1.5 flex items-center gap-x-4">
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="text-muted-foreground text-xs">Top Source</span>
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{leadsData.topSource}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5 ps-7.5">
|
||||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
Conversion Rate
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="About Conversion Rate"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex cursor-help items-center rounded-full outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>Percentage of leads converted to customers.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{leadsData.conversionRate}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Stats } from "./components/stats"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-10 md:p-20">
|
||||
<Stats />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export const rangeOptions = [
|
||||
{ label: "Q1 - 2024", value: "q1" },
|
||||
{ label: "Q2 - 2024", value: "q2" },
|
||||
{ label: "Q3 - 2024", value: "q3" },
|
||||
{ label: "Q4 - 2024", value: "q4" },
|
||||
]
|
||||
|
||||
export const performance = [
|
||||
{ label: "Deals Closed", value: 27, trend: 12, trendDir: "up" as const },
|
||||
{ label: "Revenue", value: "$182.4k", trend: 6, trendDir: "up" as const },
|
||||
{ label: "Conversion", value: "72%", trend: 3, trendDir: "down" as const },
|
||||
]
|
||||
|
||||
export const pipelineProgress = 76
|
||||
|
||||
export const activity = [
|
||||
{
|
||||
text: "Closed deal with FinSight Inc.",
|
||||
date: "Today",
|
||||
state: "secondary",
|
||||
color: "text-emerald-500",
|
||||
},
|
||||
{
|
||||
text: "3 new leads added to Pipeline.",
|
||||
date: "Yesterday",
|
||||
state: "secondary",
|
||||
color: "text-emerald-500",
|
||||
},
|
||||
{
|
||||
text: "Follow-up scheduled.",
|
||||
date: "2 days ago",
|
||||
state: "destructive",
|
||||
color: "text-destructive",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client"
|
||||
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
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 { activity, performance, pipelineProgress, rangeOptions } from "./data"
|
||||
import { MoreHorizontalIcon, SettingsIcon, TriangleAlertIcon, Pin, Share2Icon, Trash2Icon, TrendingUp, TrendingDown, CircleCheckIcon } from "lucide-react"
|
||||
|
||||
export function Stats() {
|
||||
return (
|
||||
<Frame className="w-full sm:max-w-md">
|
||||
{/* 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">Staff Performance</h3>
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
Sales Manager
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select defaultValue="q3" items={rangeOptions}>
|
||||
<SelectTrigger className="h-8! w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
align="start"
|
||||
alignItemWithTrigger={false}
|
||||
className="w-28"
|
||||
>
|
||||
{rangeOptions.map((r) => (
|
||||
<SelectItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="icon-sm" />}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
Add Alert
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Pin aria-hidden="true" />
|
||||
Pin to Dashboard
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Share2Icon aria-hidden="true" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{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.trendDir === "up"
|
||||
? "text-emerald-500"
|
||||
: "text-destructive"
|
||||
)}
|
||||
>
|
||||
{item.trendDir === "up" ? (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
) : (
|
||||
<TrendingDown aria-hidden="true" />
|
||||
)}
|
||||
{item.trendDir === "up" ? "+" : "-"}
|
||||
{item.trend}%
|
||||
</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">
|
||||
{pipelineProgress}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={pipelineProgress}
|
||||
className="**:data-[slot=progress-track]:h-2.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="text-foreground mb-2.5 text-sm font-medium">
|
||||
Recent Activity
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{activity.map((a, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-2.5 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<CircleCheckIcon className={cn("h-3.5 w-3.5", a.color)} aria-hidden="true" />
|
||||
<span className="text-foreground truncate text-xs">
|
||||
{a.text}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
a.state === "secondary" ? "info-light" : "success-light"
|
||||
}
|
||||
>
|
||||
{a.date}
|
||||
</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,9 @@
|
||||
import { Stats } from "./components/stats"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-10 md:p-20">
|
||||
<Stats />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
PanelCard,
|
||||
panelCardContentFlushClassName,
|
||||
} from '@/components/panel-card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
/** Card-surface panel for dashboard sections (replaces legacy Frame shell). */
|
||||
export function DashboardFramePanel({
|
||||
title,
|
||||
description,
|
||||
@@ -24,20 +22,15 @@ export function DashboardFramePanel({
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
}) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
|
||||
return (
|
||||
<Frame stacked dense className={cn('h-full w-full', className)}>
|
||||
{hasHeader ? (
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3 border-b border-(--frame-panel-border-color)">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
<FramePanel className={cn('flex flex-col p-0!', contentClassName)}>{children}</FramePanel>
|
||||
</Frame>
|
||||
<PanelCard
|
||||
title={title}
|
||||
description={description}
|
||||
actions={actions}
|
||||
className={cn('h-full', className)}
|
||||
contentClassName={cn(panelCardContentFlushClassName, contentClassName)}
|
||||
>
|
||||
{children}
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { dashboardKpiGridClassName, kpiCardContentClassName } from '@/lib/ui-surface'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
|
||||
@@ -153,13 +154,13 @@ export function DashboardKpiGrid({
|
||||
const cards = buildKpis({ modules, peers, speakers, jobs, loading })
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
<section aria-label="KPI обзора" className={dashboardKpiGridClassName}>
|
||||
{cards.map((card) => (
|
||||
<Frame key={card.label}>
|
||||
<FramePanel className="flex flex-col items-start gap-4">
|
||||
<Card key={card.label} size="sm" className="gap-0">
|
||||
<CardContent className={cn(kpiCardContentClassName, 'gap-3 p-4')}>
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
'border-background bg-muted flex size-9 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
card.iconClass,
|
||||
)}
|
||||
>
|
||||
@@ -168,15 +169,15 @@ export function DashboardKpiGrid({
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">
|
||||
<div className="text-foreground text-xl leading-none font-bold tabular-nums">
|
||||
{card.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm font-medium">{card.label}</div>
|
||||
<div className="text-muted-foreground text-xs font-medium">{card.label}</div>
|
||||
</div>
|
||||
{card.badge}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
|
||||
import { kpiGridClassName } from '@/lib/ui-surface'
|
||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
function syntheticSparkline(seed: number, points = 9): number[] {
|
||||
const base = Math.max(4, seed)
|
||||
return Array.from({ length: points }, (_, i) =>
|
||||
Math.round(base * (0.82 + (i / points) * 0.18 + Math.sin(i + seed) * 0.04)),
|
||||
)
|
||||
}
|
||||
|
||||
function buildMetrics({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}): KpiSparklineMetric[] {
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const network = aggregateNetworkMetrics(peers, speakers)
|
||||
const bgpPct =
|
||||
network.peersEnabled > 0
|
||||
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
|
||||
: 0
|
||||
const running = runningJobCount(jobs)
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'bgp',
|
||||
title: 'BGP готовность',
|
||||
label: 'Established / включённые',
|
||||
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
|
||||
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
|
||||
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
|
||||
detail: loading ? '' : `${network.peersEstablished} сессий`,
|
||||
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'danger',
|
||||
sparkline: syntheticSparkline(bgpPct || 40),
|
||||
},
|
||||
{
|
||||
id: 'modules',
|
||||
title: 'Модули',
|
||||
label: 'Активные списки',
|
||||
value: loading ? '—' : `${enabledModules}`,
|
||||
delta: loading ? '…' : `${modules.length} всего`,
|
||||
deltaVariant: 'primary-light',
|
||||
detail: loading ? '' : 'маршрутизация',
|
||||
tone: 'info',
|
||||
sparkline: syntheticSparkline(enabledModules || 3),
|
||||
},
|
||||
{
|
||||
id: 'speakers',
|
||||
title: 'Спикеры',
|
||||
label: 'Online / всего',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
delta:
|
||||
loading || network.speakersTotal === 0
|
||||
? '…'
|
||||
: network.speakersOnline === network.speakersTotal
|
||||
? 'все online'
|
||||
: 'частично',
|
||||
deltaVariant:
|
||||
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
|
||||
detail: loading ? '' : 'live-снимок',
|
||||
tone: network.speakersOnline === network.speakersTotal ? 'success' : 'warning',
|
||||
sparkline: syntheticSparkline(network.speakersOnline || 2),
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
title: 'Задачи',
|
||||
label: 'Активные / ошибки',
|
||||
value: loading ? '—' : String(running),
|
||||
delta: loading ? '…' : failedJobs > 0 ? `${failedJobs} ошибок` : 'без сбоев',
|
||||
deltaVariant: failedJobs > 0 ? 'destructive-light' : 'success-light',
|
||||
detail: loading ? '' : `${jobs.length} в выборке`,
|
||||
tone: failedJobs > 0 ? 'danger' : running > 0 ? 'info' : 'success',
|
||||
sparkline: syntheticSparkline(running + failedJobs || 1),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function DashboardKpiSparklineRow({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const metrics = useMemo(
|
||||
() => buildMetrics({ modules, peers, speakers, jobs, loading }),
|
||||
[modules, peers, speakers, jobs, loading],
|
||||
)
|
||||
|
||||
return (
|
||||
<section aria-label="KPI обзора" className={kpiGridClassName}>
|
||||
{metrics.map((metric) => (
|
||||
<KpiSparklineCard key={metric.id} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { ChartBarStrip } from '@/components/analytics/chart-bar-strip'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { SegmentedProgressCard } from '@/components/patterns/segmented-progress-card'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
capacityUtilization,
|
||||
@@ -40,9 +39,10 @@ export function DashboardNetworkHealth({
|
||||
: speakers.filter((s) => s.live?.agent_ok).length
|
||||
const total =
|
||||
mode === 'peers' ? peers.filter((p) => p.enabled !== false).length : speakers.length
|
||||
const offline = Math.max(0, total - established)
|
||||
|
||||
return (
|
||||
<DashboardFramePanel
|
||||
<SegmentedProgressCard
|
||||
title="Загрузка BGP"
|
||||
description="Утилизация сессий по пирам и спикерам"
|
||||
actions={
|
||||
@@ -55,42 +55,26 @@ export function DashboardNetworkHealth({
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 px-4 py-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-3xl font-semibold tracking-tight tabular-nums">
|
||||
{loading ? '—' : `${utilization}%`}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{loading ? '…' : `${established} / ${total} ${mode === 'peers' ? 'установлено' : 'в сети'}`}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" radius="full" className="h-7 gap-1.5 px-2.5 text-xs">
|
||||
<span className="font-semibold tabular-nums">{loading ? '—' : queued}</span>
|
||||
<span>активных задач</span>
|
||||
primary={{
|
||||
value: loading ? '—' : `${utilization}%`,
|
||||
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры online',
|
||||
percent: loading ? 0 : utilization,
|
||||
badge: (
|
||||
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
|
||||
{loading ? '…' : `${established}/${total}`}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex h-36 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartBarStrip bars={bars} />
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground flex flex-wrap gap-3 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="bg-chart-3 size-2 rounded-full" aria-hidden />
|
||||
Ниже порога
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="bg-chart-2 size-2 rounded-full" aria-hidden />
|
||||
Установлено / online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardFramePanel>
|
||||
),
|
||||
}}
|
||||
secondary={{
|
||||
value: loading ? '—' : offline,
|
||||
label: mode === 'peers' ? 'Не Established' : 'Offline',
|
||||
percent: total > 0 ? Math.round((offline / total) * 100) : 0,
|
||||
}}
|
||||
footer={
|
||||
loading
|
||||
? 'Загрузка…'
|
||||
: `Активных задач: ${queued} · сегментов в графике: ${bars.length}`
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useMemo, useState } from 'react'
|
||||
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, ModuleRow } from '@/types/api'
|
||||
|
||||
type FlowMode = 'jobs' | 'modules'
|
||||
|
||||
/** chart-13 inspired donut breakdown (Card surface). */
|
||||
export function DashboardOperationsBreakdown({
|
||||
jobs,
|
||||
modules,
|
||||
@@ -28,7 +30,7 @@ export function DashboardOperationsBreakdown({
|
||||
const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули'
|
||||
|
||||
return (
|
||||
<DashboardFramePanel
|
||||
<PanelCard
|
||||
title="Поток операций"
|
||||
description="Распределение задач и типов модулей"
|
||||
actions={
|
||||
@@ -41,20 +43,45 @@ export function DashboardOperationsBreakdown({
|
||||
]}
|
||||
/>
|
||||
}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="px-4 py-4">
|
||||
<div className="p-4">
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric
|
||||
slices={slices}
|
||||
centerLabel={centerLabel}
|
||||
centerValue={total}
|
||||
/>
|
||||
<>
|
||||
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
|
||||
{slices.length > 0 ? (
|
||||
<ul className="mt-4 flex min-w-0 flex-col">
|
||||
{slices.map((slice, index) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
<li key={slice.key}>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
||||
style={{ backgroundColor: slice.color }}
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">{slice.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">{slice.count}</span>
|
||||
<span className="text-muted-foreground/70 w-10 text-right text-xs tabular-nums">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
{index < slices.length - 1 ? <Separator className="w-auto" /> : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DashboardFramePanel>
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ChevronRight } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
||||
import { FramePanel } from '@/components/reui/frame'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
|
||||
@@ -31,7 +31,8 @@ export function DashboardQuickLinkCard({
|
||||
className="group block h-full rounded-[inherit] focus-visible:outline-none"
|
||||
aria-label={`${label}: ${description}`}
|
||||
>
|
||||
<FramePanel
|
||||
<Card
|
||||
size="sm"
|
||||
className={cn(
|
||||
'relative isolate h-full overflow-hidden transition-colors',
|
||||
'hover:border-foreground/20',
|
||||
@@ -39,7 +40,7 @@ export function DashboardQuickLinkCard({
|
||||
)}
|
||||
>
|
||||
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
|
||||
<div className="relative z-10 flex h-full flex-col gap-7.5">
|
||||
<CardContent className="relative z-10 flex h-full flex-col gap-7.5 p-5">
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background flex size-11 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5',
|
||||
@@ -61,8 +62,8 @@ export function DashboardQuickLinkCard({
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,7 @@ import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { DashboardQuickLinkCard } from '@/components/dashboard/dashboard-quick-link-card'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
|
||||
type QuickLink = {
|
||||
icon: ReactNode
|
||||
@@ -69,16 +64,15 @@ const LINKS: QuickLink[] = [
|
||||
|
||||
export function DashboardQuickLinks() {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Быстрые действия</FrameTitle>
|
||||
<FrameDescription>Частые переходы к настройке и деплою</FrameDescription>
|
||||
</FrameHeader>
|
||||
<div className="grid gap-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{LINKS.map((link) => (
|
||||
<DashboardQuickLinkCard key={link.label} {...link} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
<PanelCard
|
||||
title="Быстрые действия"
|
||||
description="Частые переходы к настройке и деплою"
|
||||
className="@container w-full"
|
||||
contentClassName="grid gap-3 p-4 sm:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
{LINKS.map((link) => (
|
||||
<DashboardQuickLinkCard key={link.label} {...link} />
|
||||
))}
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,16 +36,21 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from '@evobgp/ui/components/breadcrumb'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { TooltipProvider } from '@evobgp/ui/components/tooltip'
|
||||
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import type { ComponentType, CSSProperties, ReactNode } from 'react'
|
||||
|
||||
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
description?: string
|
||||
search?: Record<string, string>
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
@@ -56,31 +61,38 @@ interface NavGroup {
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Обзор',
|
||||
items: [{ to: '/dashboard', label: 'Панель', icon: LayoutDashboard }],
|
||||
items: [
|
||||
{
|
||||
to: '/dashboard',
|
||||
label: 'Панель',
|
||||
icon: LayoutDashboard,
|
||||
description: 'KPI, модули и активность',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Маршрутизация',
|
||||
items: [
|
||||
{ to: '/modules', label: 'Модули', icon: Boxes },
|
||||
{ to: '/network', label: 'Сеть', icon: Network },
|
||||
{ to: '/directories', label: 'Справочники', icon: BookText },
|
||||
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
||||
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
||||
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Операции',
|
||||
items: [
|
||||
{ to: '/operations', label: 'Операции', icon: Cog },
|
||||
{ to: '/firewall', label: 'Файрвол', icon: Shield },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
|
||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
||||
{ to: '/firewall', label: 'Файрвол', icon: Shield, description: 'Клиенты и правила' },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Система',
|
||||
items: [
|
||||
{ to: '/access', label: 'Доступ', icon: KeyRound },
|
||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog },
|
||||
{ to: '/settings', label: 'Настройки UI', icon: Settings },
|
||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
||||
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -91,84 +103,114 @@ const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||
)
|
||||
|
||||
const PARENT_ROUTE: Record<string, string> = {}
|
||||
const PARENT_ROUTE: Record<string, string> = {
|
||||
'/modules/new': '/modules',
|
||||
}
|
||||
|
||||
const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
|
||||
id: item.to,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
to: item.to,
|
||||
search: item.search,
|
||||
keywords: [item.to.replace(/^\//, '')],
|
||||
}))
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const activeItem =
|
||||
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
||||
ALL_NAV_ITEMS[0]
|
||||
const parentTo = PARENT_ROUTE[activeItem.to]
|
||||
const parentTo = PARENT_ROUTE[pathname] ?? PARENT_ROUTE[activeItem.to]
|
||||
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
|
||||
|
||||
const pageTitle =
|
||||
pathname.startsWith('/modules/') && pathname !== '/modules/new'
|
||||
? 'Модуль'
|
||||
: (ROUTE_LABELS[activeItem.to] ?? activeItem.label)
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
|
||||
B
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '260px',
|
||||
'--sidebar-width-icon': '62px',
|
||||
'--header-height': '56px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="gap-2">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
|
||||
B
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-sm font-semibold">EvoBGP</span>
|
||||
<span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-sm font-semibold">EvoBGP</span>
|
||||
<span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
|
||||
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
||||
<CommandPalette items={COMMAND_ITEMS} />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
render={<Link to={item.to} />}
|
||||
isActive={isActive}
|
||||
tooltip={item.label}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{parentLabel && parentTo ? (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
</>
|
||||
) : null}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? activeItem.label}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
render={<Link to={item.to} search={item.search} />}
|
||||
isActive={isActive}
|
||||
tooltip={item.label}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="sticky top-0 z-10 flex h-(--header-height) shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{parentLabel && parentTo ? (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
</>
|
||||
) : null}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{pageTitle}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SystemMonitorPopover />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Search } from 'lucide-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'
|
||||
|
||||
export type CommandPaletteItem = {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
to: string
|
||||
search?: Record<string, string>
|
||||
keywords?: string[]
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
items: CommandPaletteItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CommandPalette({ items }: CommandPaletteProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputId = useId()
|
||||
const navigate = useNavigate()
|
||||
|
||||
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)
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return items
|
||||
return items.filter((item) => {
|
||||
const haystack = [item.label, item.description, ...(item.keywords ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return haystack.includes(q)
|
||||
})
|
||||
}, [items, query])
|
||||
|
||||
function go(item: CommandPaletteItem) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
void navigate({ to: item.to, search: item.search })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarGroupContent className="relative">
|
||||
<Button
|
||||
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)}
|
||||
>
|
||||
Поиск…
|
||||
</Button>
|
||||
<Search
|
||||
aria-hidden
|
||||
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={(next) => {
|
||||
setOpen(next)
|
||||
if (!next) setQuery('')
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Быстрый переход</DialogTitle>
|
||||
<DialogDescription>Навигация по разделам EvoBGP</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md gap-0 px-0 py-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3">
|
||||
<div className="flex items-center gap-3 border-b px-4 py-3">
|
||||
<Search aria-hidden className="size-4 shrink-0 opacity-60" />
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-9 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Раздел или действие…"
|
||||
aria-label="Поиск разделов"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filtered[0]) go(filtered[0])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ul className="max-h-72 overflow-y-auto p-2" role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<li className="text-muted-foreground px-2 py-6 text-center text-sm">Ничего не найдено</li>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
className="hover:bg-accent flex w-full flex-col items-start gap-0.5 rounded-md px-3 py-2 text-left text-sm transition-colors"
|
||||
onClick={() => go(item)}
|
||||
>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
{item.description ? (
|
||||
<span className="text-muted-foreground text-xs">{item.description}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Activity, Bird, HeartPulse, ListChecks, Network } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
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 { monitoringHealthQueryOptions } from '@/queries/monitoring'
|
||||
import { networkBirdQueryOptions } from '@/queries/network'
|
||||
import { aggregateNetworkMetrics, overviewJobsQueryOptions, overviewPeersQueryOptions } from '@/queries/overview'
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
unit: string
|
||||
percent: number
|
||||
icon: ReactNode
|
||||
tone: 'success' | 'warning' | 'destructive' | 'info'
|
||||
alert: boolean
|
||||
}
|
||||
|
||||
function toneColor(tone: MonitorMetric['tone']) {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
return 'var(--color-success)'
|
||||
case 'warning':
|
||||
return 'var(--color-warning)'
|
||||
case 'destructive':
|
||||
return 'var(--color-destructive)'
|
||||
default:
|
||||
return 'var(--color-info)'
|
||||
}
|
||||
}
|
||||
|
||||
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
const color = toneColor(metric.tone)
|
||||
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"
|
||||
style={{ backgroundColor: `${color}18` }}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto" style={{ 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" style={{ color }}>
|
||||
{metric.value}
|
||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={metric.percent}
|
||||
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
|
||||
style={{ '--bar-color': color } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live system monitor popover (app-shell-7 pattern, EvoBGP API data). */
|
||||
export function SystemMonitorPopover() {
|
||||
const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 })
|
||||
const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 })
|
||||
const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 })
|
||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||
|
||||
const peers = peersQ.data?.items ?? []
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const network = aggregateNetworkMetrics(peers, [])
|
||||
const bgpPct =
|
||||
network.peersEnabled > 0
|
||||
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
|
||||
: 0
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const runningJobs = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||
const healthOk = healthQ.data?.ok ?? false
|
||||
const birdRatio =
|
||||
birdQ.data && birdQ.data.bgp_sessions_total > 0
|
||||
? Math.round((birdQ.data.bgp_established / birdQ.data.bgp_sessions_total) * 100)
|
||||
: null
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'api',
|
||||
label: 'API health',
|
||||
value: healthOk ? 'OK' : '—',
|
||||
unit: '',
|
||||
percent: healthOk ? 100 : 0,
|
||||
icon: <HeartPulse aria-hidden />,
|
||||
tone: healthOk ? 'success' : 'destructive',
|
||||
alert: !healthOk,
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
label: 'BGP пиры',
|
||||
value: String(bgpPct),
|
||||
unit: '%',
|
||||
percent: bgpPct,
|
||||
icon: <Network aria-hidden />,
|
||||
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'destructive',
|
||||
alert: bgpPct < 70 && network.peersEnabled > 0,
|
||||
},
|
||||
{
|
||||
id: 'bird',
|
||||
label: 'BIRD сессии',
|
||||
value: birdRatio !== null ? String(birdRatio) : '—',
|
||||
unit: birdRatio !== null ? '%' : '',
|
||||
percent: birdRatio ?? 0,
|
||||
icon: <Bird aria-hidden />,
|
||||
tone:
|
||||
birdRatio === null
|
||||
? 'info'
|
||||
: birdRatio >= 100
|
||||
? 'success'
|
||||
: birdRatio >= 50
|
||||
? 'warning'
|
||||
: 'destructive',
|
||||
alert: birdRatio !== null && birdRatio < 100,
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Ошибки задач',
|
||||
value: String(failedJobs),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, failedJobs * 10),
|
||||
icon: <ListChecks aria-hidden />,
|
||||
tone: failedJobs > 0 ? 'warning' : 'success',
|
||||
alert: failedJobs > 0,
|
||||
},
|
||||
],
|
||||
[bgpPct, birdRatio, failedJobs, healthOk, network.peersEnabled],
|
||||
)
|
||||
|
||||
const spiking = metrics.some((m) => m.alert) || runningJobs > 5
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Монитор системы"
|
||||
className={cn(
|
||||
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
|
||||
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="relative flex size-3.5 items-center justify-center">
|
||||
<Activity
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-3.5 transition-colors',
|
||||
spiking ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
{spiking ? (
|
||||
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
||||
<Badge
|
||||
variant={spiking ? 'destructive-light' : 'success-light'}
|
||||
size="xs"
|
||||
className="h-4 px-1.5 text-[10px]"
|
||||
>
|
||||
{spiking ? 'Внимание' : 'Норма'}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="w-80 gap-0! space-y-0! p-0!">
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
{new Date().toLocaleTimeString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
{metrics.map((metric, i) => (
|
||||
<div
|
||||
key={metric.id}
|
||||
className={cn(
|
||||
i % 2 === 1 && 'border-border border-l',
|
||||
i >= 2 && 'border-border border-t',
|
||||
)}
|
||||
>
|
||||
<MetricCell metric={metric} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||
Активных задач: <span className="text-foreground font-medium tabular-nums">{runningJobs}</span>
|
||||
{' · '}
|
||||
Пиров в каталоге:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{network.peersTotal}</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import {
|
||||
ArrowDownUp,
|
||||
Network,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Timer,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
|
||||
import { kpiGridClassName } from '@/lib/ui-surface'
|
||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||
import {
|
||||
communityLabel,
|
||||
@@ -15,6 +7,7 @@ import {
|
||||
moduleDohProfileIds,
|
||||
} from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu } from '@/lib/ui-labels'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
|
||||
|
||||
interface ModuleKpiCardsProps {
|
||||
@@ -25,6 +18,12 @@ interface ModuleKpiCardsProps {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
function syntheticSparkline(seed: number): number[] {
|
||||
return Array.from({ length: 9 }, (_, i) =>
|
||||
Math.round(seed * (0.8 + (i / 9) * 0.2 + Math.sin(i) * 0.05)),
|
||||
)
|
||||
}
|
||||
|
||||
export function ModuleKpiCards({
|
||||
mod,
|
||||
communities,
|
||||
@@ -33,52 +32,67 @@ export function ModuleKpiCards({
|
||||
loading = false,
|
||||
}: ModuleKpiCardsProps) {
|
||||
if (loading || !mod) {
|
||||
return <SectionCardsSkeleton count={5} />
|
||||
return <SectionCardsSkeleton count={4} />
|
||||
}
|
||||
|
||||
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||
const dohIds = moduleDohProfileIds(mod)
|
||||
|
||||
const items: SectionCardItem[] = [
|
||||
const metrics: KpiSparklineMetric[] = [
|
||||
{
|
||||
label: 'Приоритет',
|
||||
id: 'priority',
|
||||
title: 'Приоритет',
|
||||
label: 'Порядок в ревизии',
|
||||
value: String(mod.priority ?? 0),
|
||||
hint: 'порядок в сборке ревизии',
|
||||
icon: <ArrowDownUp className="size-3.5" />,
|
||||
delta: mod.enabled !== false ? 'активен' : 'выключен',
|
||||
deltaVariant: mod.enabled !== false ? 'success-light' : 'outline',
|
||||
detail: mod.type,
|
||||
tone: 'info',
|
||||
sparkline: syntheticSparkline(mod.priority ?? 1),
|
||||
},
|
||||
{
|
||||
label: 'Интервал',
|
||||
id: 'interval',
|
||||
title: 'Интервал',
|
||||
label: 'Обновление',
|
||||
value: moduleIntervalLabel(mod),
|
||||
hint: 'refresh_interval_sec / cron',
|
||||
icon: <Timer className="size-3.5" />,
|
||||
delta: formatDateTime(mod.last_refreshed_at) || 'никогда',
|
||||
deltaVariant: 'primary-light',
|
||||
detail: 'last refresh',
|
||||
tone: 'warning',
|
||||
sparkline: syntheticSparkline(12),
|
||||
},
|
||||
{
|
||||
label: 'DoH',
|
||||
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
|
||||
hint:
|
||||
mod.type === 'DOMAINS'
|
||||
? dohIds.length
|
||||
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
|
||||
: 'Системный DNS'
|
||||
: 'не применимо',
|
||||
icon: <Network className="size-3.5" />,
|
||||
id: 'prefixes',
|
||||
title: 'Префиксы',
|
||||
label: mod.type === 'AS_PREFIXES' ? 'AS entries' : 'Записи',
|
||||
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
|
||||
delta: `${asEntries.length} AS`,
|
||||
deltaVariant: 'success-light',
|
||||
detail: 'в модуле',
|
||||
tone: 'success',
|
||||
sparkline: syntheticSparkline(asPrefixTotal || asEntries.length || 1),
|
||||
},
|
||||
{
|
||||
label: 'Community по умолч.',
|
||||
value: communityLabel(mod.default_community_id, communities),
|
||||
hint: 'для записей без своего community',
|
||||
icon: <ShieldCheck className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
label: 'Последнее обновление',
|
||||
value: formatDateTime(mod.last_refreshed_at),
|
||||
hint:
|
||||
mod.type === 'AS_PREFIXES'
|
||||
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
|
||||
: 'время последнего refresh',
|
||||
icon: <RefreshCw className="size-3.5" />,
|
||||
id: 'policy',
|
||||
title: 'DoH / BGP',
|
||||
label: communityLabel(mod.default_community_id, communities),
|
||||
value: dohIds.length > 0 ? String(dohIds.length) : '—',
|
||||
delta: dohPolicyRu(mod.doh_resolver_policy),
|
||||
deltaVariant: 'info-light',
|
||||
detail:
|
||||
dohIds.length > 0
|
||||
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')
|
||||
: 'без DoH',
|
||||
tone: 'info',
|
||||
sparkline: syntheticSparkline(dohIds.length || 2),
|
||||
},
|
||||
]
|
||||
|
||||
return <SectionCards items={items} />
|
||||
return (
|
||||
<section aria-label="KPI модуля" className={kpiGridClassName}>
|
||||
{metrics.map((metric) => (
|
||||
<KpiSparklineCard key={metric.id} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
|
||||
@@ -10,6 +11,8 @@ import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type PeerTab = 'all' | 'established' | 'pending' | 'disabled'
|
||||
|
||||
interface NetworkPeersCardProps {
|
||||
items: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
@@ -19,6 +22,24 @@ interface NetworkPeersCardProps {
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
function filterPeers(items: PeerRow[], tab: PeerTab): PeerRow[] {
|
||||
if (tab === 'all') return items
|
||||
if (tab === 'disabled') return items.filter((p) => p.enabled === false)
|
||||
const enabled = items.filter((p) => p.enabled !== false)
|
||||
if (tab === 'established') return enabled.filter((p) => p.session_state === 'Established')
|
||||
return enabled.filter((p) => p.session_state !== 'Established')
|
||||
}
|
||||
|
||||
function tabCounts(items: PeerRow[]) {
|
||||
const enabled = items.filter((p) => p.enabled !== false)
|
||||
return {
|
||||
all: items.length,
|
||||
established: enabled.filter((p) => p.session_state === 'Established').length,
|
||||
pending: enabled.filter((p) => p.session_state !== 'Established').length,
|
||||
disabled: items.length - enabled.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function NetworkPeersCard({
|
||||
items,
|
||||
speakers,
|
||||
@@ -28,6 +49,9 @@ export function NetworkPeersCard({
|
||||
onRetry,
|
||||
}: NetworkPeersCardProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [tab, setTab] = useState<PeerTab>('all')
|
||||
const counts = useMemo(() => tabCounts(items), [items])
|
||||
const filtered = useMemo(() => filterPeers(items, tab), [items, tab])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -41,14 +65,24 @@ export function NetworkPeersCard({
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="border-b px-5 py-3">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as PeerTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
||||
<TabsTrigger value="established">Established ({counts.established})</TabsTrigger>
|
||||
<TabsTrigger value="pending">Ожидание ({counts.pending})</TabsTrigger>
|
||||
<TabsTrigger value="disabled">Выключены ({counts.disabled})</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<QueryState
|
||||
data={items}
|
||||
data={filtered}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет пиров"
|
||||
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
|
||||
empty={filtered.length === 0}
|
||||
emptyTitle="Нет пиров в выборке"
|
||||
emptyDescription="Измените фильтр или добавьте BGP-соседа."
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
@@ -58,11 +92,7 @@ export function NetworkPeersCard({
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
<PeerFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
speakers={speakers}
|
||||
/>
|
||||
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import type { JobRow } from '@/types/api'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
type JobTab = 'all' | 'active' | 'failed' | 'succeeded'
|
||||
|
||||
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
||||
if (tab === 'all') return items
|
||||
if (tab === 'active') return items.filter((j) => j.status === 'running' || j.status === 'queued')
|
||||
if (tab === 'failed')
|
||||
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||
return items.filter((j) => j.status === 'succeeded')
|
||||
}
|
||||
|
||||
function tabCounts(items: JobRow[]) {
|
||||
return {
|
||||
all: items.length,
|
||||
active: items.filter((j) => j.status === 'running' || j.status === 'queued').length,
|
||||
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||
.length,
|
||||
succeeded: items.filter((j) => j.status === 'succeeded').length,
|
||||
}
|
||||
}
|
||||
|
||||
/** data-grid-filtering-1 style jobs card with status tabs. */
|
||||
export function OperationsJobsCard({
|
||||
jobs,
|
||||
nameById,
|
||||
qc,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
nameById: Map<string, string>
|
||||
qc: QueryClient
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
}) {
|
||||
const [tab, setTab] = useState<JobTab>('all')
|
||||
const counts = useMemo(() => tabCounts(jobs), [jobs])
|
||||
const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
|
||||
|
||||
return (
|
||||
<DataGridCard title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
|
||||
<div className="border-b px-5 py-3">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
||||
<TabsTrigger value="active">Активные ({counts.active})</TabsTrigger>
|
||||
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger>
|
||||
<TabsTrigger value="failed">Ошибки ({counts.failed})</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<QueryState
|
||||
data={filtered}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={filtered.length === 0}
|
||||
emptyTitle="Нет задач в выборке"
|
||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(items) => (
|
||||
<OperationsJobsGrid
|
||||
items={items}
|
||||
nameById={nameById}
|
||||
qc={qc}
|
||||
isLoading={isLoading && items.length > 0}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Cell, Pie, PieChart } from 'recharts'
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
ChartContainer,
|
||||
type ChartConfig,
|
||||
} from '@evobgp/ui/components/chart'
|
||||
import type { BreakdownSlice } from '@/lib/metrics'
|
||||
|
||||
/** chart-27 / chart-13 inspired donut in PanelCard. */
|
||||
export function DonutBreakdownCard({
|
||||
title,
|
||||
description,
|
||||
slices,
|
||||
centerLabel,
|
||||
badge,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
slices: BreakdownSlice[]
|
||||
centerLabel: string
|
||||
badge?: string
|
||||
}) {
|
||||
const total = slices.reduce((sum, s) => sum + s.count, 0)
|
||||
const chartConfig = slices.reduce<ChartConfig>((acc, slice) => {
|
||||
acc[slice.key] = { label: slice.label, color: slice.color }
|
||||
return acc
|
||||
}, {})
|
||||
const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count }))
|
||||
|
||||
return (
|
||||
<PanelCard title={title} description={description} className="h-full">
|
||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center">
|
||||
{total === 0 ? (
|
||||
<p className="text-muted-foreground w-full py-8 text-center text-sm">Нет данных</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative mx-auto size-36 shrink-0">
|
||||
<ChartContainer config={chartConfig} className="aspect-square size-36">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="share"
|
||||
nameKey="label"
|
||||
innerRadius={48}
|
||||
outerRadius={64}
|
||||
strokeWidth={2}
|
||||
stroke="var(--color-card)"
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.key} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-muted-foreground text-xs">{centerLabel}</span>
|
||||
<span className="text-lg font-semibold tabular-nums">{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-2">
|
||||
{slices.map((slice) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
<li key={slice.key} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: slice.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">{slice.label}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums">
|
||||
{slice.count} ({pct}%)
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{badge ? (
|
||||
<Badge variant="success-light" className="absolute top-4 right-4 hidden sm:flex">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
import { IconStack } from '@/components/reui/icon-stack'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@evobgp/ui/components/empty'
|
||||
|
||||
/** empty-state-11 pattern adapted to Card surface. */
|
||||
export function IllustratedEmptyState({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-muted min-h-[240px] p-0 shadow-none">
|
||||
<CardContent className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
|
||||
{action ? <div className="w-full self-end">{action}</div> : null}
|
||||
<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>
|
||||
<Icon strokeWidth={1.9} aria-hidden />
|
||||
</IconStack>
|
||||
</EmptyMedia>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
|
||||
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { DonutBreakdownCard } from './donut-breakdown-card'
|
||||
export { IllustratedEmptyState } from './illustrated-empty-state'
|
||||
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
|
||||
export { PanelCorners } from './panel-corners'
|
||||
export { ProjectsEmptyState } from './projects-empty-state'
|
||||
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
||||
import { PanelCorners } from '@/components/patterns/panel-corners'
|
||||
import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
|
||||
export type KpiSparklineMetric = {
|
||||
id: string
|
||||
title: string
|
||||
label: string
|
||||
value: string
|
||||
delta: string
|
||||
deltaVariant: ComponentProps<typeof Badge>['variant']
|
||||
detail: string
|
||||
tone: MetricTone
|
||||
sparkline: readonly number[]
|
||||
}
|
||||
|
||||
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="Тренд">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="miter"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** KPI card with sparkline (dashboard-5 / chart-15 pattern, Card surface). */
|
||||
export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
|
||||
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 tabular-nums">
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={metric.deltaVariant} radius="full" size="sm">
|
||||
{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,8 @@
|
||||
export type MetricTone = 'danger' | 'success' | 'warning' | 'info'
|
||||
|
||||
export const toneStyles: Record<MetricTone, { stroke: string }> = {
|
||||
danger: { stroke: 'var(--color-destructive)' },
|
||||
success: { stroke: 'var(--color-success)' },
|
||||
warning: { stroke: 'var(--color-warning)' },
|
||||
info: { stroke: 'var(--color-info)' },
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Corner accents from ReUI dashboard-5 block. */
|
||||
export function PanelCorners() {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="border-foreground/65 absolute top-0 left-0 size-2 border-t border-l"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="border-foreground/65 absolute right-0 bottom-0 size-2 border-r border-b"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Boxes, Plus } from 'lucide-react'
|
||||
|
||||
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
/** empty-state-3 pattern for first module. */
|
||||
export function ProjectsEmptyState() {
|
||||
return (
|
||||
<IllustratedEmptyState
|
||||
icon={Boxes}
|
||||
title="Создайте первый модуль"
|
||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||
action={
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Новый модуль
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Progress } from '@evobgp/ui/components/progress'
|
||||
|
||||
export type SegmentStat = {
|
||||
value: string | number
|
||||
label: string
|
||||
percent: number
|
||||
badge?: ReactNode
|
||||
}
|
||||
|
||||
/** stats-4 pattern: dual metrics + progress + segmented meter (Card surface). */
|
||||
export function SegmentedProgressCard({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
primary,
|
||||
secondary,
|
||||
segments = 30,
|
||||
footer,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
primary: SegmentStat
|
||||
secondary: SegmentStat
|
||||
segments?: number
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
const filled = Math.round((secondary.percent / 100) * segments)
|
||||
|
||||
return (
|
||||
<PanelCard title={title} description={description} actions={actions} className="h-full">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex items-stretch gap-x-6">
|
||||
<div className="flex flex-1 flex-col items-start gap-1">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<span className="text-foreground text-2xl font-bold tabular-nums">{primary.value}</span>
|
||||
{primary.badge}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm font-medium">{primary.label}</span>
|
||||
<div className="mt-1 w-full">
|
||||
<Progress value={primary.percent} className="**:data-[slot=progress-track]:h-2.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-muted-foreground/10 flex flex-1 flex-col items-start gap-1 border-s ps-6">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<span className="text-foreground text-2xl font-bold tabular-nums">{secondary.value}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm font-medium">{secondary.label}</span>
|
||||
<div className="mt-1 flex w-full gap-0.5">
|
||||
{Array.from({ length: segments }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-2.5 w-0.5 flex-1 rounded-md',
|
||||
i < filled ? 'bg-success' : 'bg-muted',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{footer ? <div className="text-muted-foreground text-xs">{footer}</div> : null}
|
||||
</div>
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface QueryStateProps<T> {
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyAction?: ReactNode
|
||||
emptyContent?: ReactNode
|
||||
onRetry?: () => void
|
||||
skeleton?: ReactNode
|
||||
children: (data: T) => ReactNode
|
||||
@@ -27,6 +28,7 @@ export function QueryState<T>({
|
||||
emptyTitle = 'Нет данных',
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
emptyContent,
|
||||
onRetry,
|
||||
skeleton,
|
||||
children,
|
||||
@@ -52,6 +54,7 @@ export function QueryState<T>({
|
||||
)
|
||||
}
|
||||
if (empty || data == null) {
|
||||
if (emptyContent) return <>{emptyContent}</>
|
||||
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
}
|
||||
return <>{children(data)}</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user