Compare commits

...
10 Commits
Author SHA1 Message Date
Denozordec 1639ba40f3 refactor: update dashboard components to integrate QuickActionGrid and enhance settings
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 33s
CI / web (push) Successful in 1m0s
CI / go (push) Successful in 56s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 4m14s
Removed the deprecated DashboardQuickLinkCard and replaced it with QuickActionGrid in the DashboardQuickLinks component for improved organization and user experience. Updated the AppearanceSettingsTab to include a toggle for displaying quick actions on the dashboard, enhancing user customization options. Adjusted the SystemMonitorPopover layout for better alignment and spacing. Updated documentation to reflect the new UI preferences for quick actions.
2026-07-17 20:34:25 +07:00
Denozordec 26a96bc824 refactor: update KPI components for improved layout and clarity
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 47s
CI / go (push) Successful in 52s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m21s
Revised the KpiStatGrid and KpiStatCardBody components to enhance the layout and presentation of KPI tiles. Updated the documentation to reflect the new horizontal compact hybrid design, ensuring better visual organization with the icon on the left and improved label and value alignment. Adjusted the skeleton loading state for a more cohesive user experience.
2026-07-17 18:37:00 +07:00
Denozordec 4fc5c96e63 refactor: enhance KPI components with variant support and improved layout
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 51s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m12s
Updated the KPI components to include a new KpiStatVariant type for better visual differentiation. Refactored the KpiStatCardBody and KpiStatGrid to utilize this variant, allowing for dynamic styling based on the KPI state. Improved the layout of the KpiStatGrid and its associated components for a more cohesive user experience, including adjustments to the Badge footer presentation.
2026-07-17 17:15:14 +07:00
Denozordec 3859983ae5 refactor: update UI components to utilize Frame for improved layout and consistency
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 1m7s
CI / go (push) Successful in 2m11s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m46s
Refactored multiple components, including PanelCard, SettingsCard, and DataGridShell, to replace Card with Frame for better organization and presentation. Enhanced the DataGridToolbar to support optional ReUI filters and improved the overall structure of the KPI stat grid. Updated .gitignore to include .env.local for local environment configurations, ensuring better management of environment variables.
2026-07-17 15:30:41 +07:00
Denozordec aa1779170a refactor: enhance settings and auth components for improved navigation and user feedback
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 54s
CI / go (push) Successful in 1m3s
CI / bird2 (push) Successful in 20s
CI / release (push) Successful in 4m29s
Updated the Settings and Auth components to include better navigation handling by adding search parameters for tab selection. Enhanced the Access component to utilize badges for status indicators, improving visual clarity. Refactored the Schedule component to streamline job status display with badges, ensuring a more cohesive user experience. Additionally, integrated client directives in UI components for better performance.
2026-07-09 23:39:10 +07:00
Denozordec bbcf9d76d9 refactor: enhance schedule components with improved filtering and layout
CI / changes (push) Successful in 11s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Successful in 51s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Updated the ScheduleAgendaPanel to include a job filter feature, allowing users to filter jobs by type (all, refresh, failed). Refactored the layout to integrate a new ScheduleCalendarView for better organization. Enhanced the ScheduleJobsGrid to conditionally display pagination based on the number of items. Additionally, modified the Schedule component to utilize the new ScheduleJobsCard for improved job display and loading states, ensuring a more cohesive user experience.
2026-07-09 23:10:20 +07:00
Denozordec 55eb2a6c89 refactor: enhance dashboard layout and settings components for improved user experience
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m7s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Updated the AnalyticsDashboardSkeleton to utilize a new grid layout for KPI tiles, increasing the number of displayed items and adjusting their size for better visibility. Modified the SettingsSettingField component to accept a ReactNode for the description, allowing for richer content. Refactored the Settings component to improve the organization of API token management and session status display, enhancing clarity and usability. Additionally, integrated new UI elements for theme selection and improved layout consistency across components.
2026-07-09 21:58:39 +07:00
Denozordec a3f3ffd672 refactor: integrate PanelCard and enhance dashboard components for improved layout
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m7s
Refactored multiple dashboard components to utilize the new PanelCard for better organization and presentation. Updated the DashboardFramePanel, DashboardQuickLinks, and DashboardOperationsBreakdown components to streamline layouts and enhance user experience. Removed deprecated components and improved loading states in various sections, ensuring a cohesive interface throughout the application.
2026-07-09 21:39:20 +07:00
Denozordec 1a142e68a9 refactor: update Dashboard components for improved layout and integration
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Successful in 59s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m20s
Refactored the DashboardQuickLinkCard to enhance its structure and presentation by integrating FramePanel. Updated the DashboardQuickLinks component to utilize Frame for better organization, including a header and description. Removed the previous DashboardFramePanel integration in the DashboardComponent to streamline the layout, ensuring a more cohesive user experience.
2026-07-09 21:15:04 +07:00
Denozordec 46e7b2aa6b chore: update BadgeTabs component to log additional layout metrics
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 1m0s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 22s
Added new layout metrics logging to the BadgeTabs component, enhancing debugging capabilities. This update includes additional timestamped entries for layout metrics, providing better insights into component behavior during runtime.
2026-07-09 21:06:33 +07:00
217 changed files with 22209 additions and 973 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"pid": 43636,
"pid": 51184,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1783570299043
"startedAt": 1784215907952
}
+5
View File
@@ -9,3 +9,8 @@
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152671}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222064}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222067}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605819320}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605827145}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605864391}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606471768}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606472923}
+8
View File
@@ -0,0 +1,8 @@
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613834659}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613834740}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613854903}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613854967}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613897698}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613897769}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614164222}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614181455}
+6 -3
View File
@@ -22,9 +22,12 @@ alwaysApply: false
|------|------|------------|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, OpsDashboard, SettingsShell |
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2).
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
## Правила
@@ -45,8 +48,8 @@ alwaysApply: false
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
*Проверка:* https://ui.shadcn.com/docs/components/form
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`.
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
*Проверка:* https://ui.shadcn.com/docs/components/sonner
+1
View File
@@ -21,6 +21,7 @@ Thumbs.db
# Env / secrets (local only)
.env
.env.local
.env.*
!.env.example
!.env.*.example
+3 -1
View File
@@ -71,4 +71,6 @@ pnpm --filter @evobgp/web run build
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
UI-задачи начинаются с MCP `plugin-shadcn-shadcn` (search → examples → add command), затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
**UI design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `apps/web/src/components/reui-kit/`.
UI-задачи начинаются с MCP `user-reui` (`surface: "frame"`) + `plugin-shadcn-shadcn`, затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
@@ -1,5 +1,5 @@
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
export type AnalyticsKpiItem = {
@@ -12,39 +12,42 @@ export type AnalyticsKpiItem = {
}
}
const TONE_CLASS = {
success: 'text-success',
warning: 'text-warning',
destructive: 'text-destructive',
muted: 'text-muted-foreground',
} as const
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
if (direction === 'up') return <TrendingUp className="size-3" />
if (direction === 'down') return <TrendingDown className="size-3" />
return <Minus className="size-3" />
function deltaBadgeVariant(
tone?: 'success' | 'warning' | 'destructive' | 'muted',
): 'success-light' | 'warning-light' | 'destructive-light' | 'outline' {
if (tone === 'success') return 'success-light'
if (tone === 'warning') return 'warning-light'
if (tone === 'destructive') return 'destructive-light'
return 'outline'
}
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
function toKpiItem(item: AnalyticsKpiItem): KpiStatItem {
return {
id: item.label,
value: item.value,
label: item.label,
footer: item.delta ? (
<Badge variant={deltaBadgeVariant(item.delta.tone)} size="sm">
{item.delta.label}
</Badge>
) : undefined,
}
}
/** Compact KPI row inside analytics panels (stats-12 embedded tiles). */
export function AnalyticsKpiRow({
items,
className,
}: {
items: AnalyticsKpiItem[]
className?: string
}) {
return (
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
{items.map((item) => (
<div key={item.label} className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
{item.delta ? (
<p
className={cn(
'flex items-center gap-1 text-xs',
TONE_CLASS[item.delta.tone ?? 'muted'],
)}
>
<DeltaIcon direction={item.delta.direction} />
{item.delta.label}
</p>
) : null}
</div>
))}
</div>
<KpiStatGrid
items={items.map(toKpiItem)}
embedded
className={cn(className)}
aria-label="Показатели"
/>
)
}
@@ -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>
)
}
+24 -60
View File
@@ -1,5 +1,4 @@
import type { ComponentProps, ReactNode } from 'react'
import { useEffect, useRef } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
@@ -36,67 +35,32 @@ export function BadgeTabs({
listClassName,
contentClassName,
}: BadgeTabsProps) {
const tabsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = tabsRef.current?.querySelector('[data-slot=tabs]')
if (!el || !(el instanceof HTMLElement)) return
const flexDirection = getComputedStyle(el).flexDirection
const listRect = el.querySelector('[data-slot=tabs-list]')?.getBoundingClientRect()
const panelRect = el.querySelector('[data-slot=tabs-content]')?.getBoundingClientRect()
// #region agent log
fetch('http://127.0.0.1:7311/ingest/6b35c3ae-1bcd-4c9c-81eb-f157c9347393', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'c10bcb' },
body: JSON.stringify({
sessionId: 'c10bcb',
runId: 'post-fix',
hypothesisId: 'H1',
location: 'badge-tabs.tsx:useEffect',
message: 'BadgeTabs layout metrics',
data: {
flexDirection,
orientation: el.getAttribute('data-orientation'),
listLeft: listRect?.left,
panelLeft: panelRect?.left,
stacked: flexDirection === 'column',
},
timestamp: Date.now(),
}),
}).catch(() => {})
// #endregion
}, [value, defaultValue])
return (
<div ref={tabsRef} className={cn('w-full', className)}>
<Tabs
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
className="w-full"
<Tabs
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
orientation="horizontal"
className={cn('w-full', className)}
>
<TabsList
variant="line"
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
>
<TabsList
variant="line"
className={cn(
'mb-4 w-full justify-start gap-6',
listClassName,
)}
>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2">
{item.icon}
{item.label}
{item.count !== undefined ? (
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
{item.count}
</Badge>
) : null}
</TabsTrigger>
))}
</TabsList>
<div className={cn('w-full min-w-0', contentClassName)}>{children}</div>
</Tabs>
</div>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2">
{item.icon}
{item.label}
{item.count !== undefined ? (
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
{item.count}
</Badge>
) : null}
</TabsTrigger>
))}
</TabsList>
<div className={cn('w-full min-w-0', contentClassName)}>{children}</div>
</Tabs>
)
}
@@ -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&apos;re all caught up!
</span>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,61 @@
"use client"
import { cn } from "@evobgp/ui/lib/utils"
import { useSidebar } from "@evobgp/ui/components/sidebar"
export function SidebarRailToggle() {
const { state, toggleSidebar } = useSidebar()
const isExpanded = state === "expanded"
return (
<button
type="button"
aria-label={isExpanded ? "Collapse sidebar" : "Expand sidebar"}
onClick={toggleSidebar}
style={{
left: isExpanded ? "var(--sidebar-width)" : "var(--sidebar-width-icon)",
}}
className={cn(
"group/rail fixed z-30 flex h-12 w-7 cursor-pointer items-center pl-2 outline-none",
"top-[calc(var(--header-height)+50%)] -translate-y-1/2",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-1",
"focus-visible:rounded-sm",
"transition-[left] duration-200 ease-linear"
)}
>
<span className="flex flex-col items-center">
<span
aria-hidden="true"
className={cn(
"bg-foreground/40 block h-2 w-0.5 rounded-t-full",
"origin-bottom transition-all duration-100 ease-linear",
isExpanded
? "group-hover/rail:bg-foreground/60 group-hover/rail:rotate-40"
: "group-hover/rail:bg-foreground/60 group-hover/rail:-rotate-40"
)}
/>
<span
aria-hidden="true"
className={cn(
"bg-foreground/40 block h-2 w-0.5 rounded-b-full",
"origin-top transition-all duration-100 ease-linear",
isExpanded
? "group-hover/rail:bg-foreground/60 group-hover/rail:-rotate-40"
: "group-hover/rail:bg-foreground/60 group-hover/rail:rotate-40"
)}
/>
</span>
<span
className={cn(
"border-border bg-foreground text-background absolute left-full -ml-2 border px-2 py-0.5 text-[11px] font-medium whitespace-nowrap shadow-xs shadow-black/5",
"rounded-md",
"pointer-events-none -translate-x-0.5 opacity-0 transition-all duration-200 ease-out",
"group-hover/rail:translate-x-0 group-hover/rail:opacity-100"
)}
>
{isExpanded ? "Collapse" : "Expand"}
</span>
</button>
)
}
@@ -0,0 +1,441 @@
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type CSSProperties,
} from "react"
import { cn } from "@evobgp/ui/lib/utils"
import { Badge } from "@evobgp/ui/components/badge"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { Progress } from "@evobgp/ui/components/progress"
import {
AGENTS,
RESOURCE_METRICS,
type Agent,
type ResourceMetric,
} from "./data"
import { ActivityIcon, ChevronRightIcon } from "lucide-react"
const HISTORY_LENGTH = 20
const UPDATE_INTERVAL_MS = 1000
const SPIKE_COLOR = "var(--color-red-500)"
const STATS_RANDOM_SEED = 1337
const DEMO_REFERENCE_DATE = new Date("2026-06-12T09:24:00")
const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
// Seeded PRNG (mulberry32) keeps the demo metrics deterministic across
// renders and reloads while preserving the organic feel of live jitter.
function createSeededRandom(seed: number) {
let state = seed
return function nextRandom() {
state = (state + 0x6d2b79f5) | 0
let t = Math.imul(state ^ (state >>> 15), state | 1)
t = (t + Math.imul(t ^ (t >>> 7), t | 61)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const nextRandom = createSeededRandom(STATS_RANDOM_SEED)
function randomInRange(min: number, max: number) {
return Math.round((min + nextRandom() * (max - min)) * 10) / 10
}
function seedHistory(min: number, max: number): number[] {
return Array.from({ length: HISTORY_LENGTH }, () => randomInRange(min, max))
}
function smoothPath(pts: { x: number; y: number }[]): string {
if (pts.length === 0) return ""
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`
let d = `M ${pts[0].x} ${pts[0].y}`
for (let i = 1; i < pts.length; i++) {
const prev = pts[i - 1]
const curr = pts[i]
const cx = (prev.x + curr.x) / 2
d += ` C ${cx} ${prev.y}, ${cx} ${curr.y}, ${curr.x} ${curr.y}`
}
return d
}
// ── Sparkline Area (full-width responsive) ──
function SparklineArea({
data,
max,
color,
spikeThreshold,
height = 36,
}: {
data: number[]
max: number
color: string
spikeThreshold: number
height?: number
}) {
const gradientId = useId()
const W = 100
const H = height
const currentValue = data[data.length - 1] ?? 0
const hasSpike = currentValue > spikeThreshold
const activeColor = hasSpike ? SPIKE_COLOR : color
const pts = data.map((value, i) => ({
x: data.length < 2 ? 0 : (i / (data.length - 1)) * W,
y: H - (Math.min(value, max) / max) * (H - 2),
}))
const linePath = smoothPath(pts)
const areaPath = pts.length > 1 ? `${linePath} L ${W} ${H} L 0 ${H} Z` : ""
return (
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
aria-hidden="true"
className="w-full"
style={{ height: `${H}px` }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={activeColor} stopOpacity={0.35} />
<stop offset="100%" stopColor={activeColor} stopOpacity={0.02} />
</linearGradient>
</defs>
{areaPath && <path d={areaPath} fill={`url(#${gradientId})`} />}
<path
d={linePath}
fill="none"
stroke={activeColor}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
// ── Mini Sparkline (for agent rows) ──
function MiniSparkline({ data, color }: { data: number[]; color: string }) {
const gradientId = useId()
const W = 40
const H = 14
const dataMax = Math.max(...data, 1)
const pts = data.map((value, i) => ({
x: data.length < 2 ? 0 : (i / (data.length - 1)) * W,
y: H - (value / dataMax) * (H - 1),
}))
const linePath = smoothPath(pts)
const areaPath = pts.length > 1 ? `${linePath} L ${W} ${H} L 0 ${H} Z` : ""
return (
<svg
viewBox={`0 0 ${W} ${H}`}
width={W}
height={H}
aria-hidden="true"
className="shrink-0"
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
{areaPath && <path d={areaPath} fill={`url(#${gradientId})`} />}
<path
d={linePath}
fill="none"
stroke={color}
strokeWidth={1}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
// ── Resource Card (grid cell) ──
function ResourceCard({
metric,
history,
}: {
metric: ResourceMetric
history: number[]
}) {
const current = history[history.length - 1] ?? 0
const max = metric.id === "memory" ? 16 : 100
const isHigh = current > metric.spikeThreshold
return (
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<Item
className="flex size-5 shrink-0 items-center justify-center p-0 transition-colors duration-300"
style={{
backgroundColor: `${isHigh ? SPIKE_COLOR : metric.color}18`,
}}
>
<ItemMedia
variant="icon"
className="size-auto"
style={{ color: isHigh ? SPIKE_COLOR : metric.color }}
>
{metric.icon}
</ItemMedia>
</Item>
<span className="text-muted-foreground truncate text-[11px]">
{metric.label}
</span>
</div>
<span
className="shrink-0 text-xs font-semibold tabular-nums transition-colors duration-300"
style={{ color: isHigh ? SPIKE_COLOR : metric.color }}
>
{current}
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">
{metric.unit}
</span>
</span>
</div>
<SparklineArea
data={history}
max={max}
color={metric.color}
spikeThreshold={metric.spikeThreshold}
/>
</div>
)
}
// ── Agent Memory Row ──
type AgentWithHistory = Agent & { history: number[] }
function AgentMemoryRow({ agent }: { agent: AgentWithHistory }) {
const barPercent = Math.min(100, (agent.memoryMb / 512) * 100)
return (
<div className="flex items-center gap-2 px-3 py-1.5">
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: agent.color }}
aria-hidden="true"
/>
<span className="text-muted-foreground min-w-0 flex-1 truncate text-xs">
{agent.name}
</span>
<Progress
value={barPercent}
className="w-12 **:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
style={{ "--bar-color": agent.color } as CSSProperties}
/>
<span className="text-muted-foreground w-10 text-right text-[10px] tabular-nums">
{agent.memoryMb}MB
</span>
</div>
)
}
// ── Status Badge ──
function StatusBadge({ spiking }: { spiking: boolean }) {
return (
<Badge
className={cn(
"h-4 border-0 px-1.5 text-[10px] font-medium",
spiking
? "bg-destructive/10 text-destructive dark:bg-destructive/20"
: "bg-success/10 text-success dark:bg-success/20"
)}
>
{spiking ? "Alert" : "Normal"}
</Badge>
)
}
// ── System Stats ──
export function SystemStats() {
const [histories, setHistories] = useState<Record<string, number[]>>(() => {
const initial: Record<string, number[]> = {}
for (const m of RESOURCE_METRICS) {
initial[m.id] = seedHistory(m.seedRange[0], m.seedRange[1])
}
return initial
})
const [agentData, setAgentData] = useState<AgentWithHistory[]>(() =>
AGENTS.map((a) => ({
...a,
history: Array.from({ length: 10 }, () =>
Math.max(32, Math.round(a.memoryMb + (nextRandom() - 0.5) * 80))
),
}))
)
const [showAgents, setShowAgents] = useState(true)
const [now, setNow] = useState(DEMO_REFERENCE_DATE)
const intervalRef = useRef<ReturnType<typeof setInterval>>(null)
// Derived - no extra state needed
const spiking = RESOURCE_METRICS.some((m) => {
const history = histories[m.id] ?? []
const current = history[history.length - 1] ?? 0
return current > m.spikeThreshold
})
const tick = useCallback(() => {
setHistories((prev) => {
const next: Record<string, number[]> = {}
for (const m of RESOURCE_METRICS) {
const old = prev[m.id] ?? []
const jitter = randomInRange(
m.seedRange[0] * 0.8,
m.seedRange[1] * 1.15
)
const clamped =
m.id === "memory"
? Math.min(16, Math.max(0, jitter))
: Math.min(100, Math.max(0, jitter))
next[m.id] = [...old.slice(-(HISTORY_LENGTH - 1)), clamped]
}
return next
})
setAgentData((prev) =>
prev.map((a) => {
const newMb = Math.max(
32,
Math.round(a.memoryMb + (nextRandom() - 0.5) * 20)
)
return {
...a,
memoryMb: newMb,
history: [...a.history.slice(-9), newMb],
}
})
)
setNow((prev) => new Date(prev.getTime() + UPDATE_INTERVAL_MS))
}, [])
useEffect(() => {
intervalRef.current = setInterval(tick, UPDATE_INTERVAL_MS)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [tick])
return (
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label="System monitor"
className={cn(
"relative inline-flex h-7 items-center gap-1.5 px-2 transition-colors outline-none",
"rounded-md",
"border-border hover:bg-accent focus-visible:ring-ring border focus-visible:ring-2"
)}
/>
}
>
{/* Icon with beep ring on spike */}
<span className="relative flex size-3.5 items-center justify-center">
<ActivityIcon className={cn(
"size-3.5 transition-colors duration-300",
spiking ? "text-destructive" : "text-muted-foreground"
)} aria-hidden="true" />
{spiking && (
<span
className="bg-destructive/25 absolute inset-0 animate-ping rounded-full"
aria-hidden="true"
/>
)}
</span>
<span className="text-foreground text-xs font-medium">System</span>
<StatusBadge spiking={spiking} />
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={8}
className="w-80 gap-0! space-y-0! p-0!"
>
{/* Header */}
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">
System Monitor
</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{TIME_FORMATTER.format(now)}
</span>
</div>
{/* 2×2 Resource Grid */}
<div className="grid grid-cols-2">
{RESOURCE_METRICS.map((metric, i) => (
<div
key={metric.id}
className={cn(
i % 2 === 1 && "border-border border-l",
i >= 2 && "border-border border-t"
)}
>
<ResourceCard
metric={metric}
history={histories[metric.id] ?? []}
/>
</div>
))}
</div>
{/* Per-Agent Memory */}
<div className="border-border border-t">
<button
type="button"
onClick={() => setShowAgents((prev) => !prev)}
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors"
aria-expanded={showAgents}
aria-controls="agent-memory-panel"
>
<ChevronRightIcon className={cn(
"size-3 shrink-0 transition-transform duration-200",
showAgents && "rotate-90"
)} aria-hidden="true" />
Per-Agent Memory
</button>
{showAgents && (
<div id="agent-memory-panel" className="pb-2">
{agentData.map((agent) => (
<AgentMemoryRow key={agent.id} agent={agent} />
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,5 @@
import { AppShell } from "./components/app-shell"
export function Page() {
return <AppShell />
}
@@ -0,0 +1,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
@@ -0,0 +1,212 @@
import { type ComponentProps } from "react"
import { Badge } from "@/components/reui/badge"
import { Rating } from "@/components/reui/rating"
import { cn } from "@evobgp/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
type CheckStatus,
type IReleaseCheck,
type IReleaseReview,
} from "./data"
const MAX_VISIBLE_CHECK_CARDS = 3
const checkStatusConfig: Record<
CheckStatus,
{
variant: ComponentProps<typeof Badge>["variant"]
dotClassName: string
}
> = {
Open: {
variant: "invert-light",
dotClassName: "bg-zinc-400 dark:bg-zinc-500",
},
Done: {
variant: "success-light",
dotClassName: "bg-emerald-500 dark:bg-emerald-400",
},
Flagged: {
variant: "destructive-light",
dotClassName: "bg-rose-500 dark:bg-rose-400",
},
}
function DotSeparator() {
return (
<span
className="size-1 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500"
aria-hidden="true"
/>
)
}
function initials(name: string) {
return name
.split(" ")
.map((part) => part[0])
.join("")
}
function coverageRingColor(coverage: number) {
if (coverage >= 85) return "text-emerald-500"
if (coverage >= 60) return "text-amber-500"
return "text-rose-500"
}
function CheckStatusBadge({ status }: { status: CheckStatus }) {
return (
<Badge variant={checkStatusConfig[status].variant}>
<span
className={`${checkStatusConfig[status].dotClassName} size-1.5 shrink-0 rounded-full`}
aria-hidden="true"
/>
{status}
</Badge>
)
}
function CoverageRing({
coverage,
className,
}: {
coverage: number
className?: string
}) {
const radius = 8
const circumference = 2 * Math.PI * radius
const dashOffset = circumference - (coverage / 100) * circumference
return (
<div className={cn("flex items-center gap-1.5", className)}>
<svg
viewBox="0 0 24 24"
className={cn("size-4.5 shrink-0", coverageRingColor(coverage))}
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r={radius}
fill="none"
className="stroke-border"
strokeWidth="2.5"
/>
<circle
cx="12"
cy="12"
r={radius}
fill="none"
className="stroke-current"
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
transform="rotate(-90 12 12)"
/>
</svg>
<span className="text-muted-foreground text-xs tabular-nums">
{coverage}%
</span>
</div>
)
}
function ReleaseCheckCard({ check }: { check: IReleaseCheck }) {
return (
<Card className="h-full w-full min-w-0 gap-0 p-0 shadow-none! sm:w-[22rem]">
{/* Content */}
<CardContent className="flex flex-col gap-2.5 p-3">
<div className="flex items-start gap-3">
<CheckStatusBadge status={check.status} />
</div>
<div className="flex min-w-0 flex-col gap-1">
<h4 className="text-foreground line-clamp-2 text-sm leading-5 font-medium">
{check.title}
</h4>
<div className="text-muted-foreground flex min-w-0 flex-col gap-1 text-xs sm:flex-row sm:items-center sm:gap-2">
<div className="flex min-w-0 items-center gap-2">
<Avatar className="size-6 shrink-0">
{check.reviewer?.avatar ? (
<AvatarImage
src={check.reviewer.avatar}
alt={check.reviewer.name}
/>
) : null}
<AvatarFallback>
{check.reviewer ? initials(check.reviewer.name) : "--"}
</AvatarFallback>
</Avatar>
<span className="truncate">
{check.reviewer?.name ?? "Reviewer pending"}
</span>
</div>
<span className="hidden sm:inline-flex">
<DotSeparator />
</span>
<span className="shrink-0">{check.dueLabel}</span>
</div>
</div>
<div className="flex items-center justify-between gap-3 pt-3.5">
<div className="flex items-center gap-1.5">
<Rating rating={check.confidence} size="sm" showValue={false} />
<span className="text-muted-foreground text-xs tabular-nums">
{check.confidence.toFixed(1)}
</span>
</div>
<CoverageRing coverage={check.coverage} className="ml-auto" />
</div>
</CardContent>
</Card>
)
}
export function ReleaseCheckRail({ review }: { review: IReleaseReview }) {
const checks = review.checks ?? []
if (checks.length === 0) return null
const visibleChecks = checks.slice(0, MAX_VISIBLE_CHECK_CARDS)
const hiddenCheckCount = checks.length - visibleChecks.length
const openChecks = checks.filter((check) => check.status !== "Done").length
return (
<div>
<div className="flex flex-wrap items-center gap-2 px-5 pt-3 pb-2">
<span className="text-foreground text-xs font-medium">Checklist</span>
<DotSeparator />
<span className="text-muted-foreground text-xs">
{hiddenCheckCount > 0
? `${visibleChecks.length} of ${checks.length} items`
: `${checks.length} items`}
</span>
<DotSeparator />
<span className="text-muted-foreground text-xs">{openChecks} open</span>
{hiddenCheckCount > 0 ? (
<>
<DotSeparator />
<span className="text-muted-foreground text-xs">
+{hiddenCheckCount} more
</span>
</>
) : null}
</div>
{/* Cards */}
<div className="flex w-full max-w-[70rem] flex-wrap gap-3 px-5 pt-1 pb-4">
{visibleChecks.map((check) => (
<ReleaseCheckCard key={check.id} check={check} />
))}
</div>
</div>
)
}
@@ -0,0 +1,60 @@
import { type ReactNode } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@evobgp/ui/components/card"
interface ReleaseReviewCardProps {
title: string
description: ReactNode
action?: ReactNode
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
footerClassName?: string
}
export function ReleaseReviewCard({
title,
description,
action,
children,
footer,
className,
contentClassName,
footerClassName,
}: ReleaseReviewCardProps) {
return (
<Card className={cn("w-full gap-0 p-0", className)}>
{/* Header */}
<CardHeader className="gap-1 border-b px-5 py-3.5">
<CardTitle>{title}</CardTitle>
<CardDescription className="text-xs leading-4">
{description}
</CardDescription>
{action ? (
<CardAction className="self-center">{action}</CardAction>
) : null}
</CardHeader>
{/* Content */}
<CardContent className={cn("p-0", contentClassName)}>
{children}
</CardContent>
{footer ? (
<CardFooter className={cn("border-t px-5 py-2.5", footerClassName)}>
{footer}
</CardFooter>
) : null}
</Card>
)
}
@@ -0,0 +1,15 @@
import { ReleaseReviewGridView } from "./components/data-grid-view"
export function Page() {
return (
<main
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Release review data grid
</h1>
<ReleaseReviewGridView />
</main>
)
}
@@ -0,0 +1,426 @@
import { memo, useState } from "react"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { Badge } from "@/components/reui/badge"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import {
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from "@/components/reui/data-grid/data-grid-table"
import { Row, type ColumnDef } from "@tanstack/react-table"
import { toast } from "sonner"
import { cn } from "@evobgp/ui/lib/utils"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@evobgp/ui/components/alert-dialog"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import { Skeleton } from "@evobgp/ui/components/skeleton"
import { ITransaction, TxChannel, TxStatus } from "./data"
import { MoreHorizontalIcon, EyeIcon, CopyIcon, DownloadIcon, TriangleAlertIcon, ArrowRightIcon } from "lucide-react"
// ── Formatters ──
function formatAmount(amount: number, currency: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
minimumFractionDigits: 2,
}).format(Math.abs(amount))
}
function formatDate(iso: string) {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(iso))
}
// ── Status (aligned with data-grid-1 badge language) ──
export const StatusBadge = memo(function StatusBadge({
status,
}: {
status: TxStatus
}) {
if (status === "completed")
return <Badge variant="success-outline">Completed</Badge>
if (status === "failed")
return <Badge variant="destructive-outline">Failed</Badge>
if (status === "pending")
return <Badge variant="warning-outline">Pending</Badge>
return <Badge variant="info-outline">Cancelled</Badge>
})
// ── Channel (origin of charge - replaces "method" duplicate logos) ──
const channelLabel: Record<TxChannel, string> = {
checkout: "Checkout",
api: "API",
invoice: "Invoice",
dashboard: "Dashboard",
}
const channelVariant: Record<
TxChannel,
"success-light" | "info-light" | "warning-light"
> = {
checkout: "success-light",
api: "info-light",
invoice: "warning-light",
dashboard: "success-light",
}
export const ChannelBadge = memo(function ChannelBadge({
channel,
}: {
channel: TxChannel
}) {
return <Badge variant="secondary">{channelLabel[channel]}</Badge>
})
// ── Actions ──
export function ActionsCell({ row }: { row: Row<ITransaction> }) {
const { copyToClipboard } = useCopyToClipboard()
const [disputeOpen, setDisputeOpen] = useState(false)
const handleCopyRef = () => {
copyToClipboard(row.original.reference)
toast.success("Reference copied", { description: row.original.reference })
}
const handleDisputeConfirm = () => {
setDisputeOpen(false)
toast.message("Dispute opened", {
description: `${row.original.reference}. Connect your payments API (demo).`,
})
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label="Transaction actions"
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" className="min-w-40">
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() =>
toast.info("Transaction details", {
description: "Demo. Navigate to your detail view.",
})
}
>
<EyeIcon className="size-4" aria-hidden="true" />
View Details
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyRef}>
<CopyIcon className="size-4" aria-hidden="true" />
Copy Reference
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.success("Receipt", {
description: "Demo. Download from your storage.",
})
}
>
<DownloadIcon className="size-4" aria-hidden="true" />
Receipt
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDisputeOpen(true)}
>
<TriangleAlertIcon className="size-4" aria-hidden="true" />
Dispute
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={disputeOpen} onOpenChange={setDisputeOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Open a dispute?</AlertDialogTitle>
<AlertDialogDescription>
This starts a dispute for{""}
<span className="text-foreground font-mono text-xs">
{row.original.reference}
</span>
. In production this is irreversible until resolved. Wire to your
payment provider.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDisputeConfirm}
>
Start dispute
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export const columns: ColumnDef<ITransaction>[] = [
{
accessorKey: "id",
id: "id",
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
enableSorting: false,
size: 35,
enableResizing: false,
enableHiding: false,
meta: {
skeleton: <Skeleton className="size-4 rounded" />,
headerClassName:
"[--data-grid-header-cell-ps:var(--frame-panel-header-px)]",
cellClassName: "[--data-grid-body-cell-ps:var(--frame-panel-px)]",
},
},
{
accessorKey: "reference",
id: "reference",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<div className="flex min-w-0 flex-col gap-0.5">
<span className="group/ref text-foreground hover:text-primary inline-flex min-w-0 cursor-pointer items-center gap-1 truncate font-mono text-xs font-medium transition-colors">
<span className="group-hover/ref:border-primary border-b border-transparent py-0.25 transition-colors">
{row.original.reference}
</span>
<ArrowRightIcon className="size-3 shrink-0 -translate-x-1 opacity-0 transition-all group-hover/ref:translate-x-0 group-hover/ref:opacity-100" aria-hidden="true" />
</span>
<span className="text-muted-foreground text-xs">
{formatDate(row.original.date)}
</span>
</div>
),
size: 150,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Reference",
skeleton: (
<div className="flex min-w-0 flex-col gap-0.5">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3 w-20" />
</div>
),
},
},
{
accessorKey: "description",
id: "description",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<div className="tems-center flex gap-2.5">
<Item className="w-auto shrink-0 border-0 p-0 [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto">
{row.original.merchantLogo}
</ItemMedia>
</Item>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground truncate text-sm font-medium">
{row.original.description}
</span>
<span className="text-muted-foreground truncate text-xs">
{row.original.merchant} · {row.original.category}
</span>
</div>
</div>
),
minSize: 150,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Description",
skeleton: (
<div className="flex items-center gap-2.5">
<Skeleton className="size-5 shrink-0 rounded" />
<div className="flex min-w-0 flex-col gap-0.5">
<Skeleton className="h-3.5 w-36" />
<Skeleton className="h-3 w-28" />
</div>
</div>
),
},
},
{
accessorKey: "country",
id: "region",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<img
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
alt=""
width={16}
height={16}
loading="lazy"
decoding="async"
className="size-4 shrink-0 rounded-full object-cover"
/>
<span className="text-foreground line-clamp-1 text-sm font-medium">
{row.original.country}
</span>
</div>
{row.original.timezone && (
<span className="text-muted-foreground line-clamp-1 text-xs tabular-nums">
{row.original.timezone}
</span>
)}
</div>
),
size: 170,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Region",
skeleton: (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<Skeleton className="size-4 shrink-0 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-3 w-28" />
</div>
),
},
},
{
accessorKey: "channel",
id: "channel",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ChannelBadge channel={row.original.channel} />,
size: 120,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Channel",
skeleton: <Skeleton className="h-6 w-20 rounded-full" />,
},
},
{
accessorKey: "amount",
id: "amount",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => {
const { amount, currency } = row.original
const inflow = amount > 0
return (
<span
className={cn("font-semibold tabular-nums", inflow && "text-success")}
>
{inflow ? "+" : ""}
{formatAmount(amount, currency)}
</span>
)
},
size: 100,
enableSorting: true,
enableHiding: true,
meta: {
headerTitle: "Amount",
skeleton: <Skeleton className="h-4 w-20" />,
},
},
{
accessorKey: "account",
id: "account",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<span className="text-foreground font-mono text-xs">
{row.original.account}
</span>
),
size: 160,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Account",
skeleton: <Skeleton className="h-4 w-24" />,
},
},
{
accessorKey: "status",
id: "status",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
size: 90,
enableSorting: true,
enableHiding: true,
enableResizing: false,
meta: {
headerTitle: "Status",
skeleton: <Skeleton className="h-6 w-20 rounded-full" />,
},
},
{
id: "actions",
header: "",
cell: ({ row }) => <ActionsCell row={row} />,
size: 50,
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: {
skeleton: <Skeleton className="size-7 rounded" />,
headerClassName:
"[--data-grid-header-cell-pe:var(--frame-panel-header-px)]",
cellClassName: "[--data-grid-body-cell-pe:var(--frame-panel-px)]",
},
},
]
@@ -0,0 +1,597 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { DataGrid } from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
ColumnSizingState,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { Button } from "@evobgp/ui/components/button"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import { Separator } from "@evobgp/ui/components/separator"
import { TooltipProvider } from "@evobgp/ui/components/tooltip"
import { ChannelBadge, columns, StatusBadge } from "./columns"
import {
TRANSACTIONS,
type ITransaction,
type TxChannel,
type TxStatus,
} from "./data"
import { HashIcon, FileTextIcon, Building2Icon, FolderIcon, GlobeIcon, RouteIcon, CircleDotIcon, PlusIcon, FilterIcon, FunnelXIcon } from "lucide-react"
// ── Helpers ──
function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (
values.every((value) => typeof value === "string" && value.trim() === "")
)
return false
if (values.every((value) => value === null || value === undefined))
return false
if (values.every((value) => Array.isArray(value) && value.length === 0))
return false
return true
})
}
/** Stable key for comparing whether applied (active) filters meaningfully changed. */
function serializeActiveFiltersKey(active: Filter[]) {
return JSON.stringify(
active.map((f) => ({
field: f.field,
operator: f.operator,
values: f.values,
}))
)
}
function filterFieldValue(item: ITransaction, field: string): unknown {
if (field === "region") {
return [item.country, item.timezone].filter(Boolean).join(" ")
}
return item[field as keyof ITransaction]
}
function applyFiltersToData(
data: ITransaction[],
filters: Filter[]
): ITransaction[] {
const active = getActiveFilters(filters)
let result = [...data]
active.forEach((filter) => {
const { field, operator, values } = filter
result = result.filter((item) => {
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? raw : ""
switch (operator) {
case "is":
return values.includes(fieldValue)
case "is_not":
return !values.includes(fieldValue)
case "is_any_of":
return values.some((v) => fieldValue === v)
case "is_not_any_of":
return !values.some((v) => fieldValue === v)
case "contains": {
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase())
)
}
case "not_contains":
return !values.some((v) =>
String(fieldValue).toLowerCase().includes(String(v).toLowerCase())
)
case "starts_with":
return values.some((v) =>
String(fieldValue).toLowerCase().startsWith(String(v).toLowerCase())
)
case "ends_with":
return values.some((v) =>
String(fieldValue).toLowerCase().endsWith(String(v).toLowerCase())
)
case "equals":
return fieldValue === values[0]
case "not_equals":
return fieldValue !== values[0]
case "greater_than":
return Number(fieldValue) > Number(values[0])
case "less_than":
return Number(fieldValue) < Number(values[0])
case "greater_than_or_equal":
return Number(fieldValue) >= Number(values[0])
case "less_than_or_equal":
return Number(fieldValue) <= Number(values[0])
case "between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) >= min && Number(fieldValue) <= max
}
return true
case "not_between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) < min || Number(fieldValue) > max
}
return true
case "empty":
return fieldValue === "" || fieldValue == null
case "not_empty":
return fieldValue !== "" && fieldValue != null
default:
return true
}
})
})
return result
}
// ── Filter field config ──
const CATEGORY_OPTIONS = [
"Payments",
"Infrastructure",
"AI / ML",
"Automation",
"Design",
"Documentation",
"Backend",
"Developer tools",
]
const STATUS_OPTIONS: { value: TxStatus; label: string }[] = [
{ value: "completed", label: "Completed" },
{ value: "pending", label: "Pending" },
{ value: "failed", label: "Failed" },
{ value: "cancelled", label: "Cancelled" },
]
const CHANNEL_OPTIONS: { value: TxChannel; label: string }[] = [
{ value: "checkout", label: "Checkout" },
{ value: "api", label: "API" },
{ value: "invoice", label: "Invoice" },
{ value: "dashboard", label: "Dashboard" },
]
const MERCHANT_FILTER_OPTIONS = Array.from(
new Map(
TRANSACTIONS.map((transaction) => [
transaction.merchant,
{
value: transaction.merchant,
label: transaction.merchant,
icon: (
<Item
render={<span />}
className="w-auto shrink-0 border-0 p-0 [&_svg]:size-4"
>
<ItemMedia variant="icon" className="size-auto">
{transaction.merchantLogo}
</ItemMedia>
</Item>
),
},
])
).values()
)
function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return "Select..."
if (values.length > 1) return `${values.length} selected`
return null
}
const filterFields: FilterFieldConfig[] = [
{
key: "reference",
label: "Reference",
icon: (
<HashIcon className="size-3.5" aria-hidden />
),
type: "text",
className: "w-44",
placeholder: "Search...",
},
{
key: "description",
label: "Description",
icon: (
<FileTextIcon className="size-3.5" aria-hidden />
),
type: "text",
className: "w-52",
placeholder: "Search...",
},
{
key: "merchant",
label: "Merchant",
icon: (
<Building2Icon className="size-3.5" aria-hidden />
),
type: "select",
searchable: true,
className: "w-[200px]",
options: MERCHANT_FILTER_OPTIONS,
customValueRenderer: (values, options) => {
const state = renderSelectedCount(values)
if (state) return state
const option = options.find((item) => item.value === values[0])
if (!option) return String(values[0])
return (
<div className="flex items-center gap-2">
{option.icon}
<span className="truncate">{option.label}</span>
</div>
)
},
},
{
key: "category",
label: "Category",
icon: (
<FolderIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: true,
className: "w-[200px]",
options: CATEGORY_OPTIONS.map((category) => ({
value: category,
label: category,
})),
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return <Badge variant="outline">{String(values[0])}</Badge>
},
},
{
key: "region",
label: "Region",
icon: (
<GlobeIcon className="size-3.5" aria-hidden />
),
type: "text",
className: "w-52",
placeholder: "Search...",
},
{
key: "channel",
label: "Channel",
icon: (
<RouteIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[156px]",
options: CHANNEL_OPTIONS,
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return <ChannelBadge channel={values[0] as TxChannel} />
},
},
{
key: "status",
label: "Status",
icon: (
<CircleDotIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[156px]",
options: STATUS_OPTIONS,
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return <StatusBadge status={values[0] as TxStatus} />
},
},
]
/** Single default row: Reference | contains | empty - inactive until user types (see getActiveFilters). */
function createDefaultTransactionFilters(): Filter[] {
return [createFilter("reference", "contains", [""])]
}
const DESCRIPTION_COLUMN_ID = "description"
const DESCRIPTION_COLUMN_DEFAULT_SIZE = 300
function getAutoDescriptionColumnSize(
containerWidth: number,
columnVisibility: VisibilityState
) {
const occupiedWidth = columns.reduce((total, column) => {
if (
!column.id ||
column.id === DESCRIPTION_COLUMN_ID ||
columnVisibility[column.id] === false
) {
return total
}
return total + (typeof column.size === "number" ? column.size : 0)
}, 0)
return Math.max(
DESCRIPTION_COLUMN_DEFAULT_SIZE,
Math.round(containerWidth - occupiedWidth)
)
}
// ── Main component ──
export function DataGridView() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "reference", desc: true },
])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
account: false,
})
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({})
const [filters, setFilters] = useState<Filter[]>(
createDefaultTransactionFilters
)
const [isLoading, setIsLoading] = useState(false)
const [filteredData, setFilteredData] = useState<ITransaction[]>(TRANSACTIONS)
const [gridWidth, setGridWidth] = useState(0)
const isInitialLoad = useRef(true)
const gridWidthRef = useRef<HTMLDivElement>(null)
const hasManualColumnSizing = useRef(false)
const lastAppliedActiveKey = useRef<string>(
serializeActiveFiltersKey(
getActiveFilters(createDefaultTransactionFilters())
)
)
const applyFilters = useCallback((newFilters: Filter[]) => {
return applyFiltersToData(TRANSACTIONS, newFilters)
}, [])
const simulateAsyncFiltering = useCallback(
async (newFilters: Filter[]) => {
setIsLoading(true)
await new Promise((resolve) => setTimeout(resolve, 400))
setFilteredData(applyFilters(newFilters))
setIsLoading(false)
},
[applyFilters]
)
const handleFiltersChange = useCallback(
(newFilters: Filter[]) => {
setFilters(newFilters)
const newActive = getActiveFilters(newFilters)
const nextKey = serializeActiveFiltersKey(newActive)
if (nextKey === lastAppliedActiveKey.current) return
lastAppliedActiveKey.current = nextKey
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
simulateAsyncFiltering(newFilters)
},
[simulateAsyncFiltering]
)
useEffect(() => {
if (isInitialLoad.current) {
setFilteredData(applyFilters(filters))
isInitialLoad.current = false
}
}, [filters, applyFilters])
useEffect(() => {
const element = gridWidthRef.current
if (!element) return
const syncGridWidth = () => {
setGridWidth(element.clientWidth)
}
syncGridWidth()
if (typeof ResizeObserver === "undefined") return
const observer = new ResizeObserver(syncGridWidth)
observer.observe(element)
return () => {
observer.disconnect()
}
}, [])
useEffect(() => {
if (gridWidth <= 0 || hasManualColumnSizing.current) return
const nextDescriptionWidth = getAutoDescriptionColumnSize(
gridWidth,
columnVisibility
)
setColumnSizing((current) =>
current[DESCRIPTION_COLUMN_ID] === nextDescriptionWidth
? current
: {
...current,
[DESCRIPTION_COLUMN_ID]: nextDescriptionWidth,
}
)
}, [columnVisibility, gridWidth])
const [columnOrder, setColumnOrder] = useState<string[]>(
columns.map((c) => c.id as string)
)
const handleColumnSizingChange = useCallback(
(
updater:
| ColumnSizingState
| ((old: ColumnSizingState) => ColumnSizingState)
) => {
hasManualColumnSizing.current = true
setColumnSizing(updater)
},
[]
)
const table = useReactTable({
columns,
data: filteredData,
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
getRowId: (row) => row.id,
state: {
pagination,
sorting,
columnOrder,
columnSizing,
columnVisibility,
},
columnResizeMode: "onChange",
onColumnOrderChange: setColumnOrder,
onColumnSizingChange: handleColumnSizingChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
/** Show Clear whenever any filter row is visible (including empty placeholders). */
const showClearButton = filters.length > 0
return (
<TooltipProvider delay={200}>
{/* Table */}
<DataGrid
table={table}
isLoading={isLoading}
loadingMode="skeleton"
recordCount={filteredData.length}
emptyMessage={
!isLoading && filteredData.length === 0
? "No transactions match your filters."
: undefined
}
tableLayout={{
columnsPinnable: true,
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
dense: true,
}}
>
<Frame variant="default" spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle id="page-heading" className="text-balance">
Transactions
</FrameTitle>
<FrameDescription className="text-xs text-pretty">
Billing ledger(refunds / payouts)
</FrameDescription>
</div>
<Button
type="button"
onClick={() =>
toast.info("New transaction", {
description:
"Wire to your checkout or manual entry flow, demo only.",
})
}
>
<PlusIcon aria-hidden />
New transaction
</Button>
</FrameHeader>
<FramePanel className="p-0! shadow-none!">
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button variant="outline" aria-label="Filters">
<FilterIcon aria-hidden />
Filters
</Button>
}
/>
{showClearButton && (
<Button
variant="outline"
className="shrink-0"
onClick={() => {
const next = createDefaultTransactionFilters()
lastAppliedActiveKey.current = serializeActiveFiltersKey(
getActiveFilters(next)
)
setFilters(next)
simulateAsyncFiltering(next)
}}
disabled={isLoading}
>
<FunnelXIcon className="size-3.5" aria-hidden />
Clear
</Button>
)}
</div>
<Separator />
<div ref={gridWidthRef} className="w-full">
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</div>
</FramePanel>
<FrameFooter>
<DataGridPagination />
</FrameFooter>
</Frame>
</DataGrid>
</TooltipProvider>
)
}
@@ -0,0 +1,263 @@
import { type ReactNode } from "react"
import { AnthropicBlack } from "@evobgp/ui/components/svgs/anthropicBlack"
import { AnthropicWhite } from "@evobgp/ui/components/svgs/anthropicWhite"
import { Convex } from "@evobgp/ui/components/svgs/convex"
import { Mintlify } from "@evobgp/ui/components/svgs/mintlify"
import { N8n } from "@evobgp/ui/components/svgs/n8n"
import { Neon } from "@evobgp/ui/components/svgs/neon"
import { Openai } from "@evobgp/ui/components/svgs/openai"
import { OpenaiDark } from "@evobgp/ui/components/svgs/openaiDark"
import { Paper } from "@evobgp/ui/components/svgs/paper"
import { Prisma } from "@evobgp/ui/components/svgs/prisma"
import { PrismaDark } from "@evobgp/ui/components/svgs/prismaDark"
import { Stripe } from "@evobgp/ui/components/svgs/stripe"
import { Supabase } from "@evobgp/ui/components/svgs/supabase"
// ── Types ──
export type TxType = "payment" | "refund" | "payout" | "charge" | "transfer"
export type TxStatus = "completed" | "pending" | "failed" | "cancelled"
/** Where the charge originated (billing SaaS pattern - replaces duplicate "method" logos). */
export type TxChannel = "checkout" | "api" | "invoice" | "dashboard"
export interface ITransaction {
id: string
reference: string
date: string
description: string
merchant: string
merchantLogo: ReactNode
category: string
type: TxType
channel: TxChannel
amount: number
currency: string
status: TxStatus
account: string
country: string
flag: string
timezone?: string
note?: string
}
// ── Logos (one mark per vendor - @/components/ui/svgs only) ──
const OPENAI_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<Openai className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<OpenaiDark className="size-5" />
</span>
</>
)
const PRISMA_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<Prisma className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<PrismaDark className="size-5" />
</span>
</>
)
const ANTHROPIC_LOGO = (
<>
<span aria-hidden className="dark:hidden">
<AnthropicBlack className="size-5" />
</span>
<span aria-hidden className="hidden dark:block">
<AnthropicWhite className="size-5" />
</span>
</>
)
// ── Data: 10 rows, unique merchants, inbound + outbound (refunds / payouts) ──
export const TRANSACTIONS: ITransaction[] = [
{
id: "txn_001",
reference: "TXN-2025-000143",
date: "2025-06-12T09:14:00Z",
description: "Pro plan · subscription renewal",
merchant: "Stripe",
merchantLogo: <Stripe className="size-5" aria-hidden="true" />,
category: "Payments",
type: "charge",
channel: "checkout",
amount: 240.0,
currency: "USD",
status: "completed",
account: "•••• 4242",
country: "United States",
flag: "us",
timezone: "PST (UTC8)",
},
{
id: "txn_002",
reference: "TXN-2025-000142",
date: "2025-06-11T16:30:00Z",
description: "Database compute · June cycle",
merchant: "Supabase",
merchantLogo: <Supabase className="size-5" aria-hidden="true" />,
category: "Infrastructure",
type: "charge",
channel: "dashboard",
amount: 75.0,
currency: "USD",
status: "completed",
account: "•••• 4242",
country: "United States",
flag: "us",
timezone: "EST (UTC5)",
},
{
id: "txn_003",
reference: "TXN-2025-000141",
date: "2025-06-11T11:02:00Z",
description: "GPT-4o usage · billing period",
merchant: "OpenAI",
merchantLogo: OPENAI_LOGO,
category: "AI / ML",
type: "charge",
channel: "api",
amount: 312.48,
currency: "USD",
status: "completed",
account: "•••• 4242",
country: "United States",
flag: "us",
timezone: "PST (UTC8)",
},
{
id: "txn_004",
reference: "TXN-2025-000140",
date: "2025-06-10T08:50:00Z",
description: "Workflow credit · billing adjustment (outbound)",
merchant: "N8n",
merchantLogo: <N8n className="size-5" aria-hidden="true" />,
category: "Automation",
type: "refund",
channel: "dashboard",
amount: -21.0,
currency: "USD",
status: "completed",
account: "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 (UTC6)",
},
{
id: "txn_007",
reference: "TXN-2025-000137",
date: "2025-06-08T12:22:00Z",
description: "Function invocations · May",
merchant: "Convex",
merchantLogo: <Convex className="size-5" aria-hidden="true" />,
category: "Backend",
type: "charge",
channel: "api",
amount: 25.0,
currency: "USD",
status: "failed",
account: "•••• 9871",
country: "United States",
flag: "us",
timezone: "PST (UTC8)",
note: "Card declined, retry scheduled",
},
{
id: "txn_008",
reference: "TXN-2025-000136",
date: "2025-06-07T10:05:00Z",
description: "Payout to linked bank · hobby tier balance",
merchant: "Neon",
merchantLogo: <Neon className="size-5" aria-hidden="true" />,
category: "Infrastructure",
type: "payout",
channel: "dashboard",
amount: -250.0,
currency: "USD",
status: "completed",
account: "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 (UTC5)",
},
]
@@ -0,0 +1,15 @@
import { DataGridView } from "./components/data-grid-view"
export function Page() {
return (
<main
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Billing ledger data grid
</h1>
<DataGridView />
</main>
)
}
@@ -0,0 +1,408 @@
"use client"
import { Badge } from "@/components/reui/badge"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import {
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from "@/components/reui/data-grid/data-grid-table"
import { Rating } from "@/components/reui/rating"
import { type ReactNode } from "react"
import { type ColumnDef } from "@tanstack/react-table"
import { format, parseISO } from "date-fns"
import { cn } from "@evobgp/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import { Switch } from "@evobgp/ui/components/switch"
import {
type AutomationKind,
type AutomationOwnerAvailability,
type AutomationState,
type IAutomationRecord,
} from "./data"
import { GitBranchIcon, RouteIcon, SparklesIcon, MailIcon, BellRingIcon, EllipsisVerticalIcon, PencilIcon, EyeIcon, CircleCheckIcon, ArchiveIcon } from "lucide-react"
export type AutomationAction = "edit" | "open" | "archive"
const automationKindStyles: Record<
AutomationKind,
{ chipClassName: string; icon: ReactNode }
> = {
sequence: {
chipClassName:
"bg-sky-50 text-sky-600 ring-sky-200 dark:bg-sky-500/15 dark:text-sky-300 dark:ring-sky-400/30",
icon: (
<GitBranchIcon className="size-4" aria-hidden="true" />
),
},
routing: {
chipClassName:
"bg-violet-50 text-violet-600 ring-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:ring-violet-400/30",
icon: (
<RouteIcon className="size-4" aria-hidden="true" />
),
},
enrichment: {
chipClassName:
"bg-emerald-50 text-emerald-600 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-400/30",
icon: (
<SparklesIcon className="size-4" aria-hidden="true" />
),
},
digest: {
chipClassName:
"bg-amber-50 text-amber-600 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:ring-amber-400/30",
icon: (
<MailIcon className="size-4" aria-hidden="true" />
),
},
escalation: {
chipClassName:
"bg-rose-50 text-rose-600 ring-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:ring-rose-400/30",
icon: (
<BellRingIcon className="size-4" aria-hidden="true" />
),
},
}
const stateBadgeStyles: Record<
AutomationState,
{ label: string; dotClassName?: string }
> = {
live: {
label: "Live",
dotClassName: "bg-emerald-500",
},
review: {
label: "Needs approval",
dotClassName: "bg-amber-500",
},
drafts: {
label: "Draft",
dotClassName: "bg-slate-400 dark:bg-slate-300",
},
paused: {
label: "Paused",
dotClassName: "bg-zinc-400 dark:bg-zinc-300",
},
}
const updatedBucketLabel: Record<IAutomationRecord["updatedBucket"], string> = {
today: "Today",
"this-week": "This week",
older: "Older",
}
const availabilityColor: Record<AutomationOwnerAvailability, string> = {
online: "bg-green-500",
away: "bg-yellow-400",
busy: "bg-red-500",
offline: "bg-gray-500",
}
function AutomationKindChip({ kind }: { kind: AutomationKind }) {
const style = automationKindStyles[kind]
return (
<Item
render={<span />}
className={cn(
"p-0",
"inline-flex size-10 items-center justify-center ring-1 ring-inset",
style.chipClassName
)}
aria-hidden="true"
>
<ItemMedia variant="icon" className="size-auto">
{style.icon}
</ItemMedia>
</Item>
)
}
function AutomationNameCell({ automation }: { automation: IAutomationRecord }) {
return (
<div className="flex min-w-0 items-center gap-3">
<AutomationKindChip kind={automation.kind} />
<div className="flex min-w-0 flex-col gap-px">
<span className="text-foreground truncate text-sm font-medium">
{automation.title}
</span>
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
<span className="truncate">{automation.runWindowLabel}</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="truncate">{automation.audienceLabel}</span>
</div>
</div>
</div>
)
}
function OwnerCell({ automation }: { automation: IAutomationRecord }) {
return (
<div className="flex items-center gap-2">
<div className="relative shrink-0">
<Avatar className="size-8">
{automation.owner.avatar ? (
<AvatarImage
src={automation.owner.avatar}
alt={automation.owner.name}
/>
) : null}
<AvatarFallback>{automation.owner.initials}</AvatarFallback>
</Avatar>
<span
className={cn(
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
availabilityColor[automation.owner.availability]
)}
aria-hidden
/>
</div>
<div className="min-w-0">
<div className="text-foreground line-clamp-1 font-medium">
{automation.owner.name}
</div>
<div
className="text-muted-foreground line-clamp-1 text-xs"
title={automation.owner.email}
>
{automation.owner.email}
</div>
</div>
</div>
)
}
function StateCell({ automation }: { automation: IAutomationRecord }) {
const stateStyle = stateBadgeStyles[automation.state]
return (
<div className="flex min-w-0 items-center">
<Badge variant="outline" className="gap-1.5">
{stateStyle.dotClassName ? (
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
stateStyle.dotClassName
)}
aria-hidden="true"
/>
) : null}
{stateStyle.label}
</Badge>
</div>
)
}
function UpdatedCell({ automation }: { automation: IAutomationRecord }) {
return (
<div className="flex min-w-0 flex-col gap-px">
<span className="text-foreground text-sm">
{format(parseISO(automation.updatedAt), "MMM d, yyyy")}
</span>
<span className="text-muted-foreground text-xs">
{updatedBucketLabel[automation.updatedBucket]}
</span>
</div>
)
}
function RatingCell({ automation }: { automation: IAutomationRecord }) {
return <Rating rating={automation.rating} size="sm" showValue={true} />
}
function AutomationActionsCell({
automation,
onAction,
onToggleEnabled,
}: {
automation: IAutomationRecord
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${automation.title}`}
>
<EllipsisVerticalIcon className="size-4" aria-hidden="true" />
</Button>
}
/>
{/* Content */}
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onAction("edit", automation)}>
<PencilIcon className="size-4" aria-hidden="true" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction("open", automation)}>
<EyeIcon className="size-4" aria-hidden="true" />
View Details
</DropdownMenuItem>
<DropdownMenuItem
closeOnClick={false}
onClick={(event) => {
// The Switch toggles itself and its click bubbles here; skip it
// so item-level activation (row click, Enter/Space) toggles once.
if (
event.target instanceof Element &&
event.target.closest('[data-slot="switch"]')
) {
return
}
onToggleEnabled(automation, !automation.enabled)
}}
className="justify-between gap-4"
>
<span className="flex items-center gap-2">
<CircleCheckIcon className="size-4" aria-hidden="true" />
Enabled
</span>
<Switch
size="sm"
aria-label={`Toggle ${automation.title}`}
checked={automation.enabled}
onCheckedChange={(checked) =>
onToggleEnabled(automation, checked)
}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onAction("archive", automation)}
>
<ArchiveIcon className="size-4" aria-hidden="true" />
Archive
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function createAutomationColumns({
onAction,
onToggleEnabled,
}: {
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
}): ColumnDef<IAutomationRecord>[] {
return [
{
id: "select",
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
size: 30,
enableSorting: false,
enableResizing: false,
enableHiding: false,
meta: {
headerClassName:
"[--data-grid-header-cell-ps:var(--frame-panel-header-px)]",
cellClassName: "[--data-grid-body-cell-ps:var(--frame-panel-px)]",
},
},
{
accessorFn: (row) => row.title,
id: "workflow",
header: ({ column }) => (
<DataGridColumnHeader title="Workflow" column={column} />
),
cell: ({ row }) => <AutomationNameCell automation={row.original} />,
size: 340,
minSize: 200,
enableSorting: true,
enableHiding: false,
meta: {
autoSize: true,
headerClassName: "pl-3!",
cellClassName: "pl-3!",
},
},
{
accessorFn: (row) => row.owner.name,
id: "owner",
header: ({ column }) => (
<DataGridColumnHeader title="Owner" column={column} />
),
cell: ({ row }) => <OwnerCell automation={row.original} />,
size: 175,
enableSorting: true,
},
{
accessorFn: (row) => row.rating,
id: "rating",
header: ({ column }) => (
<DataGridColumnHeader title="Score" column={column} />
),
cell: ({ row }) => <RatingCell automation={row.original} />,
size: 150,
enableSorting: true,
},
{
accessorFn: (row) => row.state,
id: "state",
header: ({ column }) => (
<DataGridColumnHeader title="State" column={column} />
),
cell: ({ row }) => <StateCell automation={row.original} />,
size: 150,
enableSorting: true,
},
{
accessorFn: (row) => parseISO(row.updatedAt).getTime(),
id: "updatedAt",
header: ({ column }) => (
<DataGridColumnHeader title="Last updated" column={column} />
),
cell: ({ row }) => <UpdatedCell automation={row.original} />,
size: 125,
enableSorting: true,
},
{
id: "actions",
header: () => null,
cell: ({ row }) => (
<div className="flex items-center justify-end">
<AutomationActionsCell
automation={row.original}
onAction={onAction}
onToggleEnabled={onToggleEnabled}
/>
</div>
),
size: 56,
enableSorting: false,
meta: {
headerClassName:
"[--data-grid-header-cell-pe:var(--frame-panel-header-px)]",
cellClassName: "[--data-grid-body-cell-pe:var(--frame-panel-px)]",
},
},
]
}
@@ -0,0 +1,660 @@
import { useCallback, useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { DataGrid } from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type PaginationState,
type RowSelectionState,
type SortingState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { cn } from "@evobgp/ui/lib/utils"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@evobgp/ui/components/alert-dialog"
import { Button } from "@evobgp/ui/components/button"
import { Separator } from "@evobgp/ui/components/separator"
import { Tabs, TabsList, TabsTrigger } from "@evobgp/ui/components/tabs"
import { createAutomationColumns } from "./columns"
import {
AUTOMATION_TABS,
AUTOMATIONS,
DELIVERY_FILTER_OPTIONS,
getAutomationTabFromState,
OWNER_FILTER_OPTIONS,
UPDATED_FILTER_OPTIONS,
type AutomationState,
type AutomationTab,
type IAutomationRecord,
} from "./data"
import { SearchIcon, UsersIcon, RouteIcon, ClockIcon, PlusIcon, FilterIcon, FunnelXIcon } from "lucide-react"
type ToastTone = "success" | "neutral" | "destructive"
const toneStyles: Record<ToastTone, { dot: string }> = {
success: { dot: "bg-emerald-500" },
neutral: { dot: "bg-sky-500" },
destructive: { dot: "bg-rose-500" },
}
function showAutomationToast({
tone,
title,
description,
}: {
tone: ToastTone
title: string
description: string
}) {
toast.custom((id) => (
<div className="bg-popover text-popover-foreground border-border flex w-[356px] flex-col gap-3 rounded-md border p-4 shadow-lg">
<div className="flex items-start gap-2">
<span
className={cn(
"mt-1 flex size-2 shrink-0 rounded-full",
toneStyles[tone].dot
)}
aria-hidden="true"
/>
<div className="flex flex-1 flex-col gap-1">
<p className="text-sm font-semibold">{title}</p>
<p className="text-muted-foreground text-sm leading-relaxed text-pretty">
{description}
</p>
</div>
</div>
<div className="flex gap-2">
<Button size="xs" variant="outline" onClick={() => toast.dismiss(id)}>
Done
</Button>
</div>
</div>
))
}
function getAutomationSearchBlob(automation: IAutomationRecord) {
return [
automation.title,
automation.kind,
automation.state,
automation.deliveryMode,
automation.audienceLabel,
automation.runWindowLabel,
automation.owner.name,
automation.owner.email,
automation.owner.teamLabel,
automation.approvalRequired ? "approval required" : "auto-approved",
automation.enabled ? "enabled" : "disabled",
]
.join(" ")
.toLowerCase()
}
function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (
values.every((value) => typeof value === "string" && value.trim() === "")
) {
return false
}
if (values.every((value) => value === null || value === undefined)) {
return false
}
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
return false
}
return true
})
}
function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return "Select..."
if (values.length > 1) return `${values.length} selected`
return null
}
function renderSingleSelectedLabel(
values: unknown[],
options: { value: string; label: string }[]
) {
const state = renderSelectedCount(values)
if (state) return state
const option = options.find((item) => item.value === values[0])
return option?.label ?? String(values[0])
}
function filterFieldValue(
automation: IAutomationRecord,
field: string
): unknown {
switch (field) {
case "workflow":
return getAutomationSearchBlob(automation)
case "ownerTeam":
return automation.owner.team
case "deliveryMode":
return automation.deliveryMode
case "updatedBucket":
return automation.updatedBucket
default:
return ""
}
}
function applyFiltersToData(
data: IAutomationRecord[],
filters: Filter[]
): IAutomationRecord[] {
const active = getActiveFilters(filters)
let result = [...data]
active.forEach((filter) => {
const { field, operator, values } = filter
result = result.filter((item) => {
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? raw : ""
switch (operator) {
case "is":
return values.includes(fieldValue)
case "is_not":
return !values.includes(fieldValue)
case "is_any_of":
return values.some((value) => fieldValue === value)
case "is_not_any_of":
return !values.some((value) => fieldValue === value)
case "contains": {
const tokens = values
.map((value) => String(value).trim())
.filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase())
)
}
case "not_contains":
return !values.some((value) =>
String(fieldValue)
.toLowerCase()
.includes(String(value).toLowerCase())
)
case "starts_with":
return values.some((value) =>
String(fieldValue)
.toLowerCase()
.startsWith(String(value).toLowerCase())
)
case "ends_with":
return values.some((value) =>
String(fieldValue)
.toLowerCase()
.endsWith(String(value).toLowerCase())
)
case "empty":
return fieldValue === "" || fieldValue == null
case "not_empty":
return fieldValue !== "" && fieldValue != null
default:
return true
}
})
})
return result
}
const OWNER_TEAM_FILTER_OPTIONS = OWNER_FILTER_OPTIONS.filter(
(option) => option.value !== "everyone"
).map((option) => ({
value: option.value,
label: option.label,
}))
const DELIVERY_MODE_FILTER_OPTIONS = DELIVERY_FILTER_OPTIONS.filter(
(option) => option.value !== "any"
).map((option) => ({
value: option.value,
label: option.label,
}))
const UPDATED_BUCKET_FILTER_OPTIONS = UPDATED_FILTER_OPTIONS.filter(
(option) => option.value !== "any"
).map((option) => ({
value: option.value,
label: option.label,
}))
const filterFields: FilterFieldConfig[] = [
{
key: "workflow",
label: "Workflow",
icon: (
<SearchIcon className="size-3.5" aria-hidden />
),
type: "text",
className: "w-52",
placeholder: "Search...",
},
{
key: "ownerTeam",
label: "Owner team",
icon: (
<UsersIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[168px]",
options: OWNER_TEAM_FILTER_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, OWNER_TEAM_FILTER_OPTIONS),
},
{
key: "deliveryMode",
label: "Delivery mode",
icon: (
<RouteIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[168px]",
options: DELIVERY_MODE_FILTER_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, DELIVERY_MODE_FILTER_OPTIONS),
},
{
key: "updatedBucket",
label: "Last updated",
icon: (
<ClockIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[160px]",
options: UPDATED_BUCKET_FILTER_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, UPDATED_BUCKET_FILTER_OPTIONS),
},
]
function createDefaultAutomationFilters(): Filter[] {
return [createFilter("workflow", "contains", [""])]
}
function getTabCounts(records: IAutomationRecord[]) {
return {
all: records.length,
live: records.filter((record) => record.state === "live").length,
review: records.filter((record) => record.state === "review").length,
drafts: records.filter((record) => record.state === "drafts").length,
paused: records.filter((record) => record.state === "paused").length,
} satisfies Record<AutomationTab, number>
}
export function AutomationLibraryGridView() {
const [automations, setAutomations] =
useState<IAutomationRecord[]>(AUTOMATIONS)
const [activeTab, setActiveTab] = useState<AutomationTab>("all")
const [filters, setFilters] = useState<Filter[]>(
createDefaultAutomationFilters
)
const [sorting, setSorting] = useState<SortingState>([
{ id: "updatedAt", desc: true },
])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [automationPendingArchive, setAutomationPendingArchive] =
useState<IAutomationRecord | null>(null)
const resetPagination = useCallback(() => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
)
}, [])
const filteredBaseAutomations = useMemo(() => {
return applyFiltersToData(automations, filters)
}, [automations, filters])
const filteredAutomations = useMemo(
() =>
filteredBaseAutomations.filter((automation) =>
activeTab === "all"
? true
: getAutomationTabFromState(automation.state) === activeTab
),
[activeTab, filteredBaseAutomations]
)
const tabCounts = useMemo(
() => getTabCounts(filteredBaseAutomations),
[filteredBaseAutomations]
)
const filteredLiveCount = useMemo(
() =>
filteredAutomations.filter((automation) => automation.state === "live")
.length,
[filteredAutomations]
)
const filteredReviewCount = useMemo(
() =>
filteredAutomations.filter((automation) => automation.state === "review")
.length,
[filteredAutomations]
)
const selectedCount = useMemo(
() => Object.keys(rowSelection).length,
[rowSelection]
)
const columns = useMemo(
() =>
createAutomationColumns({
onAction: (action, automation) => {
if (action === "archive") {
setAutomationPendingArchive(automation)
return
}
if (action === "edit") {
showAutomationToast({
tone: "neutral",
title: "Workflow editor",
description: `Connect "${automation.title}" to your builder, side panel, or automation step editor.`,
})
return
}
showAutomationToast({
tone: "success",
title: "Workflow details",
description: `"${automation.title}" is ready for a detail route, run history drawer, or audit panel.`,
})
},
onToggleEnabled: (automation, nextValue) => {
const nextState: AutomationState =
nextValue && automation.state === "paused"
? "live"
: nextValue && automation.state === "drafts"
? "review"
: !nextValue && automation.state === "live"
? "paused"
: automation.state
setAutomations((current) =>
current.map((item) =>
item.id === automation.id
? {
...item,
enabled: nextValue,
state: nextState,
}
: item
)
)
showAutomationToast({
tone: nextValue ? "success" : "neutral",
title: nextValue ? "Workflow enabled" : "Workflow paused",
description: nextValue
? `"${automation.title}" is ready to run in the ${nextState === "review" ? "review" : "live"} queue.`
: `"${automation.title}" will stay available but will not continue running until resumed.`,
})
},
}),
[]
)
const table = useReactTable({
data: filteredAutomations,
columns,
getRowId: (row) => row.id,
state: {
sorting,
rowSelection,
pagination,
},
enableRowSelection: true,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const handleClearControls = useCallback(() => {
setFilters(createDefaultAutomationFilters())
resetPagination()
}, [resetPagination])
const handleFiltersChange = useCallback(
(nextFilters: Filter[]) => {
setFilters(nextFilters)
resetPagination()
},
[resetPagination]
)
const handleArchiveAutomation = useCallback(() => {
if (!automationPendingArchive) return
const automationToArchive = automationPendingArchive
setAutomations((current) =>
current.filter(
(automation) => automation.id !== automationPendingArchive.id
)
)
setRowSelection((current) => {
const next = { ...current }
delete next[automationPendingArchive.id]
return next
})
setAutomationPendingArchive(null)
resetPagination()
showAutomationToast({
tone: "destructive",
title: "Workflow archived",
description: `"${automationToArchive.title}" was removed from this automation library.`,
})
}, [automationPendingArchive, resetPagination])
const emptyMessage =
"No workflows match this automation slice. Switch tabs or clear the filters."
return (
<>
{/* Table */}
<DataGrid
table={table}
recordCount={filteredAutomations.length}
emptyMessage={emptyMessage}
tableLayout={{
dense: true,
}}
>
<Frame dense variant="default" spacing="sm" className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex flex-col gap-px">
<FrameTitle className="text-balance">
Automation Library
</FrameTitle>
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>
{filteredAutomations.length} workflow
{filteredAutomations.length === 1 ? "" : "s"}
</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{filteredLiveCount} live</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{filteredReviewCount} review</span>
{selectedCount > 0 ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{selectedCount} selected</span>
</>
) : null}
</FrameDescription>
</div>
<Button type="button" className="shrink-0">
<PlusIcon className="size-4" aria-hidden="true" />
New workflow
</Button>
</FrameHeader>
<FramePanel className="p-0 shadow-none!">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<Tabs
value={activeTab}
onValueChange={(value) => {
setActiveTab(value as AutomationTab)
resetPagination()
}}
>
<TabsList variant="line" className="gap-5">
{AUTOMATION_TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="gap-2 px-0 pb-3 text-sm"
>
<span>{tab.label}</span>
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{tabCounts[tab.value]}
</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<Separator />
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button variant="outline" aria-label="Filters">
<FilterIcon className="size-4" aria-hidden="true" />
Filters
</Button>
}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} selected
</Badge>
) : null}
<Button
type="button"
variant="outline"
onClick={handleClearControls}
>
<FunnelXIcon className="size-4" aria-hidden="true" />
Clear
</Button>
</div>
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
<AlertDialog
open={automationPendingArchive != null}
onOpenChange={(open) => {
if (!open) {
setAutomationPendingArchive(null)
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive workflow?</AlertDialogTitle>
<AlertDialogDescription>
{automationPendingArchive
? `Archive "${automationPendingArchive.title}" from this automation library. Run history and ownership context can stay available in your backend, but this row will disappear from the grid preview.`
: "Archive this workflow from the automation library."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleArchiveAutomation}
render={
<Button type="button" variant="destructive">
Archive
</Button>
}
/>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,384 @@
export type AutomationTab = "all" | "live" | "review" | "drafts" | "paused"
export type AutomationKind =
| "sequence"
| "routing"
| "enrichment"
| "digest"
| "escalation"
export type OwnerFilter =
| "everyone"
| "product"
| "engineering"
| "operations"
| "revenue"
| "support"
export type DeliveryFilter =
| "any"
| "scheduled"
| "event-driven"
| "manual"
| "hybrid"
export type UpdatedFilter = "any" | "today" | "this-week" | "older"
export type AutomationState = Exclude<AutomationTab, "all">
export type AutomationOwnerAvailability = "online" | "away" | "busy" | "offline"
export interface IAutomationOwner {
id: string
name: string
email: string
initials: string
avatar?: string
availability: AutomationOwnerAvailability
team: Exclude<OwnerFilter, "everyone">
teamLabel: string
}
export interface IAutomationRecord {
id: string
title: string
kind: AutomationKind
state: AutomationState
rating: number
deliveryMode: Exclude<DeliveryFilter, "any">
owner: IAutomationOwner
updatedAt: string
updatedBucket: Exclude<UpdatedFilter, "any">
enabled: boolean
approvalRequired: boolean
audienceLabel: string
runWindowLabel: string
}
const OWNERS: Record<string, IAutomationOwner> = {
maya: {
id: "maya-patel",
name: "Maya Patel",
email: "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
})
@@ -0,0 +1,108 @@
import { Badge } from "@/components/reui/badge"
import { Button } from "@evobgp/ui/components/button"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import {
RENEWAL_STAGE_ORDER,
type RenewalOwnerOption,
type RenewalStage,
} from "./data"
interface RenewalSelectionBarProps {
selectedCount: number
ownerValue: string
stageValue: RenewalStage
ownerOptions: RenewalOwnerOption[]
onOwnerChange: (value: string) => void
onStageChange: (value: RenewalStage) => void
onApply: () => void
onClear: () => void
}
export function RenewalSelectionBar({
selectedCount,
ownerValue,
stageValue,
ownerOptions,
onOwnerChange,
onStageChange,
onApply,
onClear,
}: RenewalSelectionBarProps) {
return (
<div className="bg-muted/30 flex flex-col gap-3 border-y px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-center gap-2">
<Badge size="sm" variant="outline" radius="full">
{selectedCount} selected
</Badge>
<span className="text-muted-foreground text-xs">
Update owner or stage.
</span>
</div>
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
<Select
value={ownerValue}
onValueChange={(value) => {
if (!value) return
onOwnerChange(value)
}}
>
<SelectTrigger
size="sm"
className="w-full sm:w-[190px]"
aria-label="Assign owner"
>
<SelectValue placeholder="Assign owner" />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{ownerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select
value={stageValue}
onValueChange={(value) => onStageChange(value as RenewalStage)}
>
<SelectTrigger
size="sm"
className="w-full sm:w-[190px]"
aria-label="Set stage"
>
<SelectValue placeholder="Set stage" />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{RENEWAL_STAGE_ORDER.map((stage) => (
<SelectItem key={stage} value={stage}>
{stage}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button type="button" size="sm" variant="ghost" onClick={onClear}>
Deselect
</Button>
<Button type="button" size="sm" onClick={onApply}>
Apply updates
</Button>
</div>
</div>
)
}
@@ -0,0 +1,63 @@
import { type ReactNode } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@evobgp/ui/components/card"
interface RenewalsCommandCardProps {
title: string
description?: ReactNode
meta?: ReactNode
action?: ReactNode
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
}
export function RenewalsCommandCard({
title,
description,
meta,
action,
children,
footer,
className,
contentClassName,
}: RenewalsCommandCardProps) {
return (
<Card className={cn("w-full gap-0 p-0", className)}>
{/* Header */}
<CardHeader className="flex flex-row items-center justify-between gap-4 border-b px-5 py-4">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<CardTitle>{title}</CardTitle>
{description ? (
<CardDescription className="text-xs">{description}</CardDescription>
) : null}
{meta ? (
<div className="flex flex-wrap items-center gap-2">{meta}</div>
) : null}
</div>
{action ? (
<CardAction className="self-center">{action}</CardAction>
) : null}
</CardHeader>
{/* Content */}
<CardContent className={cn("p-0", contentClassName)}>
{children}
</CardContent>
{footer ? (
<CardFooter className="border-t px-5 py-3">{footer}</CardFooter>
) : null}
</Card>
)
}
@@ -0,0 +1,33 @@
"use client"
import { useEffect, useState } from "react"
import { RenewalsCommandGridView } from "./components/data-grid-view"
export function Page() {
const [isReady, setIsReady] = useState(false)
useEffect(() => {
setIsReady(true)
}, [])
return (
<main
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Renewals command data grid
</h1>
{isReady ? (
<RenewalsCommandGridView />
) : (
<div
className="bg-card border-border w-full rounded-xl border"
style={{ height: 640 }}
aria-hidden="true"
/>
)}
</main>
)
}
@@ -0,0 +1,58 @@
import { IconStack } from "@/components/reui/icon-stack"
import { Button } from "@evobgp/ui/components/button"
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@evobgp/ui/components/empty"
import { RouteIcon } from "lucide-react"
export function EmptyState() {
return (
<div className="flex w-full max-w-4xl flex-col gap-4">
{/* Heading */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-semibold tracking-tight">
Routing Signals
</h2>
<p className="text-muted-foreground text-sm">
Shape incoming work before it reaches the roadmap.
</p>
</div>
<Button type="button" size="sm" className="w-full sm:w-auto">
Add signal
</Button>
</div>
{/* Card */}
<Card className="bg-muted min-h-[330px] p-0 shadow-none">
<CardContent className="flex min-h-[330px] items-center justify-center p-6 sm:p-10">
<Empty className="max-w-md gap-5 bg-transparent p-0">
<EmptyHeader className="items-center gap-5 text-center">
<EmptyMedia className="mb-0">
<IconStack aria-hidden="true">
<RouteIcon strokeWidth="1.9" aria-hidden="true" />
</IconStack>
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">
Create signals to route new work
</EmptyTitle>
<EmptyDescription className="max-w-sm text-sm/relaxed">
Define signals so every intake item starts with context.
</EmptyDescription>
</div>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,15 @@
import { EmptyState } from "./components/empty-state"
export function Page() {
return (
<main
className="flex min-h-svh w-full items-center justify-center p-4 sm:p-8 md:p-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Signal routing empty state
</h1>
<EmptyState />
</main>
)
}
@@ -0,0 +1,55 @@
import { Button } from "@evobgp/ui/components/button"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@evobgp/ui/components/empty"
import { ProjectsEmptyIllustration } from "./projects-empty-illustration"
import { QuickStartTemplates } from "./quick-start-templates"
import { PlusIcon, LayoutTemplateIcon } from "lucide-react"
export function EmptyState() {
return (
<div className="flex min-h-[820px] w-full max-w-[52rem] flex-col items-center justify-center gap-24 py-8 sm:gap-32 md:py-12">
{/* Empty State */}
<Empty className="flex-none gap-7 rounded-none border-0 p-0 md:p-0">
<EmptyHeader className="max-w-[28rem] gap-4">
<EmptyMedia className="mb-0">
<ProjectsEmptyIllustration />
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-2xl font-semibold tracking-tight">
No projects to show
</EmptyTitle>
<EmptyDescription className="max-w-[25rem] text-sm/relaxed">
Start a project from scratch or pick a template to launch your
first workspace and begin tracking tasks, goals, and progress.
</EmptyDescription>
</div>
</EmptyHeader>
<EmptyContent className="max-w-none gap-0">
<div className="flex w-full flex-wrap items-center justify-center gap-2">
<Button type="button">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
New project
</Button>
<Button type="button" variant="outline">
<LayoutTemplateIcon data-icon="inline-start" aria-hidden="true" />
Explore templates
</Button>
</div>
</EmptyContent>
</Empty>
<div className="w-full max-w-md">
<QuickStartTemplates />
</div>
</div>
)
}
@@ -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,116 @@
"use client"
import { useState, type ComponentType } from "react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@evobgp/ui/lib/utils"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import { BillingTab } from "./billing-tab"
import { SETTINGS_TAB_ITEMS } from "./data"
import { NotificationsTab } from "./notifications-tab"
import { ProfileTab } from "./profile-tab"
import { SecurityTab } from "./security-tab"
const TAB_COMPONENTS: Record<string, ComponentType> = {
profile: ProfileTab,
security: SecurityTab,
notifications: NotificationsTab,
billing: BillingTab,
}
// ── Settings Navigation ──
function SettingsNavigation({
isMobile,
activeValue,
}: {
isMobile: boolean
activeValue: string
}) {
return (
<div className={cn("min-w-0", isMobile ? "w-full" : "w-40 shrink-0")}>
{isMobile ? (
<div className="-mx-1 overflow-x-auto px-1 pb-1">
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
{SETTINGS_TAB_ITEMS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
)}
>
{tab.icon}
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
</div>
) : (
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
{SETTINGS_TAB_ITEMS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
)}
>
{tab.icon}
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
)}
</div>
)
}
export function AccountSettings() {
const isMobile = useIsMobile()
const [activeTab, setActiveTab] = useState("profile")
return (
<div className="w-full max-w-4xl space-y-8">
{/* Header */}
<header className="px-1">
<h1 className="text-xl font-semibold tracking-tight">
Account Settings
</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
Update your profile, access, notifications, and billing preferences.
</p>
</header>
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
orientation={isMobile ? "horizontal" : "vertical"}
className={cn("w-full gap-4 lg:gap-8")}
>
<SettingsNavigation isMobile={isMobile} activeValue={activeTab} />
<div className="min-w-0 flex-1">
{SETTINGS_TAB_ITEMS.map((tab) => {
const TabComponent = TAB_COMPONENTS[tab.value]
return (
<TabsContent key={tab.value} value={tab.value} className="mt-0">
<TabComponent />
</TabsContent>
)
})}
</div>
</Tabs>
</div>
)
}
@@ -0,0 +1,180 @@
import { useState } from "react"
import { Badge } from "@/components/reui/badge"
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 { BILLING_PLANS, COUNTRIES } from "./data"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
export function BillingTab() {
const [country, setCountry] = useState("us")
const handleCountryChange = createSelectValueHandler(setCountry)
return (
<div className="space-y-6">
{/* Card */}
<SettingsCard title="Plan and billing" description="Current subscription">
<SettingsFieldGroup
legend="Plan and billing"
description="Review your current subscription and plan options."
>
{BILLING_PLANS.map((plan, index) => (
<SettingRow
key={plan.id}
title={plan.name}
titleAddon={
plan.current ? (
<Badge variant="info-light" size="sm">
Current
</Badge>
) : null
}
description={
<>
<span className="text-foreground font-medium">
{plan.price}
</span>{" "}
/ {plan.period} · {plan.features.join(", ")}
</>
}
last={index === BILLING_PLANS.length - 1}
>
<Button variant={plan.current ? "outline" : "ghost"} size="sm">
{plan.current ? "Manage" : "Switch"}
</Button>
</SettingRow>
))}
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Billing details"
description="Invoices and payments"
footer={<Button>Update billing</Button>}
>
<SettingsFieldGroup
legend="Billing details"
description="Manage invoice contacts and payment details."
>
<SettingRow
title="Billing email"
description="Receives invoices and renewal notices."
labelFor="settings-7-billing-email"
>
<Input
id="settings-7-billing-email"
defaultValue="billing@acme.dev"
type="email"
/>
</SettingRow>
<SettingRow
title="Invoice profile"
description="Business details used on receipts and tax forms."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Invoice profile</FieldLegend>
<FieldDescription className="sr-only">
Company identity used for invoices and tax documents.
</FieldDescription>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="settings-7-company-name">
Company name
</FieldLabel>
<Input
id="settings-7-company-name"
defaultValue="Acme Labs"
/>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-billing-country">
Country
</FieldLabel>
<Select value={country} onValueChange={handleCountryChange}>
<SelectTrigger
id="settings-7-billing-country"
className="w-full"
>
<SelectValue>
{getOptionLabel(COUNTRIES, country)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{COUNTRIES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
<SettingRow
title="Tax ID"
description="Optional number used for VAT or company invoices."
labelFor="settings-7-tax-id"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>VAT</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-tax-id"
defaultValue="US-2048-ACME"
/>
</InputGroup>
</SettingRow>
<SettingRow
title="Card on file"
description="Used for monthly renewals."
last
>
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="text-muted-foreground text-sm">
Visa ending in 4242
</span>
<Button variant="outline">Update</Button>
</div>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -0,0 +1,187 @@
"use client"
import { type ReactNode } from "react"
import { UserIcon, ShieldIcon, BellIcon, CreditCardIcon, MonitorIcon, SmartphoneIcon } from "lucide-react"
// ── Types ──
export type SelectOption = {
value: string
label: string
}
export type BillingPlan = {
id: string
name: string
price: string
period: string
current: boolean
features: string[]
}
export type Session = {
id: string
device: string
browser: string
location: string
lastActive: string
current: boolean
icon: ReactNode
}
export type SettingsTabItem = {
value: string
label: string
icon: ReactNode
}
// ── Data ──
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
{
value: "profile",
label: "My Profile",
icon: (
<UserIcon aria-hidden="true" />
),
},
{
value: "security",
label: "Security",
icon: (
<ShieldIcon aria-hidden="true" />
),
},
{
value: "notifications",
label: "Notifications",
icon: (
<BellIcon aria-hidden="true" />
),
},
{
value: "billing",
label: "Billing",
icon: (
<CreditCardIcon aria-hidden="true" />
),
},
]
export const TIMEZONES: SelectOption[] = [
{ value: "utc-8", label: "UTC-8 (Pacific Time)" },
{ value: "utc-5", label: "UTC-5 (Eastern Time)" },
{ value: "utc+0", label: "UTC+0 (London)" },
{ value: "utc+1", label: "UTC+1 (Berlin)" },
{ value: "utc+9", label: "UTC+9 (Tokyo)" },
]
export const ROLES: SelectOption[] = [
{ value: "engineering-lead", label: "Engineering Lead" },
{ value: "developer", label: "Developer" },
{ value: "designer", label: "Designer" },
{ value: "product-manager", label: "Product Manager" },
]
export const COUNTRIES: SelectOption[] = [
{ value: "us", label: "United States" },
{ value: "uk", label: "United Kingdom" },
{ value: "de", label: "Germany" },
{ value: "uz", label: "Uzbekistan" },
]
export const TIMEOUTS: SelectOption[] = [
{ value: "5", label: "5 minutes" },
{ value: "10", label: "10 minutes" },
{ value: "15", label: "15 minutes" },
{ value: "30", label: "30 minutes" },
]
export const QUIET_HOURS: SelectOption[] = [
{ value: "18:00", label: "6:00 PM" },
{ value: "20:00", label: "8:00 PM" },
{ value: "22:00", label: "10:00 PM" },
{ value: "23:00", label: "11:00 PM" },
]
export const QUIET_HOURS_END: SelectOption[] = [
{ value: "06:00", label: "6:00 AM" },
{ value: "08:00", label: "8:00 AM" },
{ value: "09:00", label: "9:00 AM" },
{ value: "10:00", label: "10:00 AM" },
]
export const DIGEST_CADENCE: SelectOption[] = [
{ value: "daily", label: "Daily summary" },
{ value: "weekly", label: "Weekly digest" },
{ value: "mentions", label: "Only mentions" },
]
export const RECOVERY_METHODS: SelectOption[] = [
{ value: "authenticator", label: "Authenticator first" },
{ value: "sms", label: "SMS fallback" },
{ value: "email", label: "Email fallback" },
]
export const BILLING_PLANS: BillingPlan[] = [
{
id: "free",
name: "Free",
price: "$0",
period: "forever",
current: false,
features: ["1 workspace", "Basic exports", "Community support"],
},
{
id: "pro",
name: "Pro",
price: "$29",
period: "per month",
current: true,
features: ["Unlimited workspaces", "Automations", "Priority support"],
},
{
id: "team",
name: "Team",
price: "$79",
period: "per month",
current: false,
features: ["Everything in Pro", "SSO", "Shared billing"],
},
]
export const SESSIONS: Session[] = [
{
id: "sess-1",
device: "macOS",
browser: "Chrome",
location: "San Francisco, CA",
lastActive: "Active now",
current: true,
icon: (
<MonitorIcon aria-hidden="true" />
),
},
{
id: "sess-2",
device: "iPhone",
browser: "Safari",
location: "San Francisco, CA",
lastActive: "2 hours ago",
current: false,
icon: (
<SmartphoneIcon aria-hidden="true" />
),
},
{
id: "sess-3",
device: "Windows",
browser: "Firefox",
location: "New York, NY",
lastActive: "3 days ago",
current: false,
icon: (
<MonitorIcon aria-hidden="true" />
),
},
]
@@ -0,0 +1,223 @@
import { useState } from "react"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Switch } from "@evobgp/ui/components/switch"
import { DIGEST_CADENCE, QUIET_HOURS, QUIET_HOURS_END, TIMEOUTS } from "./data"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
export function NotificationsTab() {
const [autoDismiss, setAutoDismiss] = useState("10")
const [quietStart, setQuietStart] = useState("20:00")
const [quietEnd, setQuietEnd] = useState("08:00")
const [digestCadence, setDigestCadence] = useState("weekly")
const handleAutoDismissChange = createSelectValueHandler(setAutoDismiss)
const handleQuietStartChange = createSelectValueHandler(setQuietStart)
const handleQuietEndChange = createSelectValueHandler(setQuietEnd)
const handleDigestCadenceChange = createSelectValueHandler(setDigestCadence)
return (
<div className="space-y-6">
{/* Card */}
<SettingsCard title="Activity alerts" description="Desktop and badge">
<SettingsFieldGroup
legend="Activity alerts"
description="Control desktop, badge, and sound alerts."
>
<SettingRow
title="Desktop notifications"
description="Show alerts for mentions and approvals."
labelFor="settings-7-desktop-notifications"
>
<Switch id="settings-7-desktop-notifications" defaultChecked />
</SettingRow>
<SettingRow
title="Quiet hours"
description="Hold non-urgent alerts outside your workday."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Quiet hours</FieldLegend>
<FieldDescription className="sr-only">
Define when non-urgent activity should stay muted.
</FieldDescription>
<FieldGroup className="gap-3 sm:grid sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="settings-7-quiet-start">
Start
</FieldLabel>
<Select
value={quietStart}
onValueChange={handleQuietStartChange}
>
<SelectTrigger
id="settings-7-quiet-start"
className="w-full"
>
<SelectValue>
{getOptionLabel(QUIET_HOURS, quietStart)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{QUIET_HOURS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-quiet-end">End</FieldLabel>
<Select value={quietEnd} onValueChange={handleQuietEndChange}>
<SelectTrigger id="settings-7-quiet-end" className="w-full">
<SelectValue>
{getOptionLabel(QUIET_HOURS_END, quietEnd)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{QUIET_HOURS_END.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
<SettingRow
title="Unread badge"
description="Display a badge when new activity arrives."
labelFor="settings-7-unread-badge"
>
<Switch id="settings-7-unread-badge" defaultChecked />
</SettingRow>
<SettingRow
title="Auto dismiss"
description="Choose how long alerts stay visible."
labelFor="settings-7-auto-dismiss"
last
>
<Select value={autoDismiss} onValueChange={handleAutoDismissChange}>
<SelectTrigger id="settings-7-auto-dismiss" className="w-full">
<SelectValue>
{getOptionLabel(TIMEOUTS, autoDismiss)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TIMEOUTS.map((timeout) => (
<SelectItem key={timeout.value} value={timeout.value}>
{timeout.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Email updates"
description="Inbox preferences"
footer={
<>
<Button variant="outline">Reset</Button>
<Button>Save preferences</Button>
</>
}
>
<SettingsFieldGroup
legend="Email updates"
description="Control which emails reach your inbox."
>
<SettingRow
title="Team communication"
description="Messages, approvals, and activity summaries."
labelFor="settings-7-team-communication"
>
<Switch id="settings-7-team-communication" defaultChecked />
</SettingRow>
<SettingRow
title="Product announcements"
description="Releases, improvements, and launches."
labelFor="settings-7-product-announcements"
>
<Switch id="settings-7-product-announcements" />
</SettingRow>
<SettingRow
title="Weekly digest"
description="A recap of workspaces, mentions, and tasks."
labelFor="settings-7-weekly-digest"
>
<Switch id="settings-7-weekly-digest" defaultChecked />
</SettingRow>
<SettingRow
title="Digest cadence"
description="Set how often summary emails arrive."
labelFor="settings-7-digest-cadence"
last
>
<Select
value={digestCadence}
onValueChange={handleDigestCadenceChange}
>
<SelectTrigger id="settings-7-digest-cadence" className="w-full">
<SelectValue>
{getOptionLabel(DIGEST_CADENCE, digestCadence)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{DIGEST_CADENCE.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -0,0 +1,103 @@
"use client"
import { useState } from "react"
import { useFileUpload } from "@/hooks/use-file-upload"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Button } from "@evobgp/ui/components/button"
import { UserCircle, XIcon, UploadIcon } from "lucide-react"
interface ProfileAvatarUploadProps {
defaultAvatar: string
alt: string
inputId?: string
}
export function ProfileAvatarUpload({
defaultAvatar,
alt,
inputId,
}: ProfileAvatarUploadProps) {
const [removedCurrentPhoto, setRemovedCurrentPhoto] = useState(false)
const [{ files }, { removeFile, openFileDialog, getInputProps }] =
useFileUpload({
accept: "image/*",
})
const currentFile = files[0] ?? null
const hasSavedPhoto = Boolean(defaultAvatar) && !removedCurrentPhoto
const previewUrl =
currentFile?.preview ?? (hasSavedPhoto ? defaultAvatar : null)
const hasPhoto = Boolean(previewUrl)
const handleCancelUpload = () => {
if (!currentFile) {
return
}
removeFile(currentFile.id)
}
const handleRemovePhoto = () => {
if (currentFile) {
removeFile(currentFile.id)
}
setRemovedCurrentPhoto(true)
}
return (
<div className="flex grow flex-wrap items-center justify-start gap-2">
{/* Actions */}
<div className="relative">
<Avatar className="size-10">
<AvatarImage src={previewUrl ?? undefined} alt={alt} />
<AvatarFallback className="text-muted-foreground bg-muted">
<UserCircle aria-hidden="true" className="size-4 opacity-60" />
</AvatarFallback>
</Avatar>
{currentFile ? (
<Button
variant="outline"
size="icon-xs"
onClick={handleCancelUpload}
className="absolute -top-1 -right-1 size-4 rounded-full"
aria-label={`Cancel ${currentFile.file.name}`}
>
<XIcon aria-hidden="true" />
</Button>
) : null}
</div>
<div className="relative inline-flex">
<Button
variant="outline"
size="sm"
onClick={openFileDialog}
aria-haspopup="dialog"
>
<UploadIcon aria-hidden="true" />
{hasPhoto ? "Change" : "Upload"}
</Button>
<input
{...getInputProps({ id: inputId })}
className="sr-only"
aria-label="Upload profile image"
tabIndex={-1}
/>
</div>
{hasPhoto ? (
<Button variant="outline" size="sm" onClick={handleRemovePhoto}>
<XIcon aria-hidden="true" />
Remove
</Button>
) : null}
</div>
)
}
@@ -0,0 +1,217 @@
import { useState } from "react"
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
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 { Textarea } from "@evobgp/ui/components/textarea"
import { ROLES, TIMEZONES } from "./data"
import { ProfileAvatarUpload } from "./profile-avatar-upload"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
import { UserIcon } from "lucide-react"
export function ProfileTab() {
const [role, setRole] = useState("engineering-lead")
const [timezone, setTimezone] = useState("utc-8")
const handleRoleChange = createSelectValueHandler(setRole)
const handleTimezoneChange = createSelectValueHandler(setTimezone)
return (
<div className="space-y-6">
<Alert variant="warning">
<UserIcon aria-hidden="true" />
<AlertTitle>Complete your profile.</AlertTitle>
<AlertDescription>
Add a photo and keep your role and timezone current.
</AlertDescription>
<AlertAction>
<Button type="button" variant="outline" size="xs">
Dismiss
</Button>
<Button type="button" size="xs">
Update
</Button>
</AlertAction>
</Alert>
{/* Card */}
<SettingsCard
title="My profile"
description="Public account details"
footer={
<>
<Button variant="outline">Cancel</Button>
<Button>Save changes</Button>
</>
}
>
<SettingsFieldGroup
legend="Profile fields"
description="Update your personal profile details."
>
<SettingRow
title="Photo"
description="Shown in comments and mentions."
>
<ProfileAvatarUpload
defaultAvatar="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80"
alt="Alex Morgan"
inputId="settings-7-profile-photo"
/>
</SettingRow>
<SettingRow
title="Full name"
description="Used across the workspace."
labelFor="settings-7-full-name"
>
<Input id="settings-7-full-name" defaultValue="Alex Morgan" />
</SettingRow>
<SettingRow
title="Email address"
description="Primary sign-in email."
labelFor="settings-7-email"
titleAddon={<Badge variant="success-light">Verified</Badge>}
>
<Input
id="settings-7-email"
defaultValue="alex@acme.dev"
type="email"
/>
</SettingRow>
<SettingRow
title="Username"
description="Visible in mentions and links."
labelFor="settings-7-username"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>@</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-username"
defaultValue="alexmorgan"
/>
</InputGroup>
</SettingRow>
<SettingRow
title="Profile details"
description="Public details shared across the workspace."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Profile details</FieldLegend>
<FieldDescription className="sr-only">
Public profile and workspace defaults.
</FieldDescription>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="settings-7-role">Role</FieldLabel>
<Select value={role} onValueChange={handleRoleChange}>
<SelectTrigger id="settings-7-role" className="w-full">
<SelectValue>{getOptionLabel(ROLES, role)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ROLES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-timezone">
Time zone
</FieldLabel>
<Select value={timezone} onValueChange={handleTimezoneChange}>
<SelectTrigger id="settings-7-timezone" className="w-full">
<SelectValue>
{getOptionLabel(TIMEZONES, timezone)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TIMEZONES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-website">Website</FieldLabel>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-website"
defaultValue="alexmorgan.dev"
/>
</InputGroup>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
<SettingRow
title="Bio"
description="Short profile summary."
labelFor="settings-7-bio"
last
>
<Textarea
id="settings-7-bio"
defaultValue="Building developer tools at Acme. Previously at Vercel and Stripe."
rows={4}
className="min-h-24 resize-none"
/>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -0,0 +1,257 @@
"use client"
import { Fragment, useState } from "react"
import { Badge } from "@/components/reui/badge"
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 {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@evobgp/ui/components/item"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Switch } from "@evobgp/ui/components/switch"
import { RECOVERY_METHODS, SESSIONS } from "./data"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
import { LogOutIcon } from "lucide-react"
export function SecurityTab() {
const [recoveryMethod, setRecoveryMethod] = useState("authenticator")
const handleRecoveryMethodChange = createSelectValueHandler(setRecoveryMethod)
return (
<div className="space-y-6">
{/* Card */}
<SettingsCard
title="Change password"
description="Access protection"
footer={<Button>Update password</Button>}
>
<SettingsFieldGroup
legend="Password settings"
description="Update your password and keep your account protected."
>
<SettingRow
title="Current password"
description="Verify your identity before making changes."
labelFor="settings-7-current-password"
>
<Input
id="settings-7-current-password"
type="password"
placeholder="Enter current password"
/>
</SettingRow>
<SettingRow
title="New password"
description="Use at least 12 characters and a unique phrase."
contentClassName="@md/field-group:w-[22rem]"
last
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">New password</FieldLegend>
<FieldDescription className="sr-only">
Create and confirm the next password for this account.
</FieldDescription>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="settings-7-new-password">
New password
</FieldLabel>
<Input
id="settings-7-new-password"
type="password"
placeholder="Create a new password"
/>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-confirm-password">
Confirm password
</FieldLabel>
<Input
id="settings-7-confirm-password"
type="password"
placeholder="Confirm the new password"
/>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Two-step verification"
description="Extra sign-in checks"
>
<SettingsFieldGroup
legend="Two-step verification"
description="Add backup checks for future sign-ins."
>
<SettingRow
title="Authenticator app"
description="Use one-time codes from an app."
labelFor="settings-7-authenticator-app"
titleAddon={
<Badge variant="info-light" size="sm">
Recommended
</Badge>
}
>
<Switch id="settings-7-authenticator-app" defaultChecked />
</SettingRow>
<SettingRow
title="Recovery phone"
description="Used if you lose access to your authenticator app."
labelFor="settings-7-recovery-phone"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>+1</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-recovery-phone"
defaultValue="415 555 0148"
/>
</InputGroup>
</SettingRow>
<SettingRow
title="Delivery method"
description="Choose how backup verification requests are delivered."
labelFor="settings-7-recovery-method"
>
<Select
value={recoveryMethod}
onValueChange={handleRecoveryMethodChange}
>
<SelectTrigger id="settings-7-recovery-method" className="w-full">
<SelectValue>
{getOptionLabel(RECOVERY_METHODS, recoveryMethod)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{RECOVERY_METHODS.map((method) => (
<SelectItem key={method.value} value={method.value}>
{method.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
<SettingRow
title="Trusted devices"
description="Skip repeat prompts on known devices."
labelFor="settings-7-trusted-devices"
last
>
<Switch id="settings-7-trusted-devices" defaultChecked />
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard title="Active sessions" description="Signed-in devices">
<ItemGroup className="gap-0">
{SESSIONS.map((session, index) => (
<Fragment key={session.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null}
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
<ItemMedia className="self-center!">
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
{session.icon}
</Item>
</ItemMedia>
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
<ItemTitle className="gap-2 leading-5">
{session.browser} on {session.device}
{session.current ? (
<Badge variant="success-light" size="xs">
Current
</Badge>
) : null}
</ItemTitle>
<ItemDescription className="leading-5">
{session.location} · {session.lastActive}
</ItemDescription>
</ItemContent>
<ItemActions className="w-28 shrink-0 justify-end self-center">
{session.current ? (
<Button variant="outline" size="sm">
This device
</Button>
) : (
<Button variant="ghost" size="sm">
<LogOutIcon data-icon="inline-start" aria-hidden="true" />
Revoke
</Button>
)}
</ItemActions>
</Item>
</Fragment>
))}
</ItemGroup>
</SettingsCard>
<SettingsCard title="Delete account" description="Irreversible changes">
<SettingsFieldGroup
legend="Delete account"
description="Review irreversible actions before deleting your account."
>
<SettingRow
title="Close account"
description="Permanently remove your account, sessions, and recovery settings."
last
contentClassName="@md/field-group:w-[22rem]"
>
<div className="flex w-full flex-col items-start gap-2 @md/field-group:items-end">
<p className="text-muted-foreground text-xs leading-5">
This action cannot be undone.
</p>
<Button variant="destructive">Delete account</Button>
</div>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -0,0 +1,62 @@
import { type ReactNode } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from "@evobgp/ui/components/field"
interface SettingRowProps {
title: string
description?: ReactNode
children: ReactNode
last?: boolean
labelFor?: string
contentClassName?: string
titleAddon?: ReactNode
}
export function SettingRow({
title,
description,
children,
last,
labelFor,
contentClassName,
titleAddon,
}: SettingRowProps) {
return (
<>
<Field orientation="responsive" className="gap-4 px-5 py-3">
<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>
)}
{titleAddon}
</div>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : null}
</div>
<FieldContent
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
>
<div className="flex w-full justify-start @md/field-group:justify-end">
{children}
</div>
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
)
}
@@ -0,0 +1,53 @@
import { type ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
interface SettingsCardProps {
title: string
description?: string
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
footerClassName?: string
}
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
export function SettingsCard({
title,
description,
children,
footer,
className,
contentClassName,
footerClassName,
}: SettingsCardProps) {
return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
<FrameHeader className="gap-0 border-b px-5 py-3">
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
<div className={cn('min-w-0', contentClassName)}>{children}</div>
{footer ? (
<FrameFooter
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,37 @@
import { type ReactNode } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
interface SettingsFieldGroupProps {
legend: string
description: string
children: ReactNode
className?: string
fieldGroupClassName?: string
}
export function SettingsFieldGroup({
legend,
description,
children,
className,
fieldGroupClassName,
}: SettingsFieldGroupProps) {
return (
<FieldSet className={cn("gap-0", className)}>
<FieldLegend className="sr-only">{legend}</FieldLegend>
{/* Description */}
<FieldDescription className="sr-only">{description}</FieldDescription>
{/* List */}
<FieldGroup className={cn("gap-0", fieldGroupClassName)}>
{children}
</FieldGroup>
</FieldSet>
)
}
@@ -0,0 +1,22 @@
"use client"
import { type Dispatch, type SetStateAction } from "react"
import type { SelectOption } from "./data"
export function getOptionLabel<T extends SelectOption>(
options: T[],
value: string
) {
return options.find((option) => option.value === value)?.label ?? value
}
export function createSelectValueHandler(
setValue: Dispatch<SetStateAction<string>>
) {
return (value: string | null) => {
if (value !== null) {
setValue(value)
}
}
}
@@ -0,0 +1,9 @@
import { AccountSettings } from "./components/account-settings"
export function Page() {
return (
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 md:p-10">
<AccountSettings />
</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>
)
}
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
export interface CountedLineTab {
id: string
label: string
count?: number
}
interface CountedLineTabsProps {
tabs: CountedLineTab[]
value: string
onValueChange: (value: string) => void
className?: string
listClassName?: string
children?: ReactNode
}
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
export function CountedLineTabs({
tabs,
value,
onValueChange,
className,
listClassName,
children,
}: CountedLineTabsProps) {
return (
<Tabs value={value} onValueChange={onValueChange} className={className}>
<TabsList variant="line" className={cn('gap-5', listClassName)}>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>{tab.label}</span>
{tab.count !== undefined ? (
<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">
{tab.count}
</span>
) : null}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
)
}
@@ -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>
)
}

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