Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fc5c96e63 | ||
|
|
3859983ae5 | ||
|
|
aa1779170a | ||
|
|
bbcf9d76d9 | ||
|
|
55eb2a6c89 | ||
|
|
a3f3ffd672 | ||
|
|
1a142e68a9 | ||
|
|
46e7b2aa6b | ||
|
|
1152bb40f7 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 43636,
|
||||
"pid": 51184,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1783570299043
|
||||
"startedAt": 1784215907952
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101206}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101204}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138640}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138642}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605143758}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605144457}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605145907}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152669}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152671}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222064}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222067}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605819320}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605827145}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605864391}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606471768}
|
||||
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606472923}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ Thumbs.db
|
||||
|
||||
# Env / secrets (local only)
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,14 +40,12 @@ export function BadgeTabs({
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
orientation="horizontal"
|
||||
className={cn('w-full', className)}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className={cn(
|
||||
'mb-4 w-full justify-start gap-6',
|
||||
listClassName,
|
||||
)}
|
||||
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@evobgp/ui/components/breadcrumb"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { SidebarTrigger } from "@evobgp/ui/components/sidebar"
|
||||
import { APPS, ENVIRONMENTS, WORKSPACES } from "./data"
|
||||
import { Logo } from "./logo"
|
||||
import { SystemStats } from "./system-stats"
|
||||
import { ChevronsUpDownIcon, CheckIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
// ── Org Switcher ──
|
||||
|
||||
function OrgSwitcher() {
|
||||
const [activeOrg, setActiveOrg] = useState(WORKSPACES[0])
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm" className="text-foreground" />
|
||||
}
|
||||
>
|
||||
<activeOrg.Logo className="size-3" />
|
||||
<span className="sr-only sm:not-sr-only">{activeOrg.name}</span>
|
||||
<ChevronsUpDownIcon className="size-3.5 opacity-60" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-56">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Organizations</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{WORKSPACES.map((ws) => (
|
||||
<DropdownMenuItem
|
||||
key={ws.id}
|
||||
onClick={() => setActiveOrg(ws)}
|
||||
className={cn(activeOrg.id === ws.id && "bg-accent")}
|
||||
>
|
||||
<ws.Logo className="size-5 shrink-0" />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm">{ws.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{ws.tier}
|
||||
</span>
|
||||
</div>
|
||||
{activeOrg.id === ws.id && (
|
||||
<CheckIcon className="text-primary ml-auto size-4" aria-hidden="true" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Create Organization
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ── App Switcher ──
|
||||
|
||||
function AppSwitcher() {
|
||||
const [activeApp, setActiveApp] = useState(
|
||||
APPS.find((a) => a.isActive) ?? APPS[0]
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="text-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="hidden sm:inline">{activeApp.name}</span>
|
||||
<span className="sm:hidden">{activeApp.name.split(" ")[0]}</span>
|
||||
<ChevronsUpDownIcon className="size-3.5 opacity-60" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Applications</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{APPS.map((app) => (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
onClick={() => setActiveApp(app)}
|
||||
className={cn(activeApp.id === app.id && "bg-accent")}
|
||||
>
|
||||
{app.icon}
|
||||
{app.name}
|
||||
{activeApp.id === app.id && (
|
||||
<CheckIcon className="text-primary ml-auto size-4" aria-hidden="true" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Create Application
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Environment Switcher ──
|
||||
|
||||
function EnvironmentSwitcher() {
|
||||
const [activeEnv, setActiveEnv] = useState(
|
||||
ENVIRONMENTS.find((e) => e.isActive) ?? ENVIRONMENTS[0]
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="text-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>{activeEnv.name}</span>
|
||||
<ChevronsUpDownIcon className="size-3.5 opacity-60" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Environments</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{ENVIRONMENTS.map((env) => (
|
||||
<DropdownMenuItem
|
||||
key={env.id}
|
||||
onClick={() => setActiveEnv(env)}
|
||||
className={cn(activeEnv.id === env.id && "bg-accent")}
|
||||
>
|
||||
{env.icon}
|
||||
{env.name}
|
||||
{activeEnv.id === env.id && (
|
||||
<CheckIcon className="text-primary ml-auto size-4" aria-hidden="true" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Site Header ──
|
||||
|
||||
export function AppHeader() {
|
||||
return (
|
||||
<header className="bg-background sticky top-0 z-50 flex w-full items-center border-b">
|
||||
<div className="flex h-(--header-height) w-full items-center gap-2 px-4">
|
||||
{/* Mobile sidebar trigger */}
|
||||
<SidebarTrigger className="md:hidden" />
|
||||
|
||||
{/* Logo */}
|
||||
<div className="hidden items-center pl-0.5 md:flex">
|
||||
<Logo />
|
||||
</div>
|
||||
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList className="gap-1">
|
||||
<BreadcrumbItem>
|
||||
<OrgSwitcher />
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="text-xs opacity-60">
|
||||
/
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<AppSwitcher />
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="text-xs opacity-60">
|
||||
/
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<EnvironmentSwitcher />
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<SystemStats />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { type CSSProperties } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { SidebarInset, SidebarProvider } from "@evobgp/ui/components/sidebar"
|
||||
|
||||
import { AppHeader } from "./app-header"
|
||||
import { AppSidebar } from "./app-sidebar"
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<SidebarProvider
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
"[--sidebar:var(--color-background)]",
|
||||
"[--sidebar-accent:color-mix(in_oklab,var(--color-primary)_5%,transparent)]",
|
||||
"[--sidebar-accent-foreground:var(--color-primary)]"
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "260px",
|
||||
"--sidebar-width-icon": "62px",
|
||||
"--header-height": "50px",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<AppHeader />
|
||||
<div className="flex flex-1">
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="border-border/40 bg-muted/40 aspect-video rounded-lg border border-dashed" />
|
||||
<div className="border-border/40 bg-muted/40 aspect-video rounded-lg border border-dashed" />
|
||||
<div className="border-border/40 bg-muted/40 aspect-video rounded-lg border border-dashed" />
|
||||
</div>
|
||||
<div className="border-border/40 bg-muted/40 min-h-[1000px] flex-1 rounded-lg border border-dashed" />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
useSidebar,
|
||||
} from "@evobgp/ui/components/sidebar"
|
||||
|
||||
import { NEWS_ARTICLES } from "./data"
|
||||
import { NavMain } from "./nav-main"
|
||||
import { NavUser } from "./nav-user"
|
||||
import { SearchForm } from "./search-form"
|
||||
import { SidebarNews } from "./sidebar-news"
|
||||
import { SidebarRailToggle } from "./sidebar-rail-toggle"
|
||||
|
||||
// ── App Sidebar ──
|
||||
|
||||
export function AppSidebar() {
|
||||
const { isMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
className="top-(--header-height) h-[calc(100svh-var(--header-height))]!"
|
||||
>
|
||||
{/* Header */}
|
||||
<SidebarHeader className="flex flex-row items-center px-4 in-data-[state=collapsed]:flex-col in-data-[state=collapsed]:items-start in-data-[state=collapsed]:justify-center">
|
||||
<div className="w-full flex-1 pt-2">
|
||||
<SearchForm />
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
{/* Sidebar */}
|
||||
<SidebarContent className="gap-2 px-2">
|
||||
<NavMain />
|
||||
|
||||
<div className="mt-auto">
|
||||
<SidebarNews articles={NEWS_ARTICLES} />
|
||||
</div>
|
||||
</SidebarContent>
|
||||
|
||||
{/* Footer */}
|
||||
{/* customize: mt-2 keeps a fixed gap above the footer so the news card never butts against it when the viewport shrinks and the content scrolls */}
|
||||
<SidebarFooter className="mt-2 px-4">
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
|
||||
{!isMobile && <SidebarRailToggle />}
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { type ComponentType, type ReactNode } from "react"
|
||||
import { GlobeIcon, ShieldIcon, SmartphoneIcon, RocketIcon, FlaskConicalIcon, CodeIcon, LayoutDashboardIcon, UsersIcon, Building2Icon, ShieldCheckIcon, WebhookIcon, KeyRoundIcon, CpuIcon, MonitorIcon, MemoryStickIcon, WifiIcon } from "lucide-react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type NavChild = {
|
||||
id: string
|
||||
label: string
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export type NavItem = {
|
||||
id: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
badge?: string | number
|
||||
isActive?: boolean
|
||||
children?: NavChild[]
|
||||
}
|
||||
|
||||
export type Workspace = {
|
||||
id: string
|
||||
name: string
|
||||
tier: string
|
||||
Logo: ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
export type App = {
|
||||
id: string
|
||||
name: string
|
||||
icon: ReactNode
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export type Environment = {
|
||||
id: string
|
||||
name: string
|
||||
icon: ReactNode
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export type NewsArticle = {
|
||||
href: string
|
||||
title: string
|
||||
summary: string
|
||||
image: string
|
||||
}
|
||||
|
||||
export type ResourceMetric = {
|
||||
id: string
|
||||
label: string
|
||||
unit: string
|
||||
icon: ReactNode
|
||||
seedRange: [number, number]
|
||||
color: string
|
||||
spikeThreshold: number
|
||||
}
|
||||
|
||||
export type Agent = {
|
||||
id: string
|
||||
name: string
|
||||
memoryMb: number
|
||||
color: string
|
||||
}
|
||||
|
||||
// ── Logos ──
|
||||
|
||||
// Acme: indigo → violet → fuchsia diagonal sweep
|
||||
function AcmeLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="ws-acme"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="28"
|
||||
y2="28"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#4f46e5" />
|
||||
<stop offset="35%" stopColor="#7c3aed" />
|
||||
<stop offset="70%" stopColor="#a21caf" />
|
||||
<stop offset="100%" stopColor="#db2777" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="14" cy="14" r="14" fill="url(#ws-acme)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Starter: rose → orange → amber → lime horizontal sweep
|
||||
function StarterLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="ws-starter"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="28"
|
||||
y2="28"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#f43f5e" />
|
||||
<stop offset="33%" stopColor="#f97316" />
|
||||
<stop offset="66%" stopColor="#eab308" />
|
||||
<stop offset="100%" stopColor="#84cc16" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="14" cy="14" r="14" fill="url(#ws-starter)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Enterprise: sky → blue → indigo vertical sweep
|
||||
function EnterpriseLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="ws-enterprise"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="28"
|
||||
y2="28"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#38bdf8" />
|
||||
<stop offset="35%" stopColor="#3b82f6" />
|
||||
<stop offset="70%" stopColor="#6366f1" />
|
||||
<stop offset="100%" stopColor="#14b8a6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="14" cy="14" r="14" fill="url(#ws-enterprise)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const USER = {
|
||||
name: "Nick Bold",
|
||||
email: "nick@reui.io",
|
||||
initials: "NB",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
|
||||
} as const
|
||||
|
||||
export const WORKSPACES: Workspace[] = [
|
||||
{ id: "acme", name: "Acme Inc", tier: "Pro", Logo: AcmeLogo },
|
||||
{ id: "starter", name: "Starter Kit", tier: "Free", Logo: StarterLogo },
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "Enterprise",
|
||||
tier: "Enterprise",
|
||||
Logo: EnterpriseLogo,
|
||||
},
|
||||
]
|
||||
|
||||
export const APPS: App[] = [
|
||||
{
|
||||
id: "web-app",
|
||||
name: "Web App",
|
||||
isActive: true,
|
||||
icon: (
|
||||
<GlobeIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
name: "Admin Panel",
|
||||
icon: (
|
||||
<ShieldIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "mobile",
|
||||
name: "Mobile App",
|
||||
icon: (
|
||||
<SmartphoneIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export const ENVIRONMENTS: Environment[] = [
|
||||
{
|
||||
id: "production",
|
||||
name: "Production",
|
||||
isActive: true,
|
||||
icon: (
|
||||
<RocketIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "staging",
|
||||
name: "Staging",
|
||||
icon: (
|
||||
<FlaskConicalIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "development",
|
||||
name: "Development",
|
||||
icon: (
|
||||
<CodeIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export const NAV_MAIN: NavItem[] = [
|
||||
{
|
||||
id: "overview",
|
||||
label: "Overview",
|
||||
isActive: true,
|
||||
icon: (
|
||||
<LayoutDashboardIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
label: "Users",
|
||||
icon: (
|
||||
<UsersIcon aria-hidden="true" />
|
||||
),
|
||||
children: [
|
||||
{ id: "all-users", label: "All Users" },
|
||||
{ id: "invitations", label: "Invitations" },
|
||||
{ id: "roles", label: "Roles", isActive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "organizations",
|
||||
label: "Organizations",
|
||||
icon: (
|
||||
<Building2Icon aria-hidden="true" />
|
||||
),
|
||||
children: [
|
||||
{ id: "org-list", label: "All Organizations" },
|
||||
{ id: "org-settings", label: "Settings" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
label: "Security",
|
||||
icon: (
|
||||
<ShieldCheckIcon aria-hidden="true" />
|
||||
),
|
||||
children: [
|
||||
{ id: "attack-protection", label: "Attack Protection" },
|
||||
{ id: "fraud", label: "Fraud Detection" },
|
||||
{ id: "audit-log", label: "Audit Log" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "webhooks",
|
||||
label: "Webhooks",
|
||||
icon: (
|
||||
<WebhookIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: "API Keys",
|
||||
icon: (
|
||||
<KeyRoundIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export const RESOURCE_METRICS: ResourceMetric[] = [
|
||||
{
|
||||
id: "cpu",
|
||||
label: "CPU",
|
||||
unit: "%",
|
||||
icon: (
|
||||
<CpuIcon className="size-3 shrink-0 transition-colors duration-300" aria-hidden="true" />
|
||||
),
|
||||
seedRange: [20, 65],
|
||||
color: "var(--color-blue-500)",
|
||||
spikeThreshold: 60,
|
||||
},
|
||||
{
|
||||
id: "gpu",
|
||||
label: "GPU",
|
||||
unit: "%",
|
||||
icon: (
|
||||
<MonitorIcon className="size-3 shrink-0 transition-colors duration-300" aria-hidden="true" />
|
||||
),
|
||||
seedRange: [10, 50],
|
||||
color: "var(--color-emerald-500)",
|
||||
spikeThreshold: 45,
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
label: "Memory",
|
||||
unit: "GB",
|
||||
icon: (
|
||||
<MemoryStickIcon className="size-3 shrink-0 transition-colors duration-300" aria-hidden="true" />
|
||||
),
|
||||
seedRange: [4, 12],
|
||||
color: "var(--color-amber-500)",
|
||||
spikeThreshold: 12,
|
||||
},
|
||||
{
|
||||
id: "network",
|
||||
label: "Network",
|
||||
unit: "Mbps",
|
||||
icon: (
|
||||
<WifiIcon className="size-3 shrink-0 transition-colors duration-300" aria-hidden="true" />
|
||||
),
|
||||
seedRange: [5, 80],
|
||||
color: "var(--color-violet-500)",
|
||||
spikeThreshold: 70,
|
||||
},
|
||||
]
|
||||
|
||||
export const AGENTS: Agent[] = [
|
||||
{
|
||||
id: "ingest",
|
||||
name: "Ingest Worker",
|
||||
memoryMb: 256,
|
||||
color: "var(--color-indigo-500)",
|
||||
},
|
||||
{
|
||||
id: "transform",
|
||||
name: "Transform Pipeline",
|
||||
memoryMb: 512,
|
||||
color: "var(--color-purple-500)",
|
||||
},
|
||||
{
|
||||
id: "query",
|
||||
name: "Query Engine",
|
||||
memoryMb: 384,
|
||||
color: "var(--color-sky-500)",
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
name: "Export Service",
|
||||
memoryMb: 128,
|
||||
color: "var(--color-teal-500)",
|
||||
},
|
||||
]
|
||||
|
||||
export const NEWS_ARTICLES: NewsArticle[] = [
|
||||
{
|
||||
href: "https://pro.reui.io/changelog?v=2.0.3",
|
||||
title: "Multi-theme support is here",
|
||||
summary:
|
||||
"Switch between Vega, Nova, Maia, Lyra, and Mira themes across all components.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=400&h=225&fit=crop&q=80",
|
||||
},
|
||||
{
|
||||
href: "https://pro.reui.io/changelog?v=2.0.2",
|
||||
title: "Icon library v2 released",
|
||||
summary:
|
||||
"Five icon libraries unified under a single API with automatic mapping.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1579546929518-9e396f3cc809?w=400&h=225&fit=crop&q=80",
|
||||
},
|
||||
{
|
||||
href: "https://pro.reui.io/changelog?v=2.0.1",
|
||||
title: "Sidebar layout patterns guide",
|
||||
summary:
|
||||
"Learn best practices for building responsive sidebar layouts with collapsible navigation.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1541701494587-cb58502866ab?w=400&h=225&fit=crop&q=80",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
|
||||
export function Logo() {
|
||||
return (
|
||||
<Item
|
||||
className="bg-primary text-primary-foreground flex size-7 shrink-0 items-center justify-center p-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<svg
|
||||
width="50"
|
||||
height="50"
|
||||
viewBox="25.668 25.1352 49.6644 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-3.5"
|
||||
>
|
||||
<circle cx="70.634" cy="29.8334" r="4.69799" fill="currentColor" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M25.668 57.0144V29.8332C25.668 27.2386 27.7713 25.1352 30.366 25.1352C32.9606 25.1352 35.0639 27.2386 35.0639 29.8332V57.0144C35.0639 61.833 38.9702 65.7392 43.7888 65.7392H57.2116C62.0302 65.7392 65.9364 61.833 65.9364 57.0144V43.7258C65.9364 41.1312 68.0398 39.0278 70.6344 39.0278C73.229 39.0278 75.3324 41.1312 75.3324 43.7258V57.0144C75.3324 67.0222 67.2194 75.1352 57.2116 75.1352H43.7888C33.7809 75.1352 25.668 67.0222 25.668 57.0144Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@evobgp/ui/components/sidebar"
|
||||
import { NAV_MAIN, type NavChild, type NavItem } from "./data"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
|
||||
// ── Nav Sub Items ──
|
||||
|
||||
function NavSubItem({ child }: { child: NavChild }) {
|
||||
return (
|
||||
<SidebarMenuSubItem>
|
||||
{/* Sidebar */}
|
||||
<SidebarMenuSubButton render={<a href="#" />} isActive={child.isActive}>
|
||||
{child.label}
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
}
|
||||
|
||||
function NavSubMenu({ id, items }: { id: string; items: NavChild[] }) {
|
||||
return (
|
||||
<SidebarMenuSub id={`subnav-${id}`}>
|
||||
{items.map((child) => (
|
||||
<NavSubItem key={child.id} child={child} />
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Collapsible Nav Item ──
|
||||
|
||||
export function CollapsibleNavItem({
|
||||
item,
|
||||
}: {
|
||||
item: NavItem & { children: NavChild[] }
|
||||
}) {
|
||||
const [open, setOpen] = useState(() => item.children.some((c) => c.isActive))
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
{/* Sidebar */}
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
isActive={item.isActive}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
aria-expanded={open}
|
||||
aria-controls={`subnav-${item.id}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
<ChevronRightIcon className={cn(
|
||||
"ml-auto size-4 shrink-0 opacity-60 transition-transform duration-200 group-data-[collapsible=icon]:hidden",
|
||||
open && "rotate-90"
|
||||
)} aria-hidden="true" />
|
||||
</SidebarMenuButton>
|
||||
|
||||
{open && <NavSubMenu id={item.id} items={item.children} />}
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Leaf Nav Item ──
|
||||
|
||||
export function LeafNavItem({ item }: { item: NavItem }) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
{/* Sidebar */}
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
isActive={item.isActive}
|
||||
render={<a href="#" />}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
{item.badge !== undefined && (
|
||||
<SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Nav Main ──
|
||||
|
||||
export function NavMain() {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
{/* Sidebar */}
|
||||
<SidebarGroupLabel className="in-data-[state=collapsed]:hidden">
|
||||
Platform
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-0.25">
|
||||
{NAV_MAIN.map((item) =>
|
||||
item.children ? (
|
||||
<CollapsibleNavItem
|
||||
key={item.id}
|
||||
item={item as NavItem & { children: NavChild[] }}
|
||||
/>
|
||||
) : (
|
||||
<LeafNavItem key={item.id} item={item} />
|
||||
)
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@evobgp/ui/components/sidebar"
|
||||
import { USER } from "./data"
|
||||
import { SunIcon, MoonIcon, MonitorIcon, ChevronsUpDownIcon, UserIcon, SettingsIcon, InboxIcon, PaletteIcon, LogOutIcon } from "lucide-react"
|
||||
|
||||
// ── Theme Toggle ──
|
||||
|
||||
const THEMES = [
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
icon: (
|
||||
<SunIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
icon: (
|
||||
<MoonIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "system",
|
||||
label: "System",
|
||||
icon: (
|
||||
<MonitorIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function ThemeSegmentedToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const currentTheme = mounted ? (theme ?? "system") : "system"
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Theme"
|
||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||
>
|
||||
{THEMES.map(({ value, label, icon }) => {
|
||||
const isActive = currentTheme === value
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
isActive
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Nav User ──
|
||||
|
||||
export function NavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{/* Sidebar */}
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:justify-center"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar className="size-7 rounded-md">
|
||||
<AvatarImage src={USER.avatar} alt={USER.name} />
|
||||
<AvatarFallback className="bg-primary text-primary-foreground rounded-md text-xs font-semibold">
|
||||
{USER.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-medium">{USER.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{USER.email}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4 opacity-50 group-data-[collapsible=icon]:hidden" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
className="w-56"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="flex items-center gap-2.5 py-2">
|
||||
<Avatar className="size-8 rounded-md">
|
||||
<AvatarImage src={USER.avatar} alt={USER.name} />
|
||||
<AvatarFallback className="bg-primary text-primary-foreground rounded-md text-xs font-semibold">
|
||||
{USER.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-foreground text-sm font-semibold">
|
||||
{USER.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs font-normal">
|
||||
{USER.email}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<UserIcon aria-hidden="true" />
|
||||
Profile
|
||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
Preferences
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<InboxIcon aria-hidden="true" />
|
||||
Manage Accounts
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="cursor-default focus:bg-transparent!"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<PaletteIcon aria-hidden="true" />
|
||||
Theme
|
||||
<div className="ml-auto">
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem>
|
||||
<LogOutIcon aria-hidden="true" />
|
||||
Sign Out
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useId, useState, type ComponentProps } from "react"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@evobgp/ui/components/dialog"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import { Kbd } from "@evobgp/ui/components/kbd"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
} from "@evobgp/ui/components/sidebar"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
export function SearchForm({ ...props }: ComponentProps<"form">) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const searchInputId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
return () => window.removeEventListener("keydown", onKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<form {...props}>
|
||||
{/* Sidebar */}
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarGroupContent className="relative">
|
||||
<Button
|
||||
id="search"
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Search...
|
||||
</Button>
|
||||
<SearchIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none" />
|
||||
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
|
||||
⌘K
|
||||
</Kbd>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
<DialogDescription>Search your workspace content.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md px-4 py-2 **:data-[slot=dialog-close]:top-1/2 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:-translate-y-1/2 **:data-[slot=dialog-close]:opacity-60">
|
||||
<div className="relative flex items-center gap-3">
|
||||
<SearchIcon aria-hidden="true" className="pointer-events-none size-4 opacity-60 select-none" />
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Type to search..."
|
||||
aria-label="Search"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
import { type NewsArticle } from "./data"
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const OFFSET_FACTOR = 4
|
||||
const SCALE_FACTOR = 0.03
|
||||
const OPACITY_FACTOR = 0.1
|
||||
const COMPLETED_DISPLAY_MS = 2700
|
||||
|
||||
// ── News Card ──
|
||||
|
||||
function NewsCard({
|
||||
title,
|
||||
summary,
|
||||
image,
|
||||
onDismiss,
|
||||
hideContent,
|
||||
href,
|
||||
active,
|
||||
}: {
|
||||
title: string
|
||||
summary: string
|
||||
image?: string
|
||||
onDismiss?: () => void
|
||||
hideContent?: boolean
|
||||
href?: string
|
||||
active?: boolean
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const drag = useRef<{
|
||||
start: number
|
||||
delta: number
|
||||
startTime: number
|
||||
maxDelta: number
|
||||
}>({ start: 0, delta: 0, startTime: 0, maxDelta: 0 })
|
||||
const animation = useRef<Animation>(undefined)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const onDragMove = (e: globalThis.PointerEvent) => {
|
||||
if (!ref.current) return
|
||||
const dx = e.clientX - drag.current.start
|
||||
drag.current.delta = dx
|
||||
drag.current.maxDelta = Math.max(drag.current.maxDelta, Math.abs(dx))
|
||||
ref.current.style.setProperty("--dx", dx.toString())
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
if (!ref.current) return
|
||||
const cardWidth = ref.current.getBoundingClientRect().width
|
||||
const translateX = Math.sign(drag.current.delta) * cardWidth
|
||||
|
||||
animation.current = ref.current.animate(
|
||||
{ opacity: 0, transform: `translateX(${translateX}px)` },
|
||||
{ duration: 150, easing: "ease-in-out", fill: "forwards" }
|
||||
)
|
||||
animation.current.onfinish = () => onDismiss?.()
|
||||
}
|
||||
|
||||
const stopDragging = (cancelled: boolean) => {
|
||||
unbindListeners()
|
||||
if (!ref.current) return
|
||||
setDragging(false)
|
||||
|
||||
const dx = drag.current.delta
|
||||
if (Math.abs(dx) > ref.current.clientWidth / (cancelled ? 2 : 3)) {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
|
||||
animation.current = ref.current.animate(
|
||||
{ transform: "translateX(0)" },
|
||||
{ duration: 150, easing: "ease-in-out" }
|
||||
)
|
||||
animation.current.onfinish = () =>
|
||||
ref.current?.style.setProperty("--dx", "0")
|
||||
|
||||
drag.current = { start: 0, delta: 0, startTime: 0, maxDelta: 0 }
|
||||
}
|
||||
|
||||
const onDragEnd = () => stopDragging(false)
|
||||
const onDragCancel = () => stopDragging(true)
|
||||
|
||||
const onPointerDown = (e: ReactPointerEvent) => {
|
||||
if (!active || !ref.current || animation.current?.playState === "running")
|
||||
return
|
||||
|
||||
bindListeners()
|
||||
setDragging(true)
|
||||
drag.current.start = e.clientX
|
||||
drag.current.startTime = Date.now()
|
||||
drag.current.delta = 0
|
||||
ref.current.style.setProperty("--w", ref.current.clientWidth.toString())
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
if (!ref.current || !href) return
|
||||
if (
|
||||
drag.current.maxDelta < ref.current.clientWidth / 10 &&
|
||||
(!drag.current.startTime || Date.now() - drag.current.startTime < 250)
|
||||
) {
|
||||
window.open(href, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
}
|
||||
|
||||
const bindListeners = () => {
|
||||
document.addEventListener("pointermove", onDragMove)
|
||||
document.addEventListener("pointerup", onDragEnd)
|
||||
document.addEventListener("pointercancel", onDragCancel)
|
||||
}
|
||||
|
||||
const unbindListeners = () => {
|
||||
document.removeEventListener("pointermove", onDragMove)
|
||||
document.removeEventListener("pointerup", onDragEnd)
|
||||
document.removeEventListener("pointercancel", onDragCancel)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-border bg-card relative cursor-grab border p-3 text-[0.8125rem] shadow-xs select-none",
|
||||
"rounded-lg",
|
||||
"translate-x-[calc(var(--dx)*1px)] rotate-[calc(var(--dx)*0.05deg)] opacity-[calc(1-max(var(--dx),-1*var(--dx))/var(--w)/2)]",
|
||||
"transition-shadow data-[dragging=true]:cursor-grabbing data-[dragging=true]:shadow-md"
|
||||
)}
|
||||
style={{ "--dx": 0, "--w": 1 } as CSSProperties}
|
||||
data-dragging={dragging}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={cn(hideContent && "invisible")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-foreground line-clamp-1 font-medium">
|
||||
{title}
|
||||
</span>
|
||||
<p className="text-muted-foreground line-clamp-2 h-10 leading-5">
|
||||
{summary}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/40 bg-muted relative mt-3 aspect-video w-full shrink-0 overflow-hidden border",
|
||||
"rounded-md"
|
||||
)}
|
||||
>
|
||||
{image && (
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="size-full object-cover object-center"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-rows-[0fr] overflow-hidden opacity-0 transition-[grid-template-rows,opacity] duration-200",
|
||||
"sm:group-has-[*[data-dragging=true]]:grid-rows-[1fr] sm:group-has-[*[data-dragging=true]]:opacity-100",
|
||||
"sm:group-hover:group-data-[active=true]:grid-rows-[1fr] sm:group-hover:group-data-[active=true]:opacity-100",
|
||||
"has-focus-visible:grid-rows-[1fr] has-focus-visible:opacity-100"
|
||||
)}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
<div className="flex items-center justify-between pt-3 text-xs">
|
||||
<a
|
||||
href={href ?? "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-foreground font-medium transition-colors duration-75"
|
||||
>
|
||||
Read more
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
dismiss()
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors duration-75"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sidebar News ──
|
||||
|
||||
export function SidebarNews({ articles }: { articles: NewsArticle[] }) {
|
||||
const [dismissedIds, setDismissedIds] = useState<string[]>([])
|
||||
const cards = articles.filter(({ href }) => !dismissedIds.includes(href))
|
||||
const cardCount = cards.length
|
||||
const [showCompleted, setShowCompleted] = useState(cardCount > 0)
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
if (cardCount === 0) {
|
||||
timeout = setTimeout(() => setShowCompleted(false), COMPLETED_DISPLAY_MS)
|
||||
}
|
||||
return () => clearTimeout(timeout)
|
||||
}, [cardCount])
|
||||
|
||||
if (!cards.length && !showCompleted) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group px-3 in-data-[state=collapsed]:hidden"
|
||||
data-active={cardCount !== 0}
|
||||
>
|
||||
<div className="relative size-full">
|
||||
{cards.toReversed().map(({ href, title, summary, image }, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"absolute top-0 left-0 size-full scale-(--scale) transition-[opacity,transform] duration-200",
|
||||
cardCount - idx > 3
|
||||
? [
|
||||
"opacity-0 sm:group-hover:translate-y-(--y) sm:group-hover:opacity-(--opacity)",
|
||||
"sm:group-has-[*[data-dragging=true]]:translate-y-(--y) sm:group-has-[*[data-dragging=true]]:opacity-(--opacity)",
|
||||
]
|
||||
: "translate-y-(--y) opacity-(--opacity)"
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--y": `-${(cardCount - (idx + 1)) * OFFSET_FACTOR}%`,
|
||||
"--scale": 1 - (cardCount - (idx + 1)) * SCALE_FACTOR,
|
||||
"--opacity":
|
||||
cardCount - (idx + 1) >= 6
|
||||
? 0
|
||||
: 1 - (cardCount - (idx + 1)) * OPACITY_FACTOR,
|
||||
} as CSSProperties
|
||||
}
|
||||
aria-hidden={idx !== cardCount - 1}
|
||||
{...(idx !== cardCount - 1 ? { inert: true } : {})}
|
||||
>
|
||||
<NewsCard
|
||||
title={title}
|
||||
summary={summary}
|
||||
image={image}
|
||||
href={href}
|
||||
hideContent={cardCount - idx > 2}
|
||||
active={idx === cardCount - 1}
|
||||
onDismiss={() =>
|
||||
setDismissedIds([href, ...dismissedIds.slice(0, 50)])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Invisible spacer to hold layout height */}
|
||||
<div className="pointer-events-none invisible" aria-hidden="true">
|
||||
<NewsCard title="Title" summary="Description" />
|
||||
</div>
|
||||
|
||||
{/* All-caught-up state */}
|
||||
{showCompleted && !cardCount && (
|
||||
<div className="absolute inset-0 flex size-full flex-col items-center justify-end gap-3 pb-4">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
You're all caught up!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { useSidebar } from "@evobgp/ui/components/sidebar"
|
||||
|
||||
export function SidebarRailToggle() {
|
||||
const { state, toggleSidebar } = useSidebar()
|
||||
const isExpanded = state === "expanded"
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
||||
onClick={toggleSidebar}
|
||||
style={{
|
||||
left: isExpanded ? "var(--sidebar-width)" : "var(--sidebar-width-icon)",
|
||||
}}
|
||||
className={cn(
|
||||
"group/rail fixed z-30 flex h-12 w-7 cursor-pointer items-center pl-2 outline-none",
|
||||
"top-[calc(var(--header-height)+50%)] -translate-y-1/2",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-1",
|
||||
"focus-visible:rounded-sm",
|
||||
"transition-[left] duration-200 ease-linear"
|
||||
)}
|
||||
>
|
||||
<span className="flex flex-col items-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-foreground/40 block h-2 w-0.5 rounded-t-full",
|
||||
"origin-bottom transition-all duration-100 ease-linear",
|
||||
isExpanded
|
||||
? "group-hover/rail:bg-foreground/60 group-hover/rail:rotate-40"
|
||||
: "group-hover/rail:bg-foreground/60 group-hover/rail:-rotate-40"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-foreground/40 block h-2 w-0.5 rounded-b-full",
|
||||
"origin-top transition-all duration-100 ease-linear",
|
||||
isExpanded
|
||||
? "group-hover/rail:bg-foreground/60 group-hover/rail:-rotate-40"
|
||||
: "group-hover/rail:bg-foreground/60 group-hover/rail:rotate-40"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"border-border bg-foreground text-background absolute left-full -ml-2 border px-2 py-0.5 text-[11px] font-medium whitespace-nowrap shadow-xs shadow-black/5",
|
||||
"rounded-md",
|
||||
"pointer-events-none -translate-x-0.5 opacity-0 transition-all duration-200 ease-out",
|
||||
"group-hover/rail:translate-x-0 group-hover/rail:opacity-100"
|
||||
)}
|
||||
>
|
||||
{isExpanded ? "Collapse" : "Expand"}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Badge } from "@evobgp/ui/components/badge"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { Progress } from "@evobgp/ui/components/progress"
|
||||
import {
|
||||
AGENTS,
|
||||
RESOURCE_METRICS,
|
||||
type Agent,
|
||||
type ResourceMetric,
|
||||
} from "./data"
|
||||
import { ActivityIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
const HISTORY_LENGTH = 20
|
||||
const UPDATE_INTERVAL_MS = 1000
|
||||
const SPIKE_COLOR = "var(--color-red-500)"
|
||||
const STATS_RANDOM_SEED = 1337
|
||||
const DEMO_REFERENCE_DATE = new Date("2026-06-12T09:24:00")
|
||||
|
||||
const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
|
||||
// Seeded PRNG (mulberry32) keeps the demo metrics deterministic across
|
||||
// renders and reloads while preserving the organic feel of live jitter.
|
||||
function createSeededRandom(seed: number) {
|
||||
let state = seed
|
||||
|
||||
return function nextRandom() {
|
||||
state = (state + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(state ^ (state >>> 15), state | 1)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), t | 61)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const nextRandom = createSeededRandom(STATS_RANDOM_SEED)
|
||||
|
||||
function randomInRange(min: number, max: number) {
|
||||
return Math.round((min + nextRandom() * (max - min)) * 10) / 10
|
||||
}
|
||||
|
||||
function seedHistory(min: number, max: number): number[] {
|
||||
return Array.from({ length: HISTORY_LENGTH }, () => randomInRange(min, max))
|
||||
}
|
||||
|
||||
function smoothPath(pts: { x: number; y: number }[]): string {
|
||||
if (pts.length === 0) return ""
|
||||
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const prev = pts[i - 1]
|
||||
const curr = pts[i]
|
||||
const cx = (prev.x + curr.x) / 2
|
||||
d += ` C ${cx} ${prev.y}, ${cx} ${curr.y}, ${curr.x} ${curr.y}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ── Sparkline Area (full-width responsive) ──
|
||||
|
||||
function SparklineArea({
|
||||
data,
|
||||
max,
|
||||
color,
|
||||
spikeThreshold,
|
||||
height = 36,
|
||||
}: {
|
||||
data: number[]
|
||||
max: number
|
||||
color: string
|
||||
spikeThreshold: number
|
||||
height?: number
|
||||
}) {
|
||||
const gradientId = useId()
|
||||
const W = 100
|
||||
const H = height
|
||||
const currentValue = data[data.length - 1] ?? 0
|
||||
const hasSpike = currentValue > spikeThreshold
|
||||
const activeColor = hasSpike ? SPIKE_COLOR : color
|
||||
|
||||
const pts = data.map((value, i) => ({
|
||||
x: data.length < 2 ? 0 : (i / (data.length - 1)) * W,
|
||||
y: H - (Math.min(value, max) / max) * (H - 2),
|
||||
}))
|
||||
|
||||
const linePath = smoothPath(pts)
|
||||
const areaPath = pts.length > 1 ? `${linePath} L ${W} ${H} L 0 ${H} Z` : ""
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
className="w-full"
|
||||
style={{ height: `${H}px` }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={activeColor} stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor={activeColor} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{areaPath && <path d={areaPath} fill={`url(#${gradientId})`} />}
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={activeColor}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Mini Sparkline (for agent rows) ──
|
||||
|
||||
function MiniSparkline({ data, color }: { data: number[]; color: string }) {
|
||||
const gradientId = useId()
|
||||
const W = 40
|
||||
const H = 14
|
||||
const dataMax = Math.max(...data, 1)
|
||||
|
||||
const pts = data.map((value, i) => ({
|
||||
x: data.length < 2 ? 0 : (i / (data.length - 1)) * W,
|
||||
y: H - (value / dataMax) * (H - 1),
|
||||
}))
|
||||
|
||||
const linePath = smoothPath(pts)
|
||||
const areaPath = pts.length > 1 ? `${linePath} L ${W} ${H} L 0 ${H} Z` : ""
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
width={W}
|
||||
height={H}
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{areaPath && <path d={areaPath} fill={`url(#${gradientId})`} />}
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Resource Card (grid cell) ──
|
||||
|
||||
function ResourceCard({
|
||||
metric,
|
||||
history,
|
||||
}: {
|
||||
metric: ResourceMetric
|
||||
history: number[]
|
||||
}) {
|
||||
const current = history[history.length - 1] ?? 0
|
||||
const max = metric.id === "memory" ? 16 : 100
|
||||
const isHigh = current > metric.spikeThreshold
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item
|
||||
className="flex size-5 shrink-0 items-center justify-center p-0 transition-colors duration-300"
|
||||
style={{
|
||||
backgroundColor: `${isHigh ? SPIKE_COLOR : metric.color}18`,
|
||||
}}
|
||||
>
|
||||
<ItemMedia
|
||||
variant="icon"
|
||||
className="size-auto"
|
||||
style={{ color: isHigh ? SPIKE_COLOR : metric.color }}
|
||||
>
|
||||
{metric.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-muted-foreground truncate text-[11px]">
|
||||
{metric.label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="shrink-0 text-xs font-semibold tabular-nums transition-colors duration-300"
|
||||
style={{ color: isHigh ? SPIKE_COLOR : metric.color }}
|
||||
>
|
||||
{current}
|
||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">
|
||||
{metric.unit}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<SparklineArea
|
||||
data={history}
|
||||
max={max}
|
||||
color={metric.color}
|
||||
spikeThreshold={metric.spikeThreshold}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Agent Memory Row ──
|
||||
|
||||
type AgentWithHistory = Agent & { history: number[] }
|
||||
|
||||
function AgentMemoryRow({ agent }: { agent: AgentWithHistory }) {
|
||||
const barPercent = Math.min(100, (agent.memoryMb / 512) * 100)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: agent.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground min-w-0 flex-1 truncate text-xs">
|
||||
{agent.name}
|
||||
</span>
|
||||
<Progress
|
||||
value={barPercent}
|
||||
className="w-12 **:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
|
||||
style={{ "--bar-color": agent.color } as CSSProperties}
|
||||
/>
|
||||
<span className="text-muted-foreground w-10 text-right text-[10px] tabular-nums">
|
||||
{agent.memoryMb}MB
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Status Badge ──
|
||||
|
||||
function StatusBadge({ spiking }: { spiking: boolean }) {
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
"h-4 border-0 px-1.5 text-[10px] font-medium",
|
||||
spiking
|
||||
? "bg-destructive/10 text-destructive dark:bg-destructive/20"
|
||||
: "bg-success/10 text-success dark:bg-success/20"
|
||||
)}
|
||||
>
|
||||
{spiking ? "Alert" : "Normal"}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// ── System Stats ──
|
||||
|
||||
export function SystemStats() {
|
||||
const [histories, setHistories] = useState<Record<string, number[]>>(() => {
|
||||
const initial: Record<string, number[]> = {}
|
||||
for (const m of RESOURCE_METRICS) {
|
||||
initial[m.id] = seedHistory(m.seedRange[0], m.seedRange[1])
|
||||
}
|
||||
return initial
|
||||
})
|
||||
|
||||
const [agentData, setAgentData] = useState<AgentWithHistory[]>(() =>
|
||||
AGENTS.map((a) => ({
|
||||
...a,
|
||||
history: Array.from({ length: 10 }, () =>
|
||||
Math.max(32, Math.round(a.memoryMb + (nextRandom() - 0.5) * 80))
|
||||
),
|
||||
}))
|
||||
)
|
||||
|
||||
const [showAgents, setShowAgents] = useState(true)
|
||||
const [now, setNow] = useState(DEMO_REFERENCE_DATE)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval>>(null)
|
||||
|
||||
// Derived - no extra state needed
|
||||
const spiking = RESOURCE_METRICS.some((m) => {
|
||||
const history = histories[m.id] ?? []
|
||||
const current = history[history.length - 1] ?? 0
|
||||
return current > m.spikeThreshold
|
||||
})
|
||||
|
||||
const tick = useCallback(() => {
|
||||
setHistories((prev) => {
|
||||
const next: Record<string, number[]> = {}
|
||||
for (const m of RESOURCE_METRICS) {
|
||||
const old = prev[m.id] ?? []
|
||||
const jitter = randomInRange(
|
||||
m.seedRange[0] * 0.8,
|
||||
m.seedRange[1] * 1.15
|
||||
)
|
||||
const clamped =
|
||||
m.id === "memory"
|
||||
? Math.min(16, Math.max(0, jitter))
|
||||
: Math.min(100, Math.max(0, jitter))
|
||||
next[m.id] = [...old.slice(-(HISTORY_LENGTH - 1)), clamped]
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
setAgentData((prev) =>
|
||||
prev.map((a) => {
|
||||
const newMb = Math.max(
|
||||
32,
|
||||
Math.round(a.memoryMb + (nextRandom() - 0.5) * 20)
|
||||
)
|
||||
return {
|
||||
...a,
|
||||
memoryMb: newMb,
|
||||
history: [...a.history.slice(-9), newMb],
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
setNow((prev) => new Date(prev.getTime() + UPDATE_INTERVAL_MS))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
intervalRef.current = setInterval(tick, UPDATE_INTERVAL_MS)
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
}, [tick])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="System monitor"
|
||||
className={cn(
|
||||
"relative inline-flex h-7 items-center gap-1.5 px-2 transition-colors outline-none",
|
||||
"rounded-md",
|
||||
"border-border hover:bg-accent focus-visible:ring-ring border focus-visible:ring-2"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Icon with beep ring on spike */}
|
||||
<span className="relative flex size-3.5 items-center justify-center">
|
||||
<ActivityIcon className={cn(
|
||||
"size-3.5 transition-colors duration-300",
|
||||
spiking ? "text-destructive" : "text-muted-foreground"
|
||||
)} aria-hidden="true" />
|
||||
{spiking && (
|
||||
<span
|
||||
className="bg-destructive/25 absolute inset-0 animate-ping rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-foreground text-xs font-medium">System</span>
|
||||
<StatusBadge spiking={spiking} />
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-80 gap-0! space-y-0! p-0!"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">
|
||||
System Monitor
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
{TIME_FORMATTER.format(now)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 2×2 Resource Grid */}
|
||||
<div className="grid grid-cols-2">
|
||||
{RESOURCE_METRICS.map((metric, i) => (
|
||||
<div
|
||||
key={metric.id}
|
||||
className={cn(
|
||||
i % 2 === 1 && "border-border border-l",
|
||||
i >= 2 && "border-border border-t"
|
||||
)}
|
||||
>
|
||||
<ResourceCard
|
||||
metric={metric}
|
||||
history={histories[metric.id] ?? []}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Per-Agent Memory */}
|
||||
<div className="border-border border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAgents((prev) => !prev)}
|
||||
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors"
|
||||
aria-expanded={showAgents}
|
||||
aria-controls="agent-memory-panel"
|
||||
>
|
||||
<ChevronRightIcon className={cn(
|
||||
"size-3 shrink-0 transition-transform duration-200",
|
||||
showAgents && "rotate-90"
|
||||
)} aria-hidden="true" />
|
||||
Per-Agent Memory
|
||||
</button>
|
||||
|
||||
{showAgents && (
|
||||
<div id="agent-memory-panel" className="pb-2">
|
||||
{agentData.map((agent) => (
|
||||
<AgentMemoryRow key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppShell } from "./components/app-shell"
|
||||
|
||||
export function Page() {
|
||||
return <AppShell />
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client"
|
||||
|
||||
import { useState, type CSSProperties } from "react"
|
||||
import { Cell, Pie, PieChart } from "recharts"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@evobgp/ui/components/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@evobgp/ui/components/chart"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
chartConfig,
|
||||
inflowRanges,
|
||||
rangeOptions,
|
||||
type InflowSource,
|
||||
type RangeKey,
|
||||
} from "./data"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
function GaugeGrid() {
|
||||
const gridPattern =
|
||||
"[background-image:linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] [background-size:8px_8px]"
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-52 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-0 h-40 [mask-image:linear-gradient(to_bottom,black_0%,black_42%,transparent_100%)] opacity-35",
|
||||
gridPattern
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -inset-x-8 top-7 h-44 [mask-image:radial-gradient(ellipse_88%_76%_at_50%_16%,black_0%,black_46%,transparent_100%)] opacity-45 blur-md",
|
||||
gridPattern
|
||||
)}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 h-24 bg-[linear-gradient(to_bottom,transparent_0%,var(--card)_72%,var(--card)_100%)]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RangeMenu({
|
||||
selectedRange,
|
||||
onSelectRange,
|
||||
}: {
|
||||
selectedRange: RangeKey
|
||||
onSelectRange: (range: RangeKey) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-w-24 justify-between gap-1.5 px-3"
|
||||
aria-label="Change inflow range"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>{inflowRanges[selectedRange].label}</span>
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
{rangeOptions.map((range) => (
|
||||
<DropdownMenuItem
|
||||
key={range.key}
|
||||
onClick={() => onSelectRange(range.key)}
|
||||
>
|
||||
{range.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceIcon({ source }: { source: InflowSource }) {
|
||||
return (
|
||||
<Item
|
||||
className={cn(
|
||||
"dark:border-border relative isolate flex size-8 items-center justify-center overflow-hidden border-2 border-white bg-[var(--source-icon-bg)] p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:bg-neutral-950 dark:shadow-[0_1px_3px_0_rgba(0,0,0,0.35)] [&_svg]:size-4 [&_svg]:text-white",
|
||||
source.id !== "qbridge" && "dark:[--source-stipple-opacity:0.38]",
|
||||
source.id === "qbridge" && "dark:[--source-stipple-opacity:0.3]"
|
||||
)}
|
||||
style={{ "--source-icon-bg": source.color } as CSSProperties}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-0 [background-image:radial-gradient(circle_at_center,rgba(255,255,255,0.22)_0.6px,transparent_0.65px)] [background-size:3px_3px] opacity-[var(--source-stipple-opacity,0)]"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-[1] [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)]"
|
||||
/>
|
||||
<ItemMedia variant="icon" className="relative z-10 size-auto">
|
||||
{source.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceMetric({ source }: { source: InflowSource }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-center gap-2 px-2 text-center">
|
||||
<SourceIcon source={source} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-muted-foreground text-xs">{source.name}</div>
|
||||
<div className="text-base font-medium">{source.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
const [selectedRange, setSelectedRange] = useState<RangeKey>("week")
|
||||
const currentRange = inflowRanges[selectedRange]
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-[360px] gap-0 overflow-hidden py-0">
|
||||
{/* Header */}
|
||||
<CardHeader className="relative z-10 flex items-center justify-between border-b p-4!">
|
||||
<CardTitle className="text-sm font-medium">Capital Inflows</CardTitle>
|
||||
<CardAction>
|
||||
<RangeMenu
|
||||
selectedRange={selectedRange}
|
||||
onSelectRange={setSelectedRange}
|
||||
/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
{/* Content */}
|
||||
<CardContent className="relative p-0!">
|
||||
<GaugeGrid />
|
||||
|
||||
<div className="relative px-5 pt-5 pb-4">
|
||||
<div className="relative mx-auto h-[158px] w-full max-w-[300px]">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-full w-full overflow-visible"
|
||||
initialDimension={{ width: 300, height: 158 }}
|
||||
role="img"
|
||||
aria-label={`Capital inflows for ${currentRange.label}: ${currentRange.total}`}
|
||||
>
|
||||
<PieChart
|
||||
accessibilityLayer
|
||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
formatter={(value, name) => (
|
||||
<>
|
||||
<span className="text-muted-foreground">{name}</span>
|
||||
<span className="font-mono font-medium tabular-nums">
|
||||
$
|
||||
{Number(value).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
M
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={currentRange.sources}
|
||||
dataKey="amount"
|
||||
nameKey="name"
|
||||
startAngle={180}
|
||||
endAngle={0}
|
||||
cx="50%"
|
||||
cy="93%"
|
||||
innerRadius={84}
|
||||
outerRadius={116}
|
||||
paddingAngle={1}
|
||||
cornerRadius={3}
|
||||
stroke="var(--card)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{currentRange.sources.map((source) => (
|
||||
<Cell key={source.id} fill={`var(--color-${source.id})`} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 top-[82px] flex flex-col items-center">
|
||||
<span className="text-muted-foreground text-xs">Capital In</span>
|
||||
<span className="text-2xl font-medium">{currentRange.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-5">
|
||||
<Separator className="mb-5" />
|
||||
<div className="grid grid-cols-[1fr_auto_1fr_auto_1fr] items-stretch">
|
||||
{currentRange.sources.map((source, index) => (
|
||||
<div key={source.id} className="contents">
|
||||
<SourceMetric source={source} />
|
||||
{index < currentRange.sources.length - 1 ? (
|
||||
<Separator orientation="vertical" className="mx-2" />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { type ChartConfig } from "@evobgp/ui/components/chart"
|
||||
import { ActivityIcon, SparklesIcon, CircleDollarSignIcon } from "lucide-react"
|
||||
|
||||
export type RangeKey = "week" | "month" | "quarter"
|
||||
|
||||
export interface InflowSource {
|
||||
id: "wavemark" | "envato" | "qbridge"
|
||||
name: string
|
||||
amount: number
|
||||
value: string
|
||||
color: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export interface InflowRange {
|
||||
label: string
|
||||
total: string
|
||||
sources: InflowSource[]
|
||||
}
|
||||
|
||||
export const rangeOptions: { key: RangeKey; label: string }[] = [
|
||||
{ key: "week", label: "Last Week" },
|
||||
{ key: "month", label: "Last Month" },
|
||||
{ key: "quarter", label: "Last Quarter" },
|
||||
]
|
||||
|
||||
const sourceIcons = {
|
||||
wavemark: (
|
||||
<ActivityIcon aria-hidden="true" />
|
||||
),
|
||||
envato: (
|
||||
<SparklesIcon aria-hidden="true" />
|
||||
),
|
||||
qbridge: (
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
),
|
||||
} satisfies Record<InflowSource["id"], ReactNode>
|
||||
|
||||
export const inflowRanges: Record<RangeKey, InflowRange> = {
|
||||
week: {
|
||||
label: "Last Week",
|
||||
total: "$12.4M",
|
||||
sources: [
|
||||
{
|
||||
id: "wavemark",
|
||||
name: "WaveMark",
|
||||
amount: 7.6,
|
||||
value: "$7.6M",
|
||||
color: "oklch(57% 0.13 184)",
|
||||
icon: sourceIcons.wavemark,
|
||||
},
|
||||
{
|
||||
id: "envato",
|
||||
name: "Envato Inc.",
|
||||
amount: 3.4,
|
||||
value: "$3.4M",
|
||||
color: "oklch(76% 0.17 84)",
|
||||
icon: sourceIcons.envato,
|
||||
},
|
||||
{
|
||||
id: "qbridge",
|
||||
name: "QBridge B.V.",
|
||||
amount: 1.2,
|
||||
value: "$1.2M",
|
||||
color: "oklch(65% 0.21 45)",
|
||||
icon: sourceIcons.qbridge,
|
||||
},
|
||||
],
|
||||
},
|
||||
month: {
|
||||
label: "Last Month",
|
||||
total: "$47.8M",
|
||||
sources: [
|
||||
{
|
||||
id: "wavemark",
|
||||
name: "WaveMark",
|
||||
amount: 28.4,
|
||||
value: "$28.4M",
|
||||
color: "oklch(57% 0.13 184)",
|
||||
icon: sourceIcons.wavemark,
|
||||
},
|
||||
{
|
||||
id: "envato",
|
||||
name: "Envato Inc.",
|
||||
amount: 13.6,
|
||||
value: "$13.6M",
|
||||
color: "oklch(76% 0.17 84)",
|
||||
icon: sourceIcons.envato,
|
||||
},
|
||||
{
|
||||
id: "qbridge",
|
||||
name: "QBridge B.V.",
|
||||
amount: 5.8,
|
||||
value: "$5.8M",
|
||||
color: "oklch(65% 0.21 45)",
|
||||
icon: sourceIcons.qbridge,
|
||||
},
|
||||
],
|
||||
},
|
||||
quarter: {
|
||||
label: "Last Quarter",
|
||||
total: "$138.2M",
|
||||
sources: [
|
||||
{
|
||||
id: "wavemark",
|
||||
name: "WaveMark",
|
||||
amount: 79.1,
|
||||
value: "$79.1M",
|
||||
color: "oklch(57% 0.13 184)",
|
||||
icon: sourceIcons.wavemark,
|
||||
},
|
||||
{
|
||||
id: "envato",
|
||||
name: "Envato Inc.",
|
||||
amount: 42.5,
|
||||
value: "$42.5M",
|
||||
color: "oklch(76% 0.17 84)",
|
||||
icon: sourceIcons.envato,
|
||||
},
|
||||
{
|
||||
id: "qbridge",
|
||||
name: "QBridge B.V.",
|
||||
amount: 16.6,
|
||||
value: "$16.6M",
|
||||
color: "oklch(65% 0.21 45)",
|
||||
icon: sourceIcons.qbridge,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const chartConfig = {
|
||||
wavemark: {
|
||||
label: "WaveMark",
|
||||
color: "oklch(57% 0.13 184)",
|
||||
},
|
||||
envato: {
|
||||
label: "Envato Inc.",
|
||||
color: "oklch(76% 0.17 84)",
|
||||
},
|
||||
qbridge: {
|
||||
label: "QBridge B.V.",
|
||||
color: "oklch(65% 0.21 45)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Chart } from "./components/chart"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6">
|
||||
<Chart />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Cell, Pie, PieChart } from "recharts"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@evobgp/ui/components/chart"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
chartConfig,
|
||||
inflowPeriods,
|
||||
type InflowFund,
|
||||
type InflowPeriod,
|
||||
} from "./data"
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
const CHART_REVEAL_STYLE = `
|
||||
@keyframes chart-13-reveal-up {
|
||||
from {
|
||||
clip-path: inset(100% 0 0 0);
|
||||
opacity: 0.75;
|
||||
}
|
||||
to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-13-reveal-up {
|
||||
animation: chart-13-reveal-up 680ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chart-13-reveal-up {
|
||||
animation: none;
|
||||
clip-path: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type DonutSlice =
|
||||
| InflowFund
|
||||
| {
|
||||
key: "reserve"
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
function getDonutData(period: InflowPeriod) {
|
||||
const trackedShare = period.funds.reduce(
|
||||
(total, fund) => total + fund.share,
|
||||
0
|
||||
)
|
||||
const reserveShare = Math.max(100 - trackedShare, 0)
|
||||
|
||||
return [
|
||||
...period.funds,
|
||||
{
|
||||
key: "reserve",
|
||||
name: "Other Sources",
|
||||
amount: "",
|
||||
share: reserveShare,
|
||||
color: "var(--muted)",
|
||||
fill: "var(--color-reserve)",
|
||||
},
|
||||
] satisfies DonutSlice[]
|
||||
}
|
||||
|
||||
function ChartTooltipFormatter(item: unknown) {
|
||||
const fund = item as DonutSlice
|
||||
const value = fund.key === "reserve" ? `${fund.share}%` : fund.amount
|
||||
|
||||
return (
|
||||
<div className="flex min-w-40 items-center justify-between gap-6">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground truncate">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-foreground font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoTooltip() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="About Capital Inflows"
|
||||
className="text-muted-foreground/70 -my-1"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<InfoIcon aria-hidden="true" className="text-sm" data-icon="inline-start" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<p>Tracked capital committed during the selected period.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowDonut({ period }: { period: InflowPeriod }) {
|
||||
const chartData = getDonutData(period)
|
||||
|
||||
return (
|
||||
<div className="chart-13-reveal-up relative size-[8.25rem] shrink-0">
|
||||
<ChartContainer
|
||||
aria-label={`Capital Inflows: ${period.total} total for ${period.label}`}
|
||||
className="aspect-square size-[8.25rem]"
|
||||
config={chartConfig}
|
||||
initialDimension={{ width: 132, height: 132 }}
|
||||
>
|
||||
<PieChart margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
hideIndicator
|
||||
formatter={(_value, _name, item) =>
|
||||
ChartTooltipFormatter(item.payload)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="share"
|
||||
endAngle={-230}
|
||||
innerRadius={47}
|
||||
isAnimationActive={false}
|
||||
nameKey="name"
|
||||
outerRadius={62}
|
||||
paddingAngle={1}
|
||||
cornerRadius={3}
|
||||
startAngle={130}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{chartData.map((item) => (
|
||||
<Cell key={item.key} fill={item.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="bg-background/90 border-border/70 flex size-[5.25rem] flex-col items-center justify-center rounded-full border border-dashed">
|
||||
<span className="text-muted-foreground/70 text-xs">Capital</span>
|
||||
<span className="mt-0.5 text-sm font-semibold">{period.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowLegend({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<ul className="flex min-w-0 flex-1 flex-col">
|
||||
{period.funds.map((fund, index) => (
|
||||
<li key={fund.key}>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{fund.amount}</span>
|
||||
<span className="text-muted-foreground/70 w-8 text-right text-xs">
|
||||
{fund.share}%
|
||||
</span>
|
||||
</div>
|
||||
{index < period.funds.length - 1 ? (
|
||||
<Separator className="w-auto" />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowPeriodPanel({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<div className="grid gap-6 @sm:grid-cols-[8.25rem_minmax(0,1fr)] @sm:items-center">
|
||||
<InflowDonut period={period} />
|
||||
<InflowLegend period={period} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<TooltipProvider delay={150}>
|
||||
<style>{CHART_REVEAL_STYLE}</style>
|
||||
<Frame className="@container w-full max-w-[29rem]">
|
||||
<FramePanel className="ps-3.5! pe-5! pt-5! pb-3.5!">
|
||||
<Tabs defaultValue="week" className="gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-0.5 ps-1.5">
|
||||
<h2 className="text-sm font-medium">Capital Inflows</h2>
|
||||
<InfoTooltip />
|
||||
</div>
|
||||
|
||||
<TabsList className="w-full @sm:w-auto">
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsTrigger key={period.value} value={period.value}>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsContent
|
||||
key={period.value}
|
||||
value={period.value}
|
||||
className="mt-0"
|
||||
>
|
||||
<InflowPeriodPanel period={period} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { type ChartConfig } from "@evobgp/ui/components/chart"
|
||||
|
||||
export type InflowFundKey = "northline" | "copper" | "bridgewell" | "reserve"
|
||||
|
||||
export interface InflowFund {
|
||||
key: Exclude<InflowFundKey, "reserve">
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
export interface InflowPeriod {
|
||||
value: "week" | "month" | "year"
|
||||
label: string
|
||||
total: string
|
||||
funds: InflowFund[]
|
||||
}
|
||||
|
||||
const teal = "oklch(0.6 0.145 181.2)"
|
||||
const amber = "oklch(0.76 0.161 80.1)"
|
||||
const orange = "oklch(0.66 0.19 42.8)"
|
||||
|
||||
export const chartConfig = {
|
||||
capital: {
|
||||
label: "Capital",
|
||||
},
|
||||
northline: {
|
||||
label: "WaveMark Capital",
|
||||
color: teal,
|
||||
},
|
||||
copper: {
|
||||
label: "Copper Market",
|
||||
color: amber,
|
||||
},
|
||||
bridgewell: {
|
||||
label: "Bridgewell Fund",
|
||||
color: orange,
|
||||
},
|
||||
reserve: {
|
||||
label: "Other Sources",
|
||||
color: "var(--muted)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const inflowPeriods: InflowPeriod[] = [
|
||||
{
|
||||
value: "week",
|
||||
label: "Week",
|
||||
total: "$12,4M",
|
||||
funds: [
|
||||
{
|
||||
key: "northline",
|
||||
name: "WaveMark Capital",
|
||||
amount: "$7,6M",
|
||||
share: 35,
|
||||
color: teal,
|
||||
fill: "var(--color-northline)",
|
||||
},
|
||||
{
|
||||
key: "copper",
|
||||
name: "Envato Market",
|
||||
amount: "$3,4M",
|
||||
share: 34,
|
||||
color: amber,
|
||||
fill: "var(--color-copper)",
|
||||
},
|
||||
{
|
||||
key: "bridgewell",
|
||||
name: "QBridge Investment",
|
||||
amount: "$1,2M",
|
||||
share: 14,
|
||||
color: orange,
|
||||
fill: "var(--color-bridgewell)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "month",
|
||||
label: "Month",
|
||||
total: "$48,2M",
|
||||
funds: [
|
||||
{
|
||||
key: "northline",
|
||||
name: "Northline Capital",
|
||||
amount: "$21,8M",
|
||||
share: 39,
|
||||
color: teal,
|
||||
fill: "var(--color-northline)",
|
||||
},
|
||||
{
|
||||
key: "copper",
|
||||
name: "Copper Market",
|
||||
amount: "$16,5M",
|
||||
share: 31,
|
||||
color: amber,
|
||||
fill: "var(--color-copper)",
|
||||
},
|
||||
{
|
||||
key: "bridgewell",
|
||||
name: "Bridgewell Fund",
|
||||
amount: "$6,9M",
|
||||
share: 18,
|
||||
color: orange,
|
||||
fill: "var(--color-bridgewell)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "year",
|
||||
label: "Year",
|
||||
total: "$186,7M",
|
||||
funds: [
|
||||
{
|
||||
key: "northline",
|
||||
name: "Northline Capital",
|
||||
amount: "$72,4M",
|
||||
share: 37,
|
||||
color: teal,
|
||||
fill: "var(--color-northline)",
|
||||
},
|
||||
{
|
||||
key: "copper",
|
||||
name: "Copper Market",
|
||||
amount: "$61,2M",
|
||||
share: 33,
|
||||
color: amber,
|
||||
fill: "var(--color-copper)",
|
||||
},
|
||||
{
|
||||
key: "bridgewell",
|
||||
name: "Bridgewell Fund",
|
||||
amount: "$29,6M",
|
||||
share: 16,
|
||||
color: orange,
|
||||
fill: "var(--color-bridgewell)",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Chart } from "./components/chart"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6">
|
||||
<Chart />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Area, AreaChart, ResponsiveContainer, Tooltip } from "recharts"
|
||||
import { activeUsersData, customersData, revenueData } from "./data"
|
||||
import { CircleDollarSignIcon, UserPlusIcon, TrendingUp } from "lucide-react"
|
||||
|
||||
// ── Business metric cards ──
|
||||
|
||||
const businessCards = [
|
||||
{
|
||||
title: "Revenue",
|
||||
period: "Last 28 days",
|
||||
value: "6.238$",
|
||||
timestamp: "",
|
||||
data: revenueData,
|
||||
color: "var(--color-emerald-500)",
|
||||
gradientId: "revenueGradient",
|
||||
icon: (
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "New Customers",
|
||||
period: "Last 28 days",
|
||||
value: "6.202",
|
||||
timestamp: "3h ago",
|
||||
data: customersData,
|
||||
color: "var(--color-blue-500)",
|
||||
gradientId: "customersGradient",
|
||||
icon: (
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Active Users",
|
||||
period: "Last 28 days",
|
||||
value: "18.945",
|
||||
timestamp: "1h ago",
|
||||
data: activeUsersData,
|
||||
color: "var(--color-violet-500)",
|
||||
gradientId: "usersGradient",
|
||||
icon: (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<div className="@container w-full max-w-6xl">
|
||||
<div className="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
|
||||
{businessCards.map((card, i) => (
|
||||
<Frame key={i} variant="ghost">
|
||||
<FramePanel className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ color: card.color }} className="[&_svg]:size-5">
|
||||
{card.icon}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{card.title}</span>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="flex items-end justify-between gap-2.5">
|
||||
{/* Value */}
|
||||
<div className="flex flex-col gap-px pb-2">
|
||||
<div className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{card.period}
|
||||
</div>
|
||||
<div className="text-foreground text-xl font-semibold tracking-tight">
|
||||
{card.value}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-16 w-full max-w-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={card.data}
|
||||
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={card.gradientId}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={card.color}
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={card.color}
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: card.color,
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: "2 2",
|
||||
}}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const value = payload[0].value as number
|
||||
const formatValue = (val: number) => {
|
||||
if (card.title === "Revenue") {
|
||||
return `${(val / 1000).toFixed(1)}k US$`
|
||||
}
|
||||
return `${(val / 1000).toFixed(1)}k`
|
||||
}
|
||||
return (
|
||||
<div className="bg-popover border-border pointer-events-none rounded-md border p-2 shadow-lg backdrop-blur-sm">
|
||||
<p className="text-popover-foreground text-sm font-semibold">
|
||||
{formatValue(value)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={card.color}
|
||||
fill={`url(#${card.gradientId})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 4,
|
||||
fill: card.color,
|
||||
stroke: "white",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ReactNode } from "react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface MetricCard {
|
||||
title: string
|
||||
period: string
|
||||
value: string
|
||||
timestamp: string
|
||||
data: { value: number }[]
|
||||
color: string
|
||||
icon: ReactNode
|
||||
gradientId: string
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const revenueData = [
|
||||
{ value: 1000 },
|
||||
{ value: 4500 },
|
||||
{ value: 2000 },
|
||||
{ value: 5200 },
|
||||
{ value: 1500 },
|
||||
{ value: 6100 },
|
||||
{ value: 3000 },
|
||||
{ value: 6800 },
|
||||
{ value: 2000 },
|
||||
{ value: 1000 },
|
||||
{ value: 4000 },
|
||||
{ value: 2000 },
|
||||
{ value: 3000 },
|
||||
{ value: 2000 },
|
||||
{ value: 6238 },
|
||||
]
|
||||
|
||||
export const customersData = [
|
||||
{ value: 2000 },
|
||||
{ value: 4500 },
|
||||
{ value: 2000 },
|
||||
{ value: 5200 },
|
||||
{ value: 1500 },
|
||||
{ value: 5100 },
|
||||
{ value: 2500 },
|
||||
{ value: 6800 },
|
||||
{ value: 1800 },
|
||||
{ value: 1000 },
|
||||
{ value: 3000 },
|
||||
{ value: 2000 },
|
||||
{ value: 2700 },
|
||||
{ value: 2000 },
|
||||
{ value: 4238 },
|
||||
]
|
||||
|
||||
export const activeUsersData = [
|
||||
{ value: 2000 },
|
||||
{ value: 3500 },
|
||||
{ value: 2000 },
|
||||
{ value: 5200 },
|
||||
{ value: 1200 },
|
||||
{ value: 4100 },
|
||||
{ value: 3500 },
|
||||
{ value: 5800 },
|
||||
{ value: 2000 },
|
||||
{ value: 800 },
|
||||
{ value: 3000 },
|
||||
{ value: 1000 },
|
||||
{ value: 4000 },
|
||||
{ value: 2000 },
|
||||
{ value: 4238 },
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Chart } from "./components/chart"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-10 md:p-20">
|
||||
<Chart />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { LabelList, Pie, PieChart } from "recharts"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { ChartContainer, ChartTooltip } from "@evobgp/ui/components/chart"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@evobgp/ui/components/tabs"
|
||||
import { browserData, chartConfig, PeriodKey, PERIODS } from "./data"
|
||||
import { TrendingUp, PieChartIcon, InfoIcon } from "lucide-react"
|
||||
|
||||
// ── Custom tooltip ──
|
||||
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean
|
||||
payload?: {
|
||||
name: string
|
||||
color: string
|
||||
value: number
|
||||
payload: { browser: string }
|
||||
}[]
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-popover min-w-[150px] rounded-lg border p-4 shadow-lg backdrop-blur-sm">
|
||||
<div className="text-popover-foreground border-border/50 mb-3 border-b pb-2 text-sm font-semibold tracking-wider uppercase">
|
||||
{payload[0].payload.browser}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-2 rounded-sm"
|
||||
style={{ backgroundColor: payload[0].color }}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Visitors
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-popover-foreground text-sm font-semibold">
|
||||
{payload[0].value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<PeriodKey>("5D")
|
||||
|
||||
const { currentData, totalVisitors } = useMemo(() => {
|
||||
const data = browserData[selectedPeriod] || []
|
||||
const total = data.reduce((sum, item) => sum + item.visitors, 0)
|
||||
return { currentData: data, totalVisitors: total }
|
||||
}, [selectedPeriod])
|
||||
|
||||
return (
|
||||
<Frame className="w-full max-w-md">
|
||||
{/* Content */}
|
||||
<FramePanel className="space-y-6 p-6!">
|
||||
<div className="flex items-center justify-between border-b border-dashed pb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold">Browser Usage</h3>
|
||||
<Badge variant="success-light" className="gap-1 border-none">
|
||||
<TrendingUp className="size-3.5" aria-hidden="true" />
|
||||
<span>+5.2%</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<Item className="bg-primary/5 text-primary flex size-12 items-center justify-center rounded-full p-0 [&_svg]:h-6 [&_svg]:w-6">
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<PieChartIcon aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
Total Visitors
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{totalVisitors.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={selectedPeriod}
|
||||
onValueChange={(value) => setSelectedPeriod(value as PeriodKey)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{Object.values(PERIODS).map((period) => (
|
||||
<TabsTrigger
|
||||
key={period.key}
|
||||
value={period.key}
|
||||
className="flex-1"
|
||||
>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mx-auto aspect-square max-h-[200px] w-full">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-full w-full overflow-visible"
|
||||
>
|
||||
<PieChart className="overflow-visible">
|
||||
<ChartTooltip content={<CustomTooltip />} />
|
||||
<Pie
|
||||
data={currentData}
|
||||
innerRadius={35}
|
||||
outerRadius={100}
|
||||
dataKey="visitors"
|
||||
nameKey="browser"
|
||||
strokeWidth={4}
|
||||
stroke="var(--background)"
|
||||
cornerRadius={8}
|
||||
paddingAngle={2}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
<LabelList
|
||||
dataKey="visitors"
|
||||
stroke="none"
|
||||
fontSize={10}
|
||||
fontWeight={600}
|
||||
fill="white"
|
||||
formatter={(value: number) =>
|
||||
value >= 1000 ? `${(value / 1000).toFixed(1)}k` : value
|
||||
}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-2 px-2 text-xs">
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
<span>Visitor data based on unique browser signatures.</span>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ChartConfig } from "@evobgp/ui/components/chart"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type PeriodKey = "5D" | "2W" | "1M"
|
||||
|
||||
// ── Config ──
|
||||
|
||||
export const chartConfig = {
|
||||
visitors: { label: "Visitors" },
|
||||
chrome: { label: "Chrome", color: "var(--color-blue-500)" },
|
||||
safari: { label: "Safari", color: "var(--color-sky-500)" },
|
||||
firefox: { label: "Firefox", color: "var(--color-orange-500)" },
|
||||
edge: { label: "Edge", color: "var(--color-indigo-500)" },
|
||||
other: { label: "Other", color: "var(--color-slate-500)" },
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const PERIODS = {
|
||||
"5D": { key: "5D", label: "5D" },
|
||||
"2W": { key: "2W", label: "2W" },
|
||||
"1M": { key: "1M", label: "1M" },
|
||||
} as const
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const browserData: Record<
|
||||
PeriodKey,
|
||||
{ browser: string; visitors: number; fill: string }[]
|
||||
> = {
|
||||
"5D": [
|
||||
{ browser: "chrome", visitors: 275, fill: "var(--color-blue-500)" },
|
||||
{ browser: "safari", visitors: 200, fill: "var(--color-sky-500)" },
|
||||
{ browser: "firefox", visitors: 187, fill: "var(--color-orange-500)" },
|
||||
{ browser: "edge", visitors: 173, fill: "var(--color-indigo-500)" },
|
||||
{ browser: "other", visitors: 90, fill: "var(--color-slate-500)" },
|
||||
],
|
||||
"2W": [
|
||||
{ browser: "chrome", visitors: 1275, fill: "var(--color-blue-500)" },
|
||||
{ browser: "safari", visitors: 800, fill: "var(--color-sky-500)" },
|
||||
{ browser: "firefox", visitors: 587, fill: "var(--color-orange-500)" },
|
||||
{ browser: "edge", visitors: 473, fill: "var(--color-indigo-500)" },
|
||||
{ browser: "other", visitors: 290, fill: "var(--color-slate-500)" },
|
||||
],
|
||||
"1M": [
|
||||
{ browser: "chrome", visitors: 4275, fill: "var(--color-blue-500)" },
|
||||
{ browser: "safari", visitors: 3200, fill: "var(--color-sky-500)" },
|
||||
{ browser: "firefox", visitors: 2187, fill: "var(--color-orange-500)" },
|
||||
{ browser: "edge", visitors: 1873, fill: "var(--color-indigo-500)" },
|
||||
{ browser: "other", visitors: 1090, fill: "var(--color-slate-500)" },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Chart } from "./components/chart"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-10 md:p-20">
|
||||
<Chart />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import { type ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { type MemberBillingStatus, type MemberRecord } from "./data"
|
||||
import { EyeIcon, PencilIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
export type MemberRowAction = "view" | "edit" | "delete"
|
||||
|
||||
const billingStatusDotClass: Record<MemberBillingStatus, string> = {
|
||||
Active: "bg-emerald-500",
|
||||
"Pending invoice": "bg-sky-500",
|
||||
"Manual review": "bg-amber-500",
|
||||
"Past due": "bg-rose-500",
|
||||
}
|
||||
|
||||
function MemberRowActions({
|
||||
member,
|
||||
onAction,
|
||||
}: {
|
||||
member: MemberRecord
|
||||
onAction: (action: MemberRowAction, member: MemberRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="pointer-events-none flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within/member-row:pointer-events-auto group-focus-within/member-row:opacity-100 group-hover/member-row:pointer-events-auto group-hover/member-row:opacity-100">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`View ${member.fullName}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onAction("view", member)
|
||||
}}
|
||||
>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Edit ${member.fullName}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onAction("edit", member)
|
||||
}}
|
||||
>
|
||||
<PencilIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="hover:text-destructive"
|
||||
aria-label={`Delete ${member.fullName}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onAction("delete", member)
|
||||
}}
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberCell({
|
||||
member,
|
||||
onAction,
|
||||
}: {
|
||||
member: MemberRecord
|
||||
onAction: (action: MemberRowAction, member: MemberRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Avatar className="size-8 shrink-0">
|
||||
<AvatarImage src={member.avatarSrc} alt={member.fullName} />
|
||||
<AvatarFallback>{member.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{member.fullName}</div>
|
||||
<div className="text-muted-foreground truncate text-sm">
|
||||
{member.displayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<MemberRowActions member={member} onAction={onAction} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BillingStatusCell({
|
||||
billingStatus,
|
||||
}: {
|
||||
billingStatus: MemberBillingStatus
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
billingStatusDotClass[billingStatus]
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate text-sm">{billingStatus}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function createMembersGridColumns({
|
||||
onAction,
|
||||
}: {
|
||||
onAction: (action: MemberRowAction, member: MemberRecord) => void
|
||||
}): ColumnDef<MemberRecord>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "fullName",
|
||||
id: "fullName",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<MemberCell member={row.original} onAction={onAction} />
|
||||
),
|
||||
size: 255,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Member",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
id: "email",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<a
|
||||
href={`mailto:${row.original.email}`}
|
||||
className="hover:text-primary block truncate text-sm transition-colors hover:underline"
|
||||
title={row.original.email}
|
||||
>
|
||||
{row.original.email}
|
||||
</a>
|
||||
),
|
||||
size: 220,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Email",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
id: "role",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="block truncate text-sm">{row.original.role}</span>
|
||||
),
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Role",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "billingStatus",
|
||||
id: "billingStatus",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<BillingStatusCell billingStatus={row.original.billingStatus} />
|
||||
),
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Billing status",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "authProvider",
|
||||
id: "authProvider",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="block truncate text-sm">
|
||||
{row.original.authProvider}
|
||||
</span>
|
||||
),
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Authentication",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "joinedAt",
|
||||
id: "joinedAt",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground block truncate text-sm tabular-nums">
|
||||
{row.original.joinedAt}
|
||||
</span>
|
||||
),
|
||||
sortingFn: (rowA, rowB) =>
|
||||
new Date(rowA.original.joinedAt).getTime() -
|
||||
new Date(rowB.original.joinedAt).getTime(),
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Joining date",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
import { createMembersGridColumns, type MemberRowAction } from "./columns"
|
||||
import {
|
||||
MEMBER_AUTH_PROVIDER_OPTIONS,
|
||||
MEMBER_BILLING_STATUS_OPTIONS,
|
||||
MEMBER_RECORDS,
|
||||
MEMBER_ROLE_OPTIONS,
|
||||
type FilterOption,
|
||||
type MemberAuthProvider,
|
||||
type MemberBillingStatus,
|
||||
type MemberRecord,
|
||||
type MemberRole,
|
||||
} from "./data"
|
||||
import { SearchIcon, XIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
type FilterTabContentProps<T extends string> = {
|
||||
options: readonly FilterOption<T>[]
|
||||
selectedValues: T[]
|
||||
onToggle: (value: T, checked: boolean) => void
|
||||
}
|
||||
|
||||
function getMemberSearchBlob(member: MemberRecord) {
|
||||
return [
|
||||
member.fullName,
|
||||
member.displayName,
|
||||
member.email,
|
||||
member.role,
|
||||
member.billingStatus,
|
||||
member.authProvider,
|
||||
member.joinedAt,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function toggleFilterValue<T extends string>(
|
||||
current: T[],
|
||||
value: T,
|
||||
checked: boolean
|
||||
) {
|
||||
if (checked) {
|
||||
return current.includes(value) ? current : [...current, value]
|
||||
}
|
||||
|
||||
return current.filter((item) => item !== value)
|
||||
}
|
||||
|
||||
function FilterTabContent<T extends string>({
|
||||
options,
|
||||
selectedValues,
|
||||
onToggle,
|
||||
}: FilterTabContentProps<T>) {
|
||||
return (
|
||||
<DropdownMenuGroup>
|
||||
{options.map((option) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={option.value}
|
||||
checked={selectedValues.includes(option.value)}
|
||||
closeOnClick={false}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggle(option.value, checked === true)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export function MembersDataGridView() {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "fullName", desc: false },
|
||||
])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedRoles, setSelectedRoles] = useState<MemberRole[]>([])
|
||||
const [selectedBillingStatuses, setSelectedBillingStatuses] = useState<
|
||||
MemberBillingStatus[]
|
||||
>([])
|
||||
const [selectedAuthProviders, setSelectedAuthProviders] = useState<
|
||||
MemberAuthProvider[]
|
||||
>([])
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const normalizedSearchQuery = searchQuery.trim().toLowerCase()
|
||||
|
||||
return MEMBER_RECORDS.filter((member) => {
|
||||
const matchesSearch =
|
||||
normalizedSearchQuery.length === 0 ||
|
||||
getMemberSearchBlob(member).includes(normalizedSearchQuery)
|
||||
const matchesRole =
|
||||
selectedRoles.length === 0 || selectedRoles.includes(member.role)
|
||||
const matchesBillingStatus =
|
||||
selectedBillingStatuses.length === 0 ||
|
||||
selectedBillingStatuses.includes(member.billingStatus)
|
||||
const matchesAuthProvider =
|
||||
selectedAuthProviders.length === 0 ||
|
||||
selectedAuthProviders.includes(member.authProvider)
|
||||
|
||||
return (
|
||||
matchesSearch &&
|
||||
matchesRole &&
|
||||
matchesBillingStatus &&
|
||||
matchesAuthProvider
|
||||
)
|
||||
})
|
||||
}, [
|
||||
searchQuery,
|
||||
selectedAuthProviders,
|
||||
selectedBillingStatuses,
|
||||
selectedRoles,
|
||||
])
|
||||
|
||||
const activeFilterCount =
|
||||
selectedRoles.length +
|
||||
selectedBillingStatuses.length +
|
||||
selectedAuthProviders.length
|
||||
|
||||
const handleMemberAction = useCallback(
|
||||
(action: MemberRowAction, member: MemberRecord) => {
|
||||
if (action === "view") {
|
||||
toast.info("Open member", {
|
||||
description: `Review ${member.fullName}'s profile and recent activity.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
toast.message("Edit member", {
|
||||
description: `Open the access and profile editor for ${member.fullName}.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast.message("Delete member", {
|
||||
description: `Wire this action to your member removal flow for ${member.fullName}.`,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const columns = useMemo(
|
||||
() => createMembersGridColumns({ onAction: handleMemberAction }),
|
||||
[handleMemberAction]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: filteredData,
|
||||
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
|
||||
getRowId: (row) => row.id,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value)
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
pageIndex: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRoleToggle = (value: MemberRole, checked: boolean) => {
|
||||
setSelectedRoles((current) => toggleFilterValue(current, value, checked))
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
pageIndex: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleBillingStatusToggle = (
|
||||
value: MemberBillingStatus,
|
||||
checked: boolean
|
||||
) => {
|
||||
setSelectedBillingStatuses((current) =>
|
||||
toggleFilterValue(current, value, checked)
|
||||
)
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
pageIndex: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleAuthProviderToggle = (
|
||||
value: MemberAuthProvider,
|
||||
checked: boolean
|
||||
) => {
|
||||
setSelectedAuthProviders((current) =>
|
||||
toggleFilterValue(current, value, checked)
|
||||
)
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
pageIndex: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setSelectedRoles([])
|
||||
setSelectedBillingStatuses([])
|
||||
setSelectedAuthProviders([])
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
pageIndex: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleImport = () => {
|
||||
toast.message("Import members", {
|
||||
description:
|
||||
"Connect this button to your CSV import, directory sync, or SCIM provisioning flow.",
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddMember = () => {
|
||||
toast.message("Add member", {
|
||||
description:
|
||||
"Open your invite drawer or provisioning dialog from this primary action.",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage={
|
||||
filteredData.length === 0
|
||||
? "No members match your current search or filters."
|
||||
: undefined
|
||||
}
|
||||
tableLayout={{
|
||||
dense: true,
|
||||
headerSticky: false,
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
}}
|
||||
tableClassNames={{
|
||||
bodyRow: "group/member-row",
|
||||
}}
|
||||
>
|
||||
{/* Heading */}
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex flex-col gap-4 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<h2 className="truncate text-2xl font-semibold tracking-tight">
|
||||
Members
|
||||
</h2>
|
||||
<Badge variant="outline" size="sm" radius="full">
|
||||
{filteredData.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center lg:ml-auto lg:w-auto lg:flex-nowrap lg:justify-end">
|
||||
<InputGroup className="w-full sm:w-72">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search..."
|
||||
aria-label="Search members"
|
||||
value={searchQuery}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
/>
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
size="icon-xs"
|
||||
onClick={() => handleSearchChange("")}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label="Filter members"
|
||||
>
|
||||
Filters
|
||||
{activeFilterCount > 0 ? (
|
||||
<Badge variant="outline" size="sm" radius="full">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<Tabs defaultValue="role" className="w-full">
|
||||
<div className="p-1">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="role">Role</TabsTrigger>
|
||||
<TabsTrigger value="billing">Billing</TabsTrigger>
|
||||
<TabsTrigger value="authentication">Auth</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="role" className="m-0 px-0.5 pb-1">
|
||||
<FilterTabContent
|
||||
options={MEMBER_ROLE_OPTIONS}
|
||||
selectedValues={selectedRoles}
|
||||
onToggle={handleRoleToggle}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="billing" className="m-0 px-0.5 pb-1">
|
||||
<FilterTabContent
|
||||
options={MEMBER_BILLING_STATUS_OPTIONS}
|
||||
selectedValues={selectedBillingStatuses}
|
||||
onToggle={handleBillingStatusToggle}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="authentication"
|
||||
className="m-0 px-0.5 pb-1"
|
||||
>
|
||||
<FilterTabContent
|
||||
options={MEMBER_AUTH_PROVIDER_OPTIONS}
|
||||
selectedValues={selectedAuthProviders}
|
||||
onToggle={handleAuthProviderToggle}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{activeFilterCount > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
closeOnClick={false}
|
||||
onClick={handleResetFilters}
|
||||
>
|
||||
Reset filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button type="button" variant="outline" onClick={handleImport}>
|
||||
Import
|
||||
</Button>
|
||||
|
||||
<Button type="button" onClick={handleAddMember}>
|
||||
Add member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="py-3">
|
||||
{filteredData.length > 0 ? (
|
||||
<DataGridPagination
|
||||
sizes={[5, 10, 20]}
|
||||
info="{from} - {to} of {count} members"
|
||||
className="py-0"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">0 members</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
export type FilterOption<T extends string> = {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
|
||||
export const MEMBER_ROLE_OPTIONS = [
|
||||
{ value: "Admin", label: "Admin" },
|
||||
{ value: "Finance", label: "Finance" },
|
||||
{ value: "Support", label: "Support" },
|
||||
{ value: "Operations", label: "Operations" },
|
||||
{ value: "Product", label: "Product" },
|
||||
{ value: "Security", label: "Security" },
|
||||
{ value: "Growth", label: "Growth" },
|
||||
] as const satisfies readonly FilterOption<string>[]
|
||||
|
||||
export const MEMBER_BILLING_STATUS_OPTIONS = [
|
||||
{ value: "Active", label: "Active" },
|
||||
{ value: "Pending invoice", label: "Pending invoice" },
|
||||
{ value: "Manual review", label: "Manual review" },
|
||||
{ value: "Past due", label: "Past due" },
|
||||
] as const satisfies readonly FilterOption<string>[]
|
||||
|
||||
export const MEMBER_AUTH_PROVIDER_OPTIONS = [
|
||||
{ value: "Google", label: "Google" },
|
||||
{ value: "GitHub", label: "GitHub" },
|
||||
{ value: "Okta SSO", label: "Okta SSO" },
|
||||
{ value: "Passwordless", label: "Passwordless" },
|
||||
] as const satisfies readonly FilterOption<string>[]
|
||||
|
||||
export type MemberRole = (typeof MEMBER_ROLE_OPTIONS)[number]["value"]
|
||||
export type MemberBillingStatus =
|
||||
(typeof MEMBER_BILLING_STATUS_OPTIONS)[number]["value"]
|
||||
export type MemberAuthProvider =
|
||||
(typeof MEMBER_AUTH_PROVIDER_OPTIONS)[number]["value"]
|
||||
|
||||
export type MemberRecord = {
|
||||
id: string
|
||||
fullName: string
|
||||
displayName: string
|
||||
email: string
|
||||
role: MemberRole
|
||||
billingStatus: MemberBillingStatus
|
||||
authProvider: MemberAuthProvider
|
||||
joinedAt: string
|
||||
avatarSrc: string
|
||||
initials: string
|
||||
}
|
||||
|
||||
export const MEMBER_RECORDS: MemberRecord[] = [
|
||||
{
|
||||
id: "lena-torres",
|
||||
fullName: "Lena Torres",
|
||||
displayName: "lena.torres",
|
||||
email: "lena@northline.app",
|
||||
role: "Admin",
|
||||
billingStatus: "Active",
|
||||
authProvider: "Google",
|
||||
joinedAt: "Apr 17, 2026",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&h=80&dpr=2&q=80",
|
||||
initials: "LT",
|
||||
},
|
||||
{
|
||||
id: "marcus-hale",
|
||||
fullName: "Marcus Hale",
|
||||
displayName: "marcus.hale",
|
||||
email: "marcus@northline.app",
|
||||
role: "Finance",
|
||||
billingStatus: "Pending invoice",
|
||||
authProvider: "Okta SSO",
|
||||
joinedAt: "Apr 12, 2026",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=80&h=80&dpr=2&q=80",
|
||||
initials: "MH",
|
||||
},
|
||||
{
|
||||
id: "imani-brooks",
|
||||
fullName: "Imani Brooks",
|
||||
displayName: "imani.brooks",
|
||||
email: "imani@northline.app",
|
||||
role: "Support",
|
||||
billingStatus: "Active",
|
||||
authProvider: "Passwordless",
|
||||
joinedAt: "Mar 29, 2026",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=80&h=80&dpr=2&q=80",
|
||||
initials: "IB",
|
||||
},
|
||||
{
|
||||
id: "theo-mercer",
|
||||
fullName: "Theo Mercer",
|
||||
displayName: "theo.mercer",
|
||||
email: "theo@northline.app",
|
||||
role: "Operations",
|
||||
billingStatus: "Manual review",
|
||||
authProvider: "GitHub",
|
||||
joinedAt: "Mar 18, 2026",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=80&h=80&dpr=2&q=80",
|
||||
initials: "TM",
|
||||
},
|
||||
{
|
||||
id: "noor-hadid",
|
||||
fullName: "Noor Hadid",
|
||||
displayName: "noor.hadid",
|
||||
email: "noor@northline.app",
|
||||
role: "Product",
|
||||
billingStatus: "Active",
|
||||
authProvider: "Google",
|
||||
joinedAt: "Feb 27, 2026",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=80&h=80&dpr=2&q=80",
|
||||
initials: "NH",
|
||||
},
|
||||
{
|
||||
id: "avery-quinn",
|
||||
fullName: "Avery Quinn",
|
||||
displayName: "avery.quinn",
|
||||
email: "avery@northline.app",
|
||||
role: "Security",
|
||||
billingStatus: "Past due",
|
||||
authProvider: "Okta SSO",
|
||||
joinedAt: "Feb 10, 2026",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1504593811423-6dd665756598?w=80&h=80&dpr=2&q=80",
|
||||
initials: "AQ",
|
||||
},
|
||||
{
|
||||
id: "sofia-lane",
|
||||
fullName: "Sofia Lane",
|
||||
displayName: "sofia.lane",
|
||||
email: "sofia@northline.app",
|
||||
role: "Finance",
|
||||
billingStatus: "Active",
|
||||
authProvider: "Google",
|
||||
joinedAt: "Jan 23, 2026",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=80&h=80&dpr=2&q=80",
|
||||
initials: "SL",
|
||||
},
|
||||
{
|
||||
id: "dev-rana",
|
||||
fullName: "Dev Rana",
|
||||
displayName: "dev.rana",
|
||||
email: "dev@northline.app",
|
||||
role: "Growth",
|
||||
billingStatus: "Pending invoice",
|
||||
authProvider: "GitHub",
|
||||
joinedAt: "Jan 11, 2026",
|
||||
avatarSrc:
|
||||
"https://images.unsplash.com/photo-1504257432389-52343af06ae3?w=80&h=80&dpr=2&q=80",
|
||||
initials: "DR",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
import { MembersDataGridView } from "./components/data-grid-view"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Members data grid
|
||||
</h1>
|
||||
<MembersDataGridView />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import { memo, type ComponentProps } from "react"
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import { type ColumnDef } from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { type IReleaseReview, type ReviewStatus, type RiskLevel } from "./data"
|
||||
import { ReleaseCheckRail } from "./release-check-rail"
|
||||
import { ChevronRightIcon, MoreHorizontalIcon, EyeIcon, CircleCheckIcon, CopyIcon, FlagIcon } from "lucide-react"
|
||||
|
||||
export type ReviewAction = "open" | "approve" | "escalate"
|
||||
|
||||
const riskVariant: Record<RiskLevel, ComponentProps<typeof Badge>["variant"]> =
|
||||
{
|
||||
Critical: "destructive-light",
|
||||
High: "warning-light",
|
||||
Medium: "info-light",
|
||||
Low: "secondary",
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
ReviewStatus,
|
||||
{
|
||||
variant: ComponentProps<typeof Badge>["variant"]
|
||||
dotClassName: string
|
||||
}
|
||||
> = {
|
||||
"Needs approval": {
|
||||
variant: "warning-outline",
|
||||
dotClassName: "bg-amber-500 dark:bg-amber-400",
|
||||
},
|
||||
Blocked: {
|
||||
variant: "destructive-outline",
|
||||
dotClassName: "bg-rose-500 dark:bg-rose-400",
|
||||
},
|
||||
Scheduled: {
|
||||
variant: "info-outline",
|
||||
dotClassName: "bg-sky-500 dark:bg-sky-400",
|
||||
},
|
||||
Ready: {
|
||||
variant: "success-outline",
|
||||
dotClassName: "bg-emerald-500 dark:bg-emerald-400",
|
||||
},
|
||||
Shipped: {
|
||||
variant: "outline",
|
||||
dotClassName: "bg-zinc-500 dark:bg-zinc-400",
|
||||
},
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<span
|
||||
className="size-1 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
}
|
||||
|
||||
function readinessPercent(review: IReleaseReview) {
|
||||
if (review.checksTotal === 0) return 100
|
||||
|
||||
return Math.round((review.checksCompleted / review.checksTotal) * 100)
|
||||
}
|
||||
|
||||
function readinessRingColor(percent: number) {
|
||||
if (percent >= 100) return "text-emerald-500"
|
||||
if (percent >= 50) return "text-amber-500"
|
||||
return "text-rose-500"
|
||||
}
|
||||
|
||||
export const StatusBadge = memo(function StatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: ReviewStatus
|
||||
}) {
|
||||
return (
|
||||
<Badge variant={statusConfig[status].variant}>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full!",
|
||||
statusConfig[status].dotClassName
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
|
||||
export const RiskBadge = memo(function RiskBadge({
|
||||
risk,
|
||||
}: {
|
||||
risk: RiskLevel
|
||||
}) {
|
||||
return <Badge variant={riskVariant[risk]}>{risk}</Badge>
|
||||
})
|
||||
|
||||
const ExpandReleaseButton = memo(function ExpandReleaseButton({
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={
|
||||
expanded ? "Collapse rollout checks" : "Expand rollout checks"
|
||||
}
|
||||
className="text-muted-foreground hover:text-foreground size-6 shrink-0 rounded-md p-0 shadow-none"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onToggle()
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon className={cn(
|
||||
"size-3.5 shrink-0 transition-transform duration-150",
|
||||
expanded && "rotate-90"
|
||||
)} aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
|
||||
const RequestCell = memo(function RequestCell({
|
||||
review,
|
||||
showChecks,
|
||||
canExpand,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
}: {
|
||||
review: IReleaseReview
|
||||
showChecks: boolean
|
||||
canExpand: boolean
|
||||
isExpanded: boolean
|
||||
onToggleExpand: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{showChecks && canExpand ? (
|
||||
<ExpandReleaseButton expanded={isExpanded} onToggle={onToggleExpand} />
|
||||
) : (
|
||||
<span className="size-6 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="text-foreground truncate text-sm leading-5 font-medium">
|
||||
{review.title}
|
||||
</span>
|
||||
<div className="text-muted-foreground flex min-w-0 flex-wrap items-center gap-2 text-xs leading-4">
|
||||
<span className="shrink-0 font-mono tracking-wide uppercase">
|
||||
{review.changeKey}
|
||||
</span>
|
||||
<DotSeparator />
|
||||
<span className="truncate">{review.service.label}</span>
|
||||
<DotSeparator />
|
||||
<span className="shrink-0">{review.environment}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const OwnerCell = memo(function OwnerCell({
|
||||
review,
|
||||
}: {
|
||||
review: IReleaseReview
|
||||
}) {
|
||||
const owner = review.owner
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar className="size-7 shrink-0">
|
||||
{owner.avatar ? (
|
||||
<AvatarImage src={owner.avatar} alt={owner.name} />
|
||||
) : null}
|
||||
<AvatarFallback>{initials(owner.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate text-sm font-medium">
|
||||
{owner.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{owner.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const WindowCell = memo(function WindowCell({
|
||||
review,
|
||||
}: {
|
||||
review: IReleaseReview
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{review.windowLabel}
|
||||
</span>
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-2 text-xs">
|
||||
<span className="truncate">{review.windowDurationLabel}</span>
|
||||
<DotSeparator />
|
||||
<span className="truncate">{review.blastRadiusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const ApprovalsCell = memo(function ApprovalsCell({
|
||||
review,
|
||||
}: {
|
||||
review: IReleaseReview
|
||||
}) {
|
||||
const pending = review.approvalsRequired - review.approvalsApproved
|
||||
const isComplete = pending <= 0
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground text-sm font-medium tabular-nums">
|
||||
{review.approvalsApproved}/{review.approvalsRequired}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isComplete
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isComplete ? "Complete" : `${pending} pending`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const ReadinessCell = memo(function ReadinessCell({
|
||||
review,
|
||||
}: {
|
||||
review: IReleaseReview
|
||||
}) {
|
||||
const percent = readinessPercent(review)
|
||||
const radius = 9
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const dashOffset = circumference - (percent / 100) * circumference
|
||||
const toneClassName = readinessRingColor(percent)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn("size-5 shrink-0", toneClassName)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-border"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-current"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
transform="rotate(-90 12 12)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-foreground text-sm tabular-nums">{percent}%</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const UpdatedCell = memo(function UpdatedCell({
|
||||
review,
|
||||
}: {
|
||||
review: IReleaseReview
|
||||
}) {
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm">{review.updatedLabel}</span>
|
||||
)
|
||||
})
|
||||
|
||||
function ReviewActionsCell({
|
||||
review,
|
||||
onAction,
|
||||
}: {
|
||||
review: IReleaseReview
|
||||
onAction: (action: ReviewAction, review: IReleaseReview) => void
|
||||
}) {
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Actions for ${review.changeKey}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
{/* Content */}
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onAction("open", review)}>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
Open review
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAction("approve", review)}>
|
||||
<CircleCheckIcon aria-hidden="true" />
|
||||
Approve window
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
copyToClipboard(review.changeKey)
|
||||
toast.success("Change key copied", {
|
||||
description: review.changeKey,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<CopyIcon aria-hidden="true" />
|
||||
Copy key
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onAction("escalate", review)}>
|
||||
<FlagIcon aria-hidden="true" />
|
||||
Escalate risk
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function createReleaseColumns({
|
||||
onReviewAction,
|
||||
showChecks,
|
||||
}: {
|
||||
onReviewAction: (action: ReviewAction, review: IReleaseReview) => void
|
||||
showChecks: boolean
|
||||
}): ColumnDef<IReleaseReview>[] {
|
||||
return [
|
||||
{
|
||||
accessorFn: (review) => review.title,
|
||||
id: "request",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Request" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<RequestCell
|
||||
review={row.original}
|
||||
showChecks={showChecks}
|
||||
canExpand={row.getCanExpand()}
|
||||
isExpanded={row.getIsExpanded()}
|
||||
onToggleExpand={row.getToggleExpandedHandler()}
|
||||
/>
|
||||
),
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
size: 332,
|
||||
minSize: 300,
|
||||
meta: {
|
||||
headerTitle: "Request",
|
||||
autoSize: true,
|
||||
headerClassName: "ps-5!",
|
||||
cellClassName: "ps-5!",
|
||||
expandedContent: (review: IReleaseReview) =>
|
||||
showChecks ? <ReleaseCheckRail review={review} /> : null,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (review) => review.owner.name,
|
||||
id: "owner",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Owner" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <OwnerCell review={row.original} />,
|
||||
size: 168,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerTitle: "Owner",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (review) => review.statusOrder,
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Status" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerTitle: "Status",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (review) => review.riskValue,
|
||||
id: "risk",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Risk" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <RiskBadge risk={row.original.risk} />,
|
||||
size: 116,
|
||||
meta: {
|
||||
headerTitle: "Risk",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (review) => review.windowStart,
|
||||
id: "window",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Window" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <WindowCell review={row.original} />,
|
||||
size: 200,
|
||||
meta: {
|
||||
headerTitle: "Window",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (review) => review.approvalsApproved,
|
||||
id: "approvals",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Approvals" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <ApprovalsCell review={row.original} />,
|
||||
size: 136,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerTitle: "Approvals",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (review) => review.checksCompleted,
|
||||
id: "readiness",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Readiness" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <ReadinessCell review={row.original} />,
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerTitle: "Readiness",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (review) => review.updatedAt,
|
||||
id: "updated",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Updated" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <UpdatedCell review={row.original} />,
|
||||
size: 120,
|
||||
meta: {
|
||||
headerTitle: "Updated",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<ReviewActionsCell review={row.original} onAction={onReviewAction} />
|
||||
),
|
||||
size: 56,
|
||||
meta: {
|
||||
headerClassName: "pe-5!",
|
||||
cellClassName: "pe-5!",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
getColumnHeaderLabel,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
type ExpandedState,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@evobgp/ui/components/toggle-group"
|
||||
import { createReleaseColumns, type ReviewAction } from "./columns"
|
||||
import {
|
||||
ORDERING_OPTIONS,
|
||||
RELEASE_REVIEWS,
|
||||
REVIEW_STATUS_ORDER,
|
||||
SERVICE_OPTIONS,
|
||||
type IReleaseReview,
|
||||
type ReleaseOrdering,
|
||||
type ReviewStatus,
|
||||
} from "./data"
|
||||
import { ReleaseReviewCard } from "./release-review-card"
|
||||
import { PlusIcon, SearchIcon, XIcon, Settings2Icon } from "lucide-react"
|
||||
|
||||
type TableDensity = "compact" | "comfortable"
|
||||
type OrderDirection = "asc" | "desc"
|
||||
type ServiceFilter = IReleaseReview["service"]["label"] | "All services"
|
||||
type StatusFilter = ReviewStatus | "All statuses"
|
||||
|
||||
const TABLE_DENSITY_OPTIONS: { value: TableDensity; label: string }[] = [
|
||||
{ value: "compact", label: "Compact" },
|
||||
{ value: "comfortable", label: "Comfortable" },
|
||||
]
|
||||
|
||||
const ORDER_DIRECTION_OPTIONS: { value: OrderDirection; label: string }[] = [
|
||||
{ value: "asc", label: "Asc" },
|
||||
{ value: "desc", label: "Desc" },
|
||||
]
|
||||
|
||||
function getDefaultExpandedReviewIds(reviews: IReleaseReview[]): ExpandedState {
|
||||
const firstExpandableRow = reviews.find((review) => review.checks?.length)
|
||||
|
||||
if (!firstExpandableRow) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return { [firstExpandableRow.id]: true }
|
||||
}
|
||||
|
||||
function compareReviews(
|
||||
reviewA: IReleaseReview,
|
||||
reviewB: IReleaseReview,
|
||||
sorting: SortingState
|
||||
) {
|
||||
const primarySort = sorting[0]
|
||||
if (!primarySort) return 0
|
||||
|
||||
const direction = primarySort.desc ? -1 : 1
|
||||
let baseValue = 0
|
||||
|
||||
switch (primarySort.id) {
|
||||
case "window":
|
||||
baseValue =
|
||||
reviewA.windowStart.localeCompare(reviewB.windowStart) ||
|
||||
reviewA.changeKey.localeCompare(reviewB.changeKey)
|
||||
break
|
||||
case "updated":
|
||||
baseValue =
|
||||
reviewA.updatedAt.localeCompare(reviewB.updatedAt) ||
|
||||
reviewA.changeKey.localeCompare(reviewB.changeKey)
|
||||
break
|
||||
case "risk":
|
||||
default:
|
||||
baseValue =
|
||||
reviewA.riskValue - reviewB.riskValue ||
|
||||
reviewA.changeKey.localeCompare(reviewB.changeKey)
|
||||
break
|
||||
}
|
||||
|
||||
return baseValue * direction
|
||||
}
|
||||
|
||||
function sortIdToOrdering(sortId: string | undefined): ReleaseOrdering {
|
||||
if (sortId === "window") return "window"
|
||||
if (sortId === "updated") return "updated"
|
||||
return "risk"
|
||||
}
|
||||
|
||||
function buildSorting(ordering: ReleaseOrdering, direction: OrderDirection) {
|
||||
const id =
|
||||
ordering === "window"
|
||||
? "window"
|
||||
: ordering === "updated"
|
||||
? "updated"
|
||||
: "risk"
|
||||
|
||||
return [{ id, desc: direction === "desc" }]
|
||||
}
|
||||
|
||||
function getReviewSearchBlob(review: IReleaseReview) {
|
||||
return [
|
||||
review.changeKey,
|
||||
review.title,
|
||||
review.service.label,
|
||||
review.environment,
|
||||
review.owner.name,
|
||||
review.status,
|
||||
review.risk,
|
||||
review.blastRadiusLabel,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function ReleaseReviewGridView() {
|
||||
const [reviews, setReviews] = useState<IReleaseReview[]>(RELEASE_REVIEWS)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [serviceFilter, setServiceFilter] =
|
||||
useState<ServiceFilter>("All services")
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("All statuses")
|
||||
const [includeShipped, setIncludeShipped] = useState(false)
|
||||
const [showChecks, setShowChecks] = useState(true)
|
||||
const [tableDensity, setTableDensity] = useState<TableDensity>("compact")
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>(
|
||||
buildSorting("risk", "desc")
|
||||
)
|
||||
const [expandedReviewIds, setExpandedReviewIds] = useState<ExpandedState>(
|
||||
() => getDefaultExpandedReviewIds(RELEASE_REVIEWS)
|
||||
)
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
owner: true,
|
||||
risk: true,
|
||||
window: false,
|
||||
approvals: true,
|
||||
updated: false,
|
||||
})
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) =>
|
||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
|
||||
)
|
||||
}, [])
|
||||
|
||||
const ordering = sortIdToOrdering(sorting[0]?.id)
|
||||
const direction: OrderDirection = sorting[0]?.desc ? "desc" : "asc"
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase()
|
||||
const hasActiveFilters =
|
||||
normalizedQuery.length > 0 ||
|
||||
serviceFilter !== "All services" ||
|
||||
statusFilter !== "All statuses"
|
||||
|
||||
const filteredReviews = useMemo(() => {
|
||||
return reviews.filter((review) => {
|
||||
if (!includeShipped && review.status === "Shipped") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
serviceFilter !== "All services" &&
|
||||
review.service.label !== serviceFilter
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (statusFilter !== "All statuses" && review.status !== statusFilter) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedQuery.length > 0 &&
|
||||
!getReviewSearchBlob(review).includes(normalizedQuery)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [includeShipped, normalizedQuery, reviews, serviceFilter, statusFilter])
|
||||
|
||||
const sortedReviews = useMemo(
|
||||
() => [...filteredReviews].sort((a, b) => compareReviews(a, b, sorting)),
|
||||
[filteredReviews, sorting]
|
||||
)
|
||||
|
||||
const hiddenShippedCount = useMemo(
|
||||
() =>
|
||||
includeShipped
|
||||
? 0
|
||||
: reviews.filter((review) => review.status === "Shipped").length,
|
||||
[includeShipped, reviews]
|
||||
)
|
||||
|
||||
const emptyMessage = useMemo(() => {
|
||||
if (filteredReviews.length > 0) return undefined
|
||||
|
||||
if (!includeShipped && hiddenShippedCount > 0 && !hasActiveFilters) {
|
||||
return `Only shipped reviews remain. Turn on Include shipped to show ${hiddenShippedCount} hidden ${hiddenShippedCount === 1 ? "record" : "records"}.`
|
||||
}
|
||||
|
||||
return "No release reviews match this view."
|
||||
}, [
|
||||
filteredReviews.length,
|
||||
hasActiveFilters,
|
||||
hiddenShippedCount,
|
||||
includeShipped,
|
||||
])
|
||||
|
||||
const handleOrderingChange = useCallback(
|
||||
(value: ReleaseOrdering | null) => {
|
||||
if (!value) return
|
||||
|
||||
setSorting(buildSorting(value, direction))
|
||||
resetPagination()
|
||||
},
|
||||
[direction, resetPagination]
|
||||
)
|
||||
|
||||
const handleDirectionChange = useCallback(
|
||||
(value: string[]) => {
|
||||
const nextDirection = value[0] as OrderDirection | undefined
|
||||
if (!nextDirection) return
|
||||
|
||||
setSorting(buildSorting(ordering, nextDirection))
|
||||
resetPagination()
|
||||
},
|
||||
[ordering, resetPagination]
|
||||
)
|
||||
|
||||
const handleShowChecksChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
setShowChecks(checked)
|
||||
setExpandedReviewIds((current) => {
|
||||
if (!checked) return {}
|
||||
return Object.keys(current).length > 0
|
||||
? current
|
||||
: getDefaultExpandedReviewIds(sortedReviews)
|
||||
})
|
||||
},
|
||||
[sortedReviews]
|
||||
)
|
||||
|
||||
const handleIncludeShippedChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
setIncludeShipped(checked)
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setSearchQuery("")
|
||||
setServiceFilter("All services")
|
||||
setStatusFilter("All statuses")
|
||||
resetPagination()
|
||||
}, [resetPagination])
|
||||
|
||||
const handleReviewAction = useCallback(
|
||||
(action: ReviewAction, review: IReleaseReview) => {
|
||||
if (action === "open") {
|
||||
toast.info("Open release review", {
|
||||
description: `${review.changeKey} · ${review.title}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "approve") {
|
||||
setReviews((current) =>
|
||||
current.map((item) =>
|
||||
item.id === review.id
|
||||
? {
|
||||
...item,
|
||||
status: "Ready",
|
||||
statusOrder: REVIEW_STATUS_ORDER.indexOf("Ready"),
|
||||
approvalsApproved: item.approvalsRequired,
|
||||
updatedAt: "2026-04-14T09:30:00Z",
|
||||
updatedLabel: "Just now",
|
||||
}
|
||||
: item
|
||||
)
|
||||
)
|
||||
resetPagination()
|
||||
toast.success("Window approved", {
|
||||
description: `${review.changeKey} is now ready for the scheduled window.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setReviews((current) =>
|
||||
current.map((item) =>
|
||||
item.id === review.id
|
||||
? {
|
||||
...item,
|
||||
status: "Blocked",
|
||||
statusOrder: REVIEW_STATUS_ORDER.indexOf("Blocked"),
|
||||
risk: "Critical",
|
||||
riskValue: 4,
|
||||
updatedAt: "2026-04-14T09:30:00Z",
|
||||
updatedLabel: "Just now",
|
||||
}
|
||||
: item
|
||||
)
|
||||
)
|
||||
toast.warning("Risk escalated", {
|
||||
description: `${review.changeKey} moved to the blocked queue.`,
|
||||
})
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createReleaseColumns({
|
||||
onReviewAction: handleReviewAction,
|
||||
showChecks,
|
||||
}),
|
||||
[handleReviewAction, showChecks]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: sortedReviews,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
expanded: expandedReviewIds,
|
||||
columnVisibility,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
onExpandedChange: setExpandedReviewIds,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: (row) =>
|
||||
showChecks && Boolean(row.original.checks?.length),
|
||||
paginateExpandedRows: false,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredReviews.length}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={{
|
||||
dense: tableDensity === "compact",
|
||||
headerSticky: true,
|
||||
columnsResizable: true,
|
||||
columnsMovable: true,
|
||||
columnsVisibility: true,
|
||||
width: "auto",
|
||||
}}
|
||||
>
|
||||
{/* Card */}
|
||||
<ReleaseReviewCard
|
||||
title="Release Reviews"
|
||||
description="Upcoming windows, blockers, and checks."
|
||||
action={
|
||||
<Button type="button" size="sm">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
New review
|
||||
</Button>
|
||||
}
|
||||
footer={
|
||||
<div className="flex w-full justify-end">
|
||||
<DataGridPagination />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="px-5 py-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<InputGroup className="w-full min-w-64 sm:w-72">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search releases..."
|
||||
aria-label="Search releases"
|
||||
value={searchQuery}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value)
|
||||
resetPagination()
|
||||
}}
|
||||
/>
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
aria-label="Clear search"
|
||||
size="icon-xs"
|
||||
onClick={() => {
|
||||
setSearchQuery("")
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
|
||||
<Select
|
||||
value={serviceFilter}
|
||||
onValueChange={(value) => {
|
||||
setServiceFilter(value as ServiceFilter)
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectGroup>
|
||||
<SelectItem value="All services">All services</SelectItem>
|
||||
{SERVICE_OPTIONS.map((service) => (
|
||||
<SelectItem key={service.value} value={service.label}>
|
||||
{service.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => {
|
||||
setStatusFilter(value as StatusFilter)
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-[170px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectGroup>
|
||||
<SelectItem value="All statuses">All statuses</SelectItem>
|
||||
{REVIEW_STATUS_ORDER.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{hasActiveFilters ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleClearFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button type="button" size="sm" variant="outline">
|
||||
<Settings2Icon aria-hidden="true" />
|
||||
View Settings
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-[340px] p-0">
|
||||
<FieldGroup className="gap-3 px-3.5 py-3">
|
||||
<div className="flex flex-col gap-0">
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className="min-h-9 items-center justify-between gap-3"
|
||||
>
|
||||
<FieldLabel className="text-sm font-normal">
|
||||
Ordering
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={ordering}
|
||||
onValueChange={handleOrderingChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-[132px] shrink-0"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectGroup>
|
||||
{ORDERING_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className="min-h-9 items-center justify-between gap-3"
|
||||
>
|
||||
<FieldLabel className="text-sm font-normal">
|
||||
Direction
|
||||
</FieldLabel>
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={[direction]}
|
||||
onValueChange={handleDirectionChange}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto w-[132px] shrink-0 justify-end"
|
||||
>
|
||||
{ORDER_DIRECTION_OPTIONS.map((option) => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="grow"
|
||||
>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className="min-h-9 items-center justify-between gap-3"
|
||||
>
|
||||
<FieldLabel className="text-sm font-normal">
|
||||
Density
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={tableDensity}
|
||||
onValueChange={(value) =>
|
||||
setTableDensity(value as TableDensity)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-[132px] shrink-0"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectGroup>
|
||||
{TABLE_DENSITY_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<FieldSeparator />
|
||||
|
||||
<div className="flex flex-col gap-0">
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className="min-h-9 items-center justify-between gap-3"
|
||||
>
|
||||
<FieldLabel className="text-sm font-normal">
|
||||
Check cards
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={showChecks}
|
||||
onCheckedChange={handleShowChecksChange}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className="min-h-9 items-center justify-between gap-3"
|
||||
>
|
||||
<FieldLabel className="text-sm font-normal">
|
||||
Include shipped
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={includeShipped}
|
||||
onCheckedChange={handleIncludeShippedChange}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<FieldSeparator />
|
||||
|
||||
<div className="flex flex-col gap-0">
|
||||
<span className="text-muted-foreground px-0.5 pb-1 text-xs font-medium">
|
||||
Columns
|
||||
</span>
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => (
|
||||
<Field
|
||||
key={column.id}
|
||||
orientation="horizontal"
|
||||
className="min-h-9 items-center justify-between gap-3"
|
||||
>
|
||||
<FieldLabel className="text-sm font-normal">
|
||||
{getColumnHeaderLabel(column)}
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(checked) =>
|
||||
column.toggleVisibility(checked)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DataGridContainer border={false}>
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</ReleaseReviewCard>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+212
@@ -0,0 +1,212 @@
|
||||
import { type ComponentProps } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Rating } from "@/components/reui/rating"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
|
||||
import {
|
||||
type CheckStatus,
|
||||
type IReleaseCheck,
|
||||
type IReleaseReview,
|
||||
} from "./data"
|
||||
|
||||
const MAX_VISIBLE_CHECK_CARDS = 3
|
||||
|
||||
const checkStatusConfig: Record<
|
||||
CheckStatus,
|
||||
{
|
||||
variant: ComponentProps<typeof Badge>["variant"]
|
||||
dotClassName: string
|
||||
}
|
||||
> = {
|
||||
Open: {
|
||||
variant: "invert-light",
|
||||
dotClassName: "bg-zinc-400 dark:bg-zinc-500",
|
||||
},
|
||||
Done: {
|
||||
variant: "success-light",
|
||||
dotClassName: "bg-emerald-500 dark:bg-emerald-400",
|
||||
},
|
||||
Flagged: {
|
||||
variant: "destructive-light",
|
||||
dotClassName: "bg-rose-500 dark:bg-rose-400",
|
||||
},
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<span
|
||||
className="size-1 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
}
|
||||
|
||||
function coverageRingColor(coverage: number) {
|
||||
if (coverage >= 85) return "text-emerald-500"
|
||||
if (coverage >= 60) return "text-amber-500"
|
||||
return "text-rose-500"
|
||||
}
|
||||
|
||||
function CheckStatusBadge({ status }: { status: CheckStatus }) {
|
||||
return (
|
||||
<Badge variant={checkStatusConfig[status].variant}>
|
||||
<span
|
||||
className={`${checkStatusConfig[status].dotClassName} size-1.5 shrink-0 rounded-full`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function CoverageRing({
|
||||
coverage,
|
||||
className,
|
||||
}: {
|
||||
coverage: number
|
||||
className?: string
|
||||
}) {
|
||||
const radius = 8
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const dashOffset = circumference - (coverage / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1.5", className)}>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn("size-4.5 shrink-0", coverageRingColor(coverage))}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-border"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-current"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
transform="rotate(-90 12 12)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{coverage}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseCheckCard({ check }: { check: IReleaseCheck }) {
|
||||
return (
|
||||
<Card className="h-full w-full min-w-0 gap-0 p-0 shadow-none! sm:w-[22rem]">
|
||||
{/* Content */}
|
||||
<CardContent className="flex flex-col gap-2.5 p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckStatusBadge status={check.status} />
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h4 className="text-foreground line-clamp-2 text-sm leading-5 font-medium">
|
||||
{check.title}
|
||||
</h4>
|
||||
<div className="text-muted-foreground flex min-w-0 flex-col gap-1 text-xs sm:flex-row sm:items-center sm:gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Avatar className="size-6 shrink-0">
|
||||
{check.reviewer?.avatar ? (
|
||||
<AvatarImage
|
||||
src={check.reviewer.avatar}
|
||||
alt={check.reviewer.name}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback>
|
||||
{check.reviewer ? initials(check.reviewer.name) : "--"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">
|
||||
{check.reviewer?.name ?? "Reviewer pending"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="hidden sm:inline-flex">
|
||||
<DotSeparator />
|
||||
</span>
|
||||
<span className="shrink-0">{check.dueLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-3.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Rating rating={check.confidence} size="sm" showValue={false} />
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{check.confidence.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<CoverageRing coverage={check.coverage} className="ml-auto" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReleaseCheckRail({ review }: { review: IReleaseReview }) {
|
||||
const checks = review.checks ?? []
|
||||
|
||||
if (checks.length === 0) return null
|
||||
|
||||
const visibleChecks = checks.slice(0, MAX_VISIBLE_CHECK_CARDS)
|
||||
const hiddenCheckCount = checks.length - visibleChecks.length
|
||||
const openChecks = checks.filter((check) => check.status !== "Done").length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 pt-3 pb-2">
|
||||
<span className="text-foreground text-xs font-medium">Checklist</span>
|
||||
<DotSeparator />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{hiddenCheckCount > 0
|
||||
? `${visibleChecks.length} of ${checks.length} items`
|
||||
: `${checks.length} items`}
|
||||
</span>
|
||||
<DotSeparator />
|
||||
<span className="text-muted-foreground text-xs">{openChecks} open</span>
|
||||
{hiddenCheckCount > 0 ? (
|
||||
<>
|
||||
<DotSeparator />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
+{hiddenCheckCount} more
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="flex w-full max-w-[70rem] flex-wrap gap-3 px-5 pt-1 pb-4">
|
||||
{visibleChecks.map((check) => (
|
||||
<ReleaseCheckCard key={check.id} check={check} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@evobgp/ui/components/card"
|
||||
|
||||
interface ReleaseReviewCardProps {
|
||||
title: string
|
||||
description: ReactNode
|
||||
action?: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
footerClassName?: string
|
||||
}
|
||||
|
||||
export function ReleaseReviewCard({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
contentClassName,
|
||||
footerClassName,
|
||||
}: ReleaseReviewCardProps) {
|
||||
return (
|
||||
<Card className={cn("w-full gap-0 p-0", className)}>
|
||||
{/* Header */}
|
||||
<CardHeader className="gap-1 border-b px-5 py-3.5">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription className="text-xs leading-4">
|
||||
{description}
|
||||
</CardDescription>
|
||||
{action ? (
|
||||
<CardAction className="self-center">{action}</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
{/* Content */}
|
||||
<CardContent className={cn("p-0", contentClassName)}>
|
||||
{children}
|
||||
</CardContent>
|
||||
|
||||
{footer ? (
|
||||
<CardFooter className={cn("border-t px-5 py-2.5", footerClassName)}>
|
||||
{footer}
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ReleaseReviewGridView } from "./components/data-grid-view"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Release review data grid
|
||||
</h1>
|
||||
<ReleaseReviewGridView />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { memo, useState } from "react"
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { Row, type ColumnDef } from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@evobgp/ui/components/alert-dialog"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
||||
import { ITransaction, TxChannel, TxStatus } from "./data"
|
||||
import { MoreHorizontalIcon, EyeIcon, CopyIcon, DownloadIcon, TriangleAlertIcon, ArrowRightIcon } from "lucide-react"
|
||||
|
||||
// ── Formatters ──
|
||||
|
||||
function formatAmount(amount: number, currency: string) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(Math.abs(amount))
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(iso))
|
||||
}
|
||||
|
||||
// ── Status (aligned with data-grid-1 badge language) ──
|
||||
|
||||
export const StatusBadge = memo(function StatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: TxStatus
|
||||
}) {
|
||||
if (status === "completed")
|
||||
return <Badge variant="success-outline">Completed</Badge>
|
||||
if (status === "failed")
|
||||
return <Badge variant="destructive-outline">Failed</Badge>
|
||||
if (status === "pending")
|
||||
return <Badge variant="warning-outline">Pending</Badge>
|
||||
return <Badge variant="info-outline">Cancelled</Badge>
|
||||
})
|
||||
|
||||
// ── Channel (origin of charge - replaces "method" duplicate logos) ──
|
||||
|
||||
const channelLabel: Record<TxChannel, string> = {
|
||||
checkout: "Checkout",
|
||||
api: "API",
|
||||
invoice: "Invoice",
|
||||
dashboard: "Dashboard",
|
||||
}
|
||||
|
||||
const channelVariant: Record<
|
||||
TxChannel,
|
||||
"success-light" | "info-light" | "warning-light"
|
||||
> = {
|
||||
checkout: "success-light",
|
||||
api: "info-light",
|
||||
invoice: "warning-light",
|
||||
dashboard: "success-light",
|
||||
}
|
||||
|
||||
export const ChannelBadge = memo(function ChannelBadge({
|
||||
channel,
|
||||
}: {
|
||||
channel: TxChannel
|
||||
}) {
|
||||
return <Badge variant="secondary">{channelLabel[channel]}</Badge>
|
||||
})
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
export function ActionsCell({ row }: { row: Row<ITransaction> }) {
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const [disputeOpen, setDisputeOpen] = useState(false)
|
||||
|
||||
const handleCopyRef = () => {
|
||||
copyToClipboard(row.original.reference)
|
||||
toast.success("Reference copied", { description: row.original.reference })
|
||||
}
|
||||
|
||||
const handleDisputeConfirm = () => {
|
||||
setDisputeOpen(false)
|
||||
toast.message("Dispute opened", {
|
||||
description: `${row.original.reference}. Connect your payments API (demo).`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label="Transaction actions"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="start" className="min-w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Transaction details", {
|
||||
description: "Demo. Navigate to your detail view.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<EyeIcon className="size-4" aria-hidden="true" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCopyRef}>
|
||||
<CopyIcon className="size-4" aria-hidden="true" />
|
||||
Copy Reference
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.success("Receipt", {
|
||||
description: "Demo. Download from your storage.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<DownloadIcon className="size-4" aria-hidden="true" />
|
||||
Receipt
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDisputeOpen(true)}
|
||||
>
|
||||
<TriangleAlertIcon className="size-4" aria-hidden="true" />
|
||||
Dispute
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={disputeOpen} onOpenChange={setDisputeOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Open a dispute?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This starts a dispute for{""}
|
||||
<span className="text-foreground font-mono text-xs">
|
||||
{row.original.reference}
|
||||
</span>
|
||||
. In production this is irreversible until resolved. Wire to your
|
||||
payment provider.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={handleDisputeConfirm}
|
||||
>
|
||||
Start dispute
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<ITransaction>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
id: "id",
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
enableSorting: false,
|
||||
size: 35,
|
||||
enableResizing: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
skeleton: <Skeleton className="size-4 rounded" />,
|
||||
headerClassName:
|
||||
"[--data-grid-header-cell-ps:var(--frame-panel-header-px)]",
|
||||
cellClassName: "[--data-grid-body-cell-ps:var(--frame-panel-px)]",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "reference",
|
||||
id: "reference",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="group/ref text-foreground hover:text-primary inline-flex min-w-0 cursor-pointer items-center gap-1 truncate font-mono text-xs font-medium transition-colors">
|
||||
<span className="group-hover/ref:border-primary border-b border-transparent py-0.25 transition-colors">
|
||||
{row.original.reference}
|
||||
</span>
|
||||
<ArrowRightIcon className="size-3 shrink-0 -translate-x-1 opacity-0 transition-all group-hover/ref:translate-x-0 group-hover/ref:opacity-100" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatDate(row.original.date)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Reference",
|
||||
skeleton: (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<Skeleton className="h-3.5 w-28" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
id: "description",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="tems-center flex gap-2.5">
|
||||
<Item className="w-auto shrink-0 border-0 p-0 [&_svg]:size-5">
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{row.original.merchantLogo}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate text-sm font-medium">
|
||||
{row.original.description}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{row.original.merchant} · {row.original.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
minSize: 150,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Description",
|
||||
skeleton: (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Skeleton className="size-5 shrink-0 rounded" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<Skeleton className="h-3.5 w-36" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "country",
|
||||
id: "region",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<img
|
||||
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="size-4 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<span className="text-foreground line-clamp-1 text-sm font-medium">
|
||||
{row.original.country}
|
||||
</span>
|
||||
</div>
|
||||
{row.original.timezone && (
|
||||
<span className="text-muted-foreground line-clamp-1 text-xs tabular-nums">
|
||||
{row.original.timezone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
size: 170,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Region",
|
||||
skeleton: (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="size-4 shrink-0 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "channel",
|
||||
id: "channel",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ChannelBadge channel={row.original.channel} />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Channel",
|
||||
skeleton: <Skeleton className="h-6 w-20 rounded-full" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
id: "amount",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const { amount, currency } = row.original
|
||||
const inflow = amount > 0
|
||||
return (
|
||||
<span
|
||||
className={cn("font-semibold tabular-nums", inflow && "text-success")}
|
||||
>
|
||||
{inflow ? "+" : "−"}
|
||||
{formatAmount(amount, currency)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
headerTitle: "Amount",
|
||||
skeleton: <Skeleton className="h-4 w-20" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "account",
|
||||
id: "account",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-foreground font-mono text-xs">
|
||||
{row.original.account}
|
||||
</span>
|
||||
),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Account",
|
||||
skeleton: <Skeleton className="h-4 w-24" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
size: 90,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Status",
|
||||
skeleton: <Skeleton className="h-6 w-20 rounded-full" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => <ActionsCell row={row} />,
|
||||
size: 50,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
skeleton: <Skeleton className="size-7 rounded" />,
|
||||
headerClassName:
|
||||
"[--data-grid-header-cell-pe:var(--frame-panel-header-px)]",
|
||||
cellClassName: "[--data-grid-body-cell-pe:var(--frame-panel-px)]",
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,597 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
createFilter,
|
||||
Filters,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from "@/components/reui/filters"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
ColumnSizingState,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
PaginationState,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import { TooltipProvider } from "@evobgp/ui/components/tooltip"
|
||||
import { ChannelBadge, columns, StatusBadge } from "./columns"
|
||||
import {
|
||||
TRANSACTIONS,
|
||||
type ITransaction,
|
||||
type TxChannel,
|
||||
type TxStatus,
|
||||
} from "./data"
|
||||
import { HashIcon, FileTextIcon, Building2Icon, FolderIcon, GlobeIcon, RouteIcon, CircleDotIcon, PlusIcon, FilterIcon, FunnelXIcon } from "lucide-react"
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function getActiveFilters(filters: Filter[]) {
|
||||
return filters.filter((filter) => {
|
||||
const { values } = filter
|
||||
if (!values || values.length === 0) return false
|
||||
if (
|
||||
values.every((value) => typeof value === "string" && value.trim() === "")
|
||||
)
|
||||
return false
|
||||
if (values.every((value) => value === null || value === undefined))
|
||||
return false
|
||||
if (values.every((value) => Array.isArray(value) && value.length === 0))
|
||||
return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** Stable key for comparing whether applied (active) filters meaningfully changed. */
|
||||
function serializeActiveFiltersKey(active: Filter[]) {
|
||||
return JSON.stringify(
|
||||
active.map((f) => ({
|
||||
field: f.field,
|
||||
operator: f.operator,
|
||||
values: f.values,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
function filterFieldValue(item: ITransaction, field: string): unknown {
|
||||
if (field === "region") {
|
||||
return [item.country, item.timezone].filter(Boolean).join(" ")
|
||||
}
|
||||
return item[field as keyof ITransaction]
|
||||
}
|
||||
|
||||
function applyFiltersToData(
|
||||
data: ITransaction[],
|
||||
filters: Filter[]
|
||||
): ITransaction[] {
|
||||
const active = getActiveFilters(filters)
|
||||
let result = [...data]
|
||||
active.forEach((filter) => {
|
||||
const { field, operator, values } = filter
|
||||
result = result.filter((item) => {
|
||||
const raw = filterFieldValue(item, field)
|
||||
const fieldValue = raw != null ? raw : ""
|
||||
|
||||
switch (operator) {
|
||||
case "is":
|
||||
return values.includes(fieldValue)
|
||||
case "is_not":
|
||||
return !values.includes(fieldValue)
|
||||
case "is_any_of":
|
||||
return values.some((v) => fieldValue === v)
|
||||
case "is_not_any_of":
|
||||
return !values.some((v) => fieldValue === v)
|
||||
case "contains": {
|
||||
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
|
||||
if (tokens.length === 0) return true
|
||||
return tokens.some((token) =>
|
||||
String(fieldValue).toLowerCase().includes(token.toLowerCase())
|
||||
)
|
||||
}
|
||||
case "not_contains":
|
||||
return !values.some((v) =>
|
||||
String(fieldValue).toLowerCase().includes(String(v).toLowerCase())
|
||||
)
|
||||
case "starts_with":
|
||||
return values.some((v) =>
|
||||
String(fieldValue).toLowerCase().startsWith(String(v).toLowerCase())
|
||||
)
|
||||
case "ends_with":
|
||||
return values.some((v) =>
|
||||
String(fieldValue).toLowerCase().endsWith(String(v).toLowerCase())
|
||||
)
|
||||
case "equals":
|
||||
return fieldValue === values[0]
|
||||
case "not_equals":
|
||||
return fieldValue !== values[0]
|
||||
case "greater_than":
|
||||
return Number(fieldValue) > Number(values[0])
|
||||
case "less_than":
|
||||
return Number(fieldValue) < Number(values[0])
|
||||
case "greater_than_or_equal":
|
||||
return Number(fieldValue) >= Number(values[0])
|
||||
case "less_than_or_equal":
|
||||
return Number(fieldValue) <= Number(values[0])
|
||||
case "between":
|
||||
if (values.length >= 2) {
|
||||
const min = Number(values[0])
|
||||
const max = Number(values[1])
|
||||
return Number(fieldValue) >= min && Number(fieldValue) <= max
|
||||
}
|
||||
return true
|
||||
case "not_between":
|
||||
if (values.length >= 2) {
|
||||
const min = Number(values[0])
|
||||
const max = Number(values[1])
|
||||
return Number(fieldValue) < min || Number(fieldValue) > max
|
||||
}
|
||||
return true
|
||||
case "empty":
|
||||
return fieldValue === "" || fieldValue == null
|
||||
case "not_empty":
|
||||
return fieldValue !== "" && fieldValue != null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Filter field config ──
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
"Payments",
|
||||
"Infrastructure",
|
||||
"AI / ML",
|
||||
"Automation",
|
||||
"Design",
|
||||
"Documentation",
|
||||
"Backend",
|
||||
"Developer tools",
|
||||
]
|
||||
|
||||
const STATUS_OPTIONS: { value: TxStatus; label: string }[] = [
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
]
|
||||
|
||||
const CHANNEL_OPTIONS: { value: TxChannel; label: string }[] = [
|
||||
{ value: "checkout", label: "Checkout" },
|
||||
{ value: "api", label: "API" },
|
||||
{ value: "invoice", label: "Invoice" },
|
||||
{ value: "dashboard", label: "Dashboard" },
|
||||
]
|
||||
|
||||
const MERCHANT_FILTER_OPTIONS = Array.from(
|
||||
new Map(
|
||||
TRANSACTIONS.map((transaction) => [
|
||||
transaction.merchant,
|
||||
{
|
||||
value: transaction.merchant,
|
||||
label: transaction.merchant,
|
||||
icon: (
|
||||
<Item
|
||||
render={<span />}
|
||||
className="w-auto shrink-0 border-0 p-0 [&_svg]:size-4"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{transaction.merchantLogo}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
),
|
||||
},
|
||||
])
|
||||
).values()
|
||||
)
|
||||
|
||||
function renderSelectedCount(values: unknown[]) {
|
||||
if (values.length === 0) return "Select..."
|
||||
if (values.length > 1) return `${values.length} selected`
|
||||
return null
|
||||
}
|
||||
|
||||
const filterFields: FilterFieldConfig[] = [
|
||||
{
|
||||
key: "reference",
|
||||
label: "Reference",
|
||||
icon: (
|
||||
<HashIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "text",
|
||||
className: "w-44",
|
||||
placeholder: "Search...",
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "Description",
|
||||
icon: (
|
||||
<FileTextIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "text",
|
||||
className: "w-52",
|
||||
placeholder: "Search...",
|
||||
},
|
||||
{
|
||||
key: "merchant",
|
||||
label: "Merchant",
|
||||
icon: (
|
||||
<Building2Icon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: true,
|
||||
className: "w-[200px]",
|
||||
options: MERCHANT_FILTER_OPTIONS,
|
||||
customValueRenderer: (values, options) => {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
|
||||
const option = options.find((item) => item.value === values[0])
|
||||
if (!option) return String(values[0])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "category",
|
||||
label: "Category",
|
||||
icon: (
|
||||
<FolderIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: true,
|
||||
className: "w-[200px]",
|
||||
options: CATEGORY_OPTIONS.map((category) => ({
|
||||
value: category,
|
||||
label: category,
|
||||
})),
|
||||
customValueRenderer: (values) => {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
|
||||
return <Badge variant="outline">{String(values[0])}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
label: "Region",
|
||||
icon: (
|
||||
<GlobeIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "text",
|
||||
className: "w-52",
|
||||
placeholder: "Search...",
|
||||
},
|
||||
{
|
||||
key: "channel",
|
||||
label: "Channel",
|
||||
icon: (
|
||||
<RouteIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[156px]",
|
||||
options: CHANNEL_OPTIONS,
|
||||
customValueRenderer: (values) => {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
|
||||
return <ChannelBadge channel={values[0] as TxChannel} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
icon: (
|
||||
<CircleDotIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[156px]",
|
||||
options: STATUS_OPTIONS,
|
||||
customValueRenderer: (values) => {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
|
||||
return <StatusBadge status={values[0] as TxStatus} />
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** Single default row: Reference | contains | empty - inactive until user types (see getActiveFilters). */
|
||||
function createDefaultTransactionFilters(): Filter[] {
|
||||
return [createFilter("reference", "contains", [""])]
|
||||
}
|
||||
|
||||
const DESCRIPTION_COLUMN_ID = "description"
|
||||
const DESCRIPTION_COLUMN_DEFAULT_SIZE = 300
|
||||
|
||||
function getAutoDescriptionColumnSize(
|
||||
containerWidth: number,
|
||||
columnVisibility: VisibilityState
|
||||
) {
|
||||
const occupiedWidth = columns.reduce((total, column) => {
|
||||
if (
|
||||
!column.id ||
|
||||
column.id === DESCRIPTION_COLUMN_ID ||
|
||||
columnVisibility[column.id] === false
|
||||
) {
|
||||
return total
|
||||
}
|
||||
|
||||
return total + (typeof column.size === "number" ? column.size : 0)
|
||||
}, 0)
|
||||
|
||||
return Math.max(
|
||||
DESCRIPTION_COLUMN_DEFAULT_SIZE,
|
||||
Math.round(containerWidth - occupiedWidth)
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ──
|
||||
|
||||
export function DataGridView() {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "reference", desc: true },
|
||||
])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
account: false,
|
||||
})
|
||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({})
|
||||
const [filters, setFilters] = useState<Filter[]>(
|
||||
createDefaultTransactionFilters
|
||||
)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [filteredData, setFilteredData] = useState<ITransaction[]>(TRANSACTIONS)
|
||||
const [gridWidth, setGridWidth] = useState(0)
|
||||
const isInitialLoad = useRef(true)
|
||||
const gridWidthRef = useRef<HTMLDivElement>(null)
|
||||
const hasManualColumnSizing = useRef(false)
|
||||
const lastAppliedActiveKey = useRef<string>(
|
||||
serializeActiveFiltersKey(
|
||||
getActiveFilters(createDefaultTransactionFilters())
|
||||
)
|
||||
)
|
||||
|
||||
const applyFilters = useCallback((newFilters: Filter[]) => {
|
||||
return applyFiltersToData(TRANSACTIONS, newFilters)
|
||||
}, [])
|
||||
|
||||
const simulateAsyncFiltering = useCallback(
|
||||
async (newFilters: Filter[]) => {
|
||||
setIsLoading(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
setFilteredData(applyFilters(newFilters))
|
||||
setIsLoading(false)
|
||||
},
|
||||
[applyFilters]
|
||||
)
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(newFilters: Filter[]) => {
|
||||
setFilters(newFilters)
|
||||
const newActive = getActiveFilters(newFilters)
|
||||
const nextKey = serializeActiveFiltersKey(newActive)
|
||||
if (nextKey === lastAppliedActiveKey.current) return
|
||||
lastAppliedActiveKey.current = nextKey
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
|
||||
simulateAsyncFiltering(newFilters)
|
||||
},
|
||||
[simulateAsyncFiltering]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialLoad.current) {
|
||||
setFilteredData(applyFilters(filters))
|
||||
isInitialLoad.current = false
|
||||
}
|
||||
}, [filters, applyFilters])
|
||||
|
||||
useEffect(() => {
|
||||
const element = gridWidthRef.current
|
||||
|
||||
if (!element) return
|
||||
|
||||
const syncGridWidth = () => {
|
||||
setGridWidth(element.clientWidth)
|
||||
}
|
||||
|
||||
syncGridWidth()
|
||||
|
||||
if (typeof ResizeObserver === "undefined") return
|
||||
|
||||
const observer = new ResizeObserver(syncGridWidth)
|
||||
observer.observe(element)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (gridWidth <= 0 || hasManualColumnSizing.current) return
|
||||
|
||||
const nextDescriptionWidth = getAutoDescriptionColumnSize(
|
||||
gridWidth,
|
||||
columnVisibility
|
||||
)
|
||||
|
||||
setColumnSizing((current) =>
|
||||
current[DESCRIPTION_COLUMN_ID] === nextDescriptionWidth
|
||||
? current
|
||||
: {
|
||||
...current,
|
||||
[DESCRIPTION_COLUMN_ID]: nextDescriptionWidth,
|
||||
}
|
||||
)
|
||||
}, [columnVisibility, gridWidth])
|
||||
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(
|
||||
columns.map((c) => c.id as string)
|
||||
)
|
||||
|
||||
const handleColumnSizingChange = useCallback(
|
||||
(
|
||||
updater:
|
||||
| ColumnSizingState
|
||||
| ((old: ColumnSizingState) => ColumnSizingState)
|
||||
) => {
|
||||
hasManualColumnSizing.current = true
|
||||
setColumnSizing(updater)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: filteredData,
|
||||
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
|
||||
getRowId: (row) => row.id,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
columnOrder,
|
||||
columnSizing,
|
||||
columnVisibility,
|
||||
},
|
||||
columnResizeMode: "onChange",
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnSizingChange: handleColumnSizingChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
/** Show Clear whenever any filter row is visible (including empty placeholders). */
|
||||
const showClearButton = filters.length > 0
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={200}>
|
||||
{/* Table */}
|
||||
<DataGrid
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage={
|
||||
!isLoading && filteredData.length === 0
|
||||
? "No transactions match your filters."
|
||||
: undefined
|
||||
}
|
||||
tableLayout={{
|
||||
columnsPinnable: true,
|
||||
columnsResizable: true,
|
||||
columnsMovable: true,
|
||||
columnsVisibility: true,
|
||||
dense: true,
|
||||
}}
|
||||
>
|
||||
<Frame variant="default" spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FrameTitle id="page-heading" className="text-balance">
|
||||
Transactions
|
||||
</FrameTitle>
|
||||
<FrameDescription className="text-xs text-pretty">
|
||||
Billing ledger(refunds / payouts)
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toast.info("New transaction", {
|
||||
description:
|
||||
"Wire to your checkout or manual entry flow, demo only.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon aria-hidden />
|
||||
New transaction
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0! shadow-none!">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button variant="outline" aria-label="Filters">
|
||||
<FilterIcon aria-hidden />
|
||||
Filters
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{showClearButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
const next = createDefaultTransactionFilters()
|
||||
lastAppliedActiveKey.current = serializeActiveFiltersKey(
|
||||
getActiveFilters(next)
|
||||
)
|
||||
setFilters(next)
|
||||
simulateAsyncFiltering(next)
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<FunnelXIcon className="size-3.5" aria-hidden />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div ref={gridWidthRef} className="w-full">
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</div>
|
||||
</FramePanel>
|
||||
<FrameFooter>
|
||||
<DataGridPagination />
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { AnthropicBlack } from "@evobgp/ui/components/svgs/anthropicBlack"
|
||||
import { AnthropicWhite } from "@evobgp/ui/components/svgs/anthropicWhite"
|
||||
import { Convex } from "@evobgp/ui/components/svgs/convex"
|
||||
import { Mintlify } from "@evobgp/ui/components/svgs/mintlify"
|
||||
import { N8n } from "@evobgp/ui/components/svgs/n8n"
|
||||
import { Neon } from "@evobgp/ui/components/svgs/neon"
|
||||
import { Openai } from "@evobgp/ui/components/svgs/openai"
|
||||
import { OpenaiDark } from "@evobgp/ui/components/svgs/openaiDark"
|
||||
import { Paper } from "@evobgp/ui/components/svgs/paper"
|
||||
import { Prisma } from "@evobgp/ui/components/svgs/prisma"
|
||||
import { PrismaDark } from "@evobgp/ui/components/svgs/prismaDark"
|
||||
import { Stripe } from "@evobgp/ui/components/svgs/stripe"
|
||||
import { Supabase } from "@evobgp/ui/components/svgs/supabase"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type TxType = "payment" | "refund" | "payout" | "charge" | "transfer"
|
||||
export type TxStatus = "completed" | "pending" | "failed" | "cancelled"
|
||||
/** Where the charge originated (billing SaaS pattern - replaces duplicate "method" logos). */
|
||||
export type TxChannel = "checkout" | "api" | "invoice" | "dashboard"
|
||||
|
||||
export interface ITransaction {
|
||||
id: string
|
||||
reference: string
|
||||
date: string
|
||||
description: string
|
||||
merchant: string
|
||||
merchantLogo: ReactNode
|
||||
category: string
|
||||
type: TxType
|
||||
channel: TxChannel
|
||||
amount: number
|
||||
currency: string
|
||||
status: TxStatus
|
||||
account: string
|
||||
country: string
|
||||
flag: string
|
||||
timezone?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
// ── Logos (one mark per vendor - @/components/ui/svgs only) ──
|
||||
|
||||
const OPENAI_LOGO = (
|
||||
<>
|
||||
<span aria-hidden className="dark:hidden">
|
||||
<Openai className="size-5" />
|
||||
</span>
|
||||
<span aria-hidden className="hidden dark:block">
|
||||
<OpenaiDark className="size-5" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
const PRISMA_LOGO = (
|
||||
<>
|
||||
<span aria-hidden className="dark:hidden">
|
||||
<Prisma className="size-5" />
|
||||
</span>
|
||||
<span aria-hidden className="hidden dark:block">
|
||||
<PrismaDark className="size-5" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
const ANTHROPIC_LOGO = (
|
||||
<>
|
||||
<span aria-hidden className="dark:hidden">
|
||||
<AnthropicBlack className="size-5" />
|
||||
</span>
|
||||
<span aria-hidden className="hidden dark:block">
|
||||
<AnthropicWhite className="size-5" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
// ── Data: 10 rows, unique merchants, inbound + outbound (refunds / payouts) ──
|
||||
|
||||
export const TRANSACTIONS: ITransaction[] = [
|
||||
{
|
||||
id: "txn_001",
|
||||
reference: "TXN-2025-000143",
|
||||
date: "2025-06-12T09:14:00Z",
|
||||
description: "Pro plan · subscription renewal",
|
||||
merchant: "Stripe",
|
||||
merchantLogo: <Stripe className="size-5" aria-hidden="true" />,
|
||||
category: "Payments",
|
||||
type: "charge",
|
||||
channel: "checkout",
|
||||
amount: 240.0,
|
||||
currency: "USD",
|
||||
status: "completed",
|
||||
account: "•••• 4242",
|
||||
country: "United States",
|
||||
flag: "us",
|
||||
timezone: "PST (UTC−8)",
|
||||
},
|
||||
{
|
||||
id: "txn_002",
|
||||
reference: "TXN-2025-000142",
|
||||
date: "2025-06-11T16:30:00Z",
|
||||
description: "Database compute · June cycle",
|
||||
merchant: "Supabase",
|
||||
merchantLogo: <Supabase className="size-5" aria-hidden="true" />,
|
||||
category: "Infrastructure",
|
||||
type: "charge",
|
||||
channel: "dashboard",
|
||||
amount: 75.0,
|
||||
currency: "USD",
|
||||
status: "completed",
|
||||
account: "•••• 4242",
|
||||
country: "United States",
|
||||
flag: "us",
|
||||
timezone: "EST (UTC−5)",
|
||||
},
|
||||
{
|
||||
id: "txn_003",
|
||||
reference: "TXN-2025-000141",
|
||||
date: "2025-06-11T11:02:00Z",
|
||||
description: "GPT-4o usage · billing period",
|
||||
merchant: "OpenAI",
|
||||
merchantLogo: OPENAI_LOGO,
|
||||
category: "AI / ML",
|
||||
type: "charge",
|
||||
channel: "api",
|
||||
amount: 312.48,
|
||||
currency: "USD",
|
||||
status: "completed",
|
||||
account: "•••• 4242",
|
||||
country: "United States",
|
||||
flag: "us",
|
||||
timezone: "PST (UTC−8)",
|
||||
},
|
||||
{
|
||||
id: "txn_004",
|
||||
reference: "TXN-2025-000140",
|
||||
date: "2025-06-10T08:50:00Z",
|
||||
description: "Workflow credit · billing adjustment (outbound)",
|
||||
merchant: "N8n",
|
||||
merchantLogo: <N8n className="size-5" aria-hidden="true" />,
|
||||
category: "Automation",
|
||||
type: "refund",
|
||||
channel: "dashboard",
|
||||
amount: -21.0,
|
||||
currency: "USD",
|
||||
status: "completed",
|
||||
account: "org@acme.io",
|
||||
country: "Germany",
|
||||
flag: "de",
|
||||
timezone: "CET (UTC+1)",
|
||||
},
|
||||
{
|
||||
id: "txn_005",
|
||||
reference: "TXN-2025-000139",
|
||||
date: "2025-06-09T14:15:00Z",
|
||||
description: "Canvas seats · prorated refund to customer",
|
||||
merchant: "Paper",
|
||||
merchantLogo: <Paper className="size-5" aria-hidden="true" />,
|
||||
category: "Design",
|
||||
type: "refund",
|
||||
channel: "invoice",
|
||||
amount: -96.0,
|
||||
currency: "USD",
|
||||
status: "pending",
|
||||
account: "•••• 0011",
|
||||
country: "United Kingdom",
|
||||
flag: "gb",
|
||||
timezone: "GMT (UTC+0)",
|
||||
},
|
||||
{
|
||||
id: "txn_006",
|
||||
reference: "TXN-2025-000138",
|
||||
date: "2025-06-08T18:44:00Z",
|
||||
description: "Docs hosting · team",
|
||||
merchant: "Mintlify",
|
||||
merchantLogo: <Mintlify className="size-5" aria-hidden="true" />,
|
||||
category: "Documentation",
|
||||
type: "charge",
|
||||
channel: "checkout",
|
||||
amount: 45.0,
|
||||
currency: "USD",
|
||||
status: "completed",
|
||||
account: "•••• 4242",
|
||||
country: "United States",
|
||||
flag: "us",
|
||||
timezone: "CST (UTC−6)",
|
||||
},
|
||||
{
|
||||
id: "txn_007",
|
||||
reference: "TXN-2025-000137",
|
||||
date: "2025-06-08T12:22:00Z",
|
||||
description: "Function invocations · May",
|
||||
merchant: "Convex",
|
||||
merchantLogo: <Convex className="size-5" aria-hidden="true" />,
|
||||
category: "Backend",
|
||||
type: "charge",
|
||||
channel: "api",
|
||||
amount: 25.0,
|
||||
currency: "USD",
|
||||
status: "failed",
|
||||
account: "•••• 9871",
|
||||
country: "United States",
|
||||
flag: "us",
|
||||
timezone: "PST (UTC−8)",
|
||||
note: "Card declined, retry scheduled",
|
||||
},
|
||||
{
|
||||
id: "txn_008",
|
||||
reference: "TXN-2025-000136",
|
||||
date: "2025-06-07T10:05:00Z",
|
||||
description: "Payout to linked bank · hobby tier balance",
|
||||
merchant: "Neon",
|
||||
merchantLogo: <Neon className="size-5" aria-hidden="true" />,
|
||||
category: "Infrastructure",
|
||||
type: "payout",
|
||||
channel: "dashboard",
|
||||
amount: -250.0,
|
||||
currency: "USD",
|
||||
status: "completed",
|
||||
account: "dev@acme.io",
|
||||
country: "Germany",
|
||||
flag: "de",
|
||||
timezone: "CET (UTC+1)",
|
||||
},
|
||||
{
|
||||
id: "txn_009",
|
||||
reference: "TXN-2025-000135",
|
||||
date: "2025-06-06T15:00:00Z",
|
||||
description: "ORM · team license",
|
||||
merchant: "Prisma",
|
||||
merchantLogo: PRISMA_LOGO,
|
||||
category: "Developer tools",
|
||||
type: "charge",
|
||||
channel: "invoice",
|
||||
amount: 60.0,
|
||||
currency: "USD",
|
||||
status: "completed",
|
||||
account: "•••• 4242",
|
||||
country: "Germany",
|
||||
flag: "de",
|
||||
timezone: "CET (UTC+1)",
|
||||
},
|
||||
{
|
||||
id: "txn_010",
|
||||
reference: "TXN-2025-000134",
|
||||
date: "2025-06-05T11:11:00Z",
|
||||
description: "Claude API · May usage",
|
||||
merchant: "Anthropic",
|
||||
merchantLogo: ANTHROPIC_LOGO,
|
||||
category: "AI / ML",
|
||||
type: "payment",
|
||||
channel: "api",
|
||||
amount: 2500.0,
|
||||
currency: "USD",
|
||||
status: "completed",
|
||||
account: "dev@acme.io",
|
||||
country: "Canada",
|
||||
flag: "ca",
|
||||
timezone: "EST (UTC−5)",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
import { DataGridView } from "./components/data-grid-view"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Billing ledger data grid
|
||||
</h1>
|
||||
<DataGridView />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { Rating } from "@/components/reui/rating"
|
||||
import { type ReactNode } from "react"
|
||||
import { type ColumnDef } from "@tanstack/react-table"
|
||||
import { format, parseISO } from "date-fns"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
import {
|
||||
type AutomationKind,
|
||||
type AutomationOwnerAvailability,
|
||||
type AutomationState,
|
||||
type IAutomationRecord,
|
||||
} from "./data"
|
||||
import { GitBranchIcon, RouteIcon, SparklesIcon, MailIcon, BellRingIcon, EllipsisVerticalIcon, PencilIcon, EyeIcon, CircleCheckIcon, ArchiveIcon } from "lucide-react"
|
||||
|
||||
export type AutomationAction = "edit" | "open" | "archive"
|
||||
|
||||
const automationKindStyles: Record<
|
||||
AutomationKind,
|
||||
{ chipClassName: string; icon: ReactNode }
|
||||
> = {
|
||||
sequence: {
|
||||
chipClassName:
|
||||
"bg-sky-50 text-sky-600 ring-sky-200 dark:bg-sky-500/15 dark:text-sky-300 dark:ring-sky-400/30",
|
||||
icon: (
|
||||
<GitBranchIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
routing: {
|
||||
chipClassName:
|
||||
"bg-violet-50 text-violet-600 ring-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:ring-violet-400/30",
|
||||
icon: (
|
||||
<RouteIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
enrichment: {
|
||||
chipClassName:
|
||||
"bg-emerald-50 text-emerald-600 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-400/30",
|
||||
icon: (
|
||||
<SparklesIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
digest: {
|
||||
chipClassName:
|
||||
"bg-amber-50 text-amber-600 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:ring-amber-400/30",
|
||||
icon: (
|
||||
<MailIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
escalation: {
|
||||
chipClassName:
|
||||
"bg-rose-50 text-rose-600 ring-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:ring-rose-400/30",
|
||||
icon: (
|
||||
<BellRingIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const stateBadgeStyles: Record<
|
||||
AutomationState,
|
||||
{ label: string; dotClassName?: string }
|
||||
> = {
|
||||
live: {
|
||||
label: "Live",
|
||||
dotClassName: "bg-emerald-500",
|
||||
},
|
||||
review: {
|
||||
label: "Needs approval",
|
||||
dotClassName: "bg-amber-500",
|
||||
},
|
||||
drafts: {
|
||||
label: "Draft",
|
||||
dotClassName: "bg-slate-400 dark:bg-slate-300",
|
||||
},
|
||||
paused: {
|
||||
label: "Paused",
|
||||
dotClassName: "bg-zinc-400 dark:bg-zinc-300",
|
||||
},
|
||||
}
|
||||
|
||||
const updatedBucketLabel: Record<IAutomationRecord["updatedBucket"], string> = {
|
||||
today: "Today",
|
||||
"this-week": "This week",
|
||||
older: "Older",
|
||||
}
|
||||
|
||||
const availabilityColor: Record<AutomationOwnerAvailability, string> = {
|
||||
online: "bg-green-500",
|
||||
away: "bg-yellow-400",
|
||||
busy: "bg-red-500",
|
||||
offline: "bg-gray-500",
|
||||
}
|
||||
|
||||
function AutomationKindChip({ kind }: { kind: AutomationKind }) {
|
||||
const style = automationKindStyles[kind]
|
||||
|
||||
return (
|
||||
<Item
|
||||
render={<span />}
|
||||
className={cn(
|
||||
"p-0",
|
||||
"inline-flex size-10 items-center justify-center ring-1 ring-inset",
|
||||
style.chipClassName
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{style.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function AutomationNameCell({ automation }: { automation: IAutomationRecord }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<AutomationKindChip kind={automation.kind} />
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<span className="text-foreground truncate text-sm font-medium">
|
||||
{automation.title}
|
||||
</span>
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<span className="truncate">{automation.runWindowLabel}</span>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{automation.audienceLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OwnerCell({ automation }: { automation: IAutomationRecord }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="size-8">
|
||||
{automation.owner.avatar ? (
|
||||
<AvatarImage
|
||||
src={automation.owner.avatar}
|
||||
alt={automation.owner.name}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback>{automation.owner.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
|
||||
availabilityColor[automation.owner.availability]
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground line-clamp-1 font-medium">
|
||||
{automation.owner.name}
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground line-clamp-1 text-xs"
|
||||
title={automation.owner.email}
|
||||
>
|
||||
{automation.owner.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StateCell({ automation }: { automation: IAutomationRecord }) {
|
||||
const stateStyle = stateBadgeStyles[automation.state]
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center">
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
{stateStyle.dotClassName ? (
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
stateStyle.dotClassName
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{stateStyle.label}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UpdatedCell({ automation }: { automation: IAutomationRecord }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<span className="text-foreground text-sm">
|
||||
{format(parseISO(automation.updatedAt), "MMM d, yyyy")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{updatedBucketLabel[automation.updatedBucket]}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RatingCell({ automation }: { automation: IAutomationRecord }) {
|
||||
return <Rating rating={automation.rating} size="sm" showValue={true} />
|
||||
}
|
||||
|
||||
function AutomationActionsCell({
|
||||
automation,
|
||||
onAction,
|
||||
onToggleEnabled,
|
||||
}: {
|
||||
automation: IAutomationRecord
|
||||
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
|
||||
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Open actions for ${automation.title}`}
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{/* Content */}
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onAction("edit", automation)}>
|
||||
<PencilIcon className="size-4" aria-hidden="true" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAction("open", automation)}>
|
||||
<EyeIcon className="size-4" aria-hidden="true" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
closeOnClick={false}
|
||||
onClick={(event) => {
|
||||
// The Switch toggles itself and its click bubbles here; skip it
|
||||
// so item-level activation (row click, Enter/Space) toggles once.
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest('[data-slot="switch"]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
onToggleEnabled(automation, !automation.enabled)
|
||||
}}
|
||||
className="justify-between gap-4"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<CircleCheckIcon className="size-4" aria-hidden="true" />
|
||||
Enabled
|
||||
</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
aria-label={`Toggle ${automation.title}`}
|
||||
checked={automation.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleEnabled(automation, checked)
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onAction("archive", automation)}
|
||||
>
|
||||
<ArchiveIcon className="size-4" aria-hidden="true" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function createAutomationColumns({
|
||||
onAction,
|
||||
onToggleEnabled,
|
||||
}: {
|
||||
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
|
||||
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
|
||||
}): ColumnDef<IAutomationRecord>[] {
|
||||
return [
|
||||
{
|
||||
id: "select",
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
size: 30,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
headerClassName:
|
||||
"[--data-grid-header-cell-ps:var(--frame-panel-header-px)]",
|
||||
cellClassName: "[--data-grid-body-cell-ps:var(--frame-panel-px)]",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.title,
|
||||
id: "workflow",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Workflow" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <AutomationNameCell automation={row.original} />,
|
||||
size: 340,
|
||||
minSize: 200,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
autoSize: true,
|
||||
headerClassName: "pl-3!",
|
||||
cellClassName: "pl-3!",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.owner.name,
|
||||
id: "owner",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Owner" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <OwnerCell automation={row.original} />,
|
||||
size: 175,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.rating,
|
||||
id: "rating",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Score" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <RatingCell automation={row.original} />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.state,
|
||||
id: "state",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="State" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <StateCell automation={row.original} />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => parseISO(row.updatedAt).getTime(),
|
||||
id: "updatedAt",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Last updated" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <UpdatedCell automation={row.original} />,
|
||||
size: 125,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end">
|
||||
<AutomationActionsCell
|
||||
automation={row.original}
|
||||
onAction={onAction}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
size: 56,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerClassName:
|
||||
"[--data-grid-header-cell-pe:var(--frame-panel-header-px)]",
|
||||
cellClassName: "[--data-grid-body-cell-pe:var(--frame-panel-px)]",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
createFilter,
|
||||
Filters,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from "@/components/reui/filters"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@evobgp/ui/components/alert-dialog"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@evobgp/ui/components/tabs"
|
||||
import { createAutomationColumns } from "./columns"
|
||||
import {
|
||||
AUTOMATION_TABS,
|
||||
AUTOMATIONS,
|
||||
DELIVERY_FILTER_OPTIONS,
|
||||
getAutomationTabFromState,
|
||||
OWNER_FILTER_OPTIONS,
|
||||
UPDATED_FILTER_OPTIONS,
|
||||
type AutomationState,
|
||||
type AutomationTab,
|
||||
type IAutomationRecord,
|
||||
} from "./data"
|
||||
import { SearchIcon, UsersIcon, RouteIcon, ClockIcon, PlusIcon, FilterIcon, FunnelXIcon } from "lucide-react"
|
||||
|
||||
type ToastTone = "success" | "neutral" | "destructive"
|
||||
|
||||
const toneStyles: Record<ToastTone, { dot: string }> = {
|
||||
success: { dot: "bg-emerald-500" },
|
||||
neutral: { dot: "bg-sky-500" },
|
||||
destructive: { dot: "bg-rose-500" },
|
||||
}
|
||||
|
||||
function showAutomationToast({
|
||||
tone,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
tone: ToastTone
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
toast.custom((id) => (
|
||||
<div className="bg-popover text-popover-foreground border-border flex w-[356px] flex-col gap-3 rounded-md border p-4 shadow-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 flex size-2 shrink-0 rounded-full",
|
||||
toneStyles[tone].dot
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed text-pretty">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="xs" variant="outline" onClick={() => toast.dismiss(id)}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
function getAutomationSearchBlob(automation: IAutomationRecord) {
|
||||
return [
|
||||
automation.title,
|
||||
automation.kind,
|
||||
automation.state,
|
||||
automation.deliveryMode,
|
||||
automation.audienceLabel,
|
||||
automation.runWindowLabel,
|
||||
automation.owner.name,
|
||||
automation.owner.email,
|
||||
automation.owner.teamLabel,
|
||||
automation.approvalRequired ? "approval required" : "auto-approved",
|
||||
automation.enabled ? "enabled" : "disabled",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function getActiveFilters(filters: Filter[]) {
|
||||
return filters.filter((filter) => {
|
||||
const { values } = filter
|
||||
if (!values || values.length === 0) return false
|
||||
if (
|
||||
values.every((value) => typeof value === "string" && value.trim() === "")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => value === null || value === undefined)) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function renderSelectedCount(values: unknown[]) {
|
||||
if (values.length === 0) return "Select..."
|
||||
if (values.length > 1) return `${values.length} selected`
|
||||
return null
|
||||
}
|
||||
|
||||
function renderSingleSelectedLabel(
|
||||
values: unknown[],
|
||||
options: { value: string; label: string }[]
|
||||
) {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
|
||||
const option = options.find((item) => item.value === values[0])
|
||||
return option?.label ?? String(values[0])
|
||||
}
|
||||
|
||||
function filterFieldValue(
|
||||
automation: IAutomationRecord,
|
||||
field: string
|
||||
): unknown {
|
||||
switch (field) {
|
||||
case "workflow":
|
||||
return getAutomationSearchBlob(automation)
|
||||
case "ownerTeam":
|
||||
return automation.owner.team
|
||||
case "deliveryMode":
|
||||
return automation.deliveryMode
|
||||
case "updatedBucket":
|
||||
return automation.updatedBucket
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function applyFiltersToData(
|
||||
data: IAutomationRecord[],
|
||||
filters: Filter[]
|
||||
): IAutomationRecord[] {
|
||||
const active = getActiveFilters(filters)
|
||||
let result = [...data]
|
||||
|
||||
active.forEach((filter) => {
|
||||
const { field, operator, values } = filter
|
||||
|
||||
result = result.filter((item) => {
|
||||
const raw = filterFieldValue(item, field)
|
||||
const fieldValue = raw != null ? raw : ""
|
||||
|
||||
switch (operator) {
|
||||
case "is":
|
||||
return values.includes(fieldValue)
|
||||
case "is_not":
|
||||
return !values.includes(fieldValue)
|
||||
case "is_any_of":
|
||||
return values.some((value) => fieldValue === value)
|
||||
case "is_not_any_of":
|
||||
return !values.some((value) => fieldValue === value)
|
||||
case "contains": {
|
||||
const tokens = values
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean)
|
||||
if (tokens.length === 0) return true
|
||||
return tokens.some((token) =>
|
||||
String(fieldValue).toLowerCase().includes(token.toLowerCase())
|
||||
)
|
||||
}
|
||||
case "not_contains":
|
||||
return !values.some((value) =>
|
||||
String(fieldValue)
|
||||
.toLowerCase()
|
||||
.includes(String(value).toLowerCase())
|
||||
)
|
||||
case "starts_with":
|
||||
return values.some((value) =>
|
||||
String(fieldValue)
|
||||
.toLowerCase()
|
||||
.startsWith(String(value).toLowerCase())
|
||||
)
|
||||
case "ends_with":
|
||||
return values.some((value) =>
|
||||
String(fieldValue)
|
||||
.toLowerCase()
|
||||
.endsWith(String(value).toLowerCase())
|
||||
)
|
||||
case "empty":
|
||||
return fieldValue === "" || fieldValue == null
|
||||
case "not_empty":
|
||||
return fieldValue !== "" && fieldValue != null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const OWNER_TEAM_FILTER_OPTIONS = OWNER_FILTER_OPTIONS.filter(
|
||||
(option) => option.value !== "everyone"
|
||||
).map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))
|
||||
|
||||
const DELIVERY_MODE_FILTER_OPTIONS = DELIVERY_FILTER_OPTIONS.filter(
|
||||
(option) => option.value !== "any"
|
||||
).map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))
|
||||
|
||||
const UPDATED_BUCKET_FILTER_OPTIONS = UPDATED_FILTER_OPTIONS.filter(
|
||||
(option) => option.value !== "any"
|
||||
).map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))
|
||||
|
||||
const filterFields: FilterFieldConfig[] = [
|
||||
{
|
||||
key: "workflow",
|
||||
label: "Workflow",
|
||||
icon: (
|
||||
<SearchIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "text",
|
||||
className: "w-52",
|
||||
placeholder: "Search...",
|
||||
},
|
||||
{
|
||||
key: "ownerTeam",
|
||||
label: "Owner team",
|
||||
icon: (
|
||||
<UsersIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[168px]",
|
||||
options: OWNER_TEAM_FILTER_OPTIONS,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, OWNER_TEAM_FILTER_OPTIONS),
|
||||
},
|
||||
{
|
||||
key: "deliveryMode",
|
||||
label: "Delivery mode",
|
||||
icon: (
|
||||
<RouteIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[168px]",
|
||||
options: DELIVERY_MODE_FILTER_OPTIONS,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, DELIVERY_MODE_FILTER_OPTIONS),
|
||||
},
|
||||
{
|
||||
key: "updatedBucket",
|
||||
label: "Last updated",
|
||||
icon: (
|
||||
<ClockIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[160px]",
|
||||
options: UPDATED_BUCKET_FILTER_OPTIONS,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, UPDATED_BUCKET_FILTER_OPTIONS),
|
||||
},
|
||||
]
|
||||
|
||||
function createDefaultAutomationFilters(): Filter[] {
|
||||
return [createFilter("workflow", "contains", [""])]
|
||||
}
|
||||
|
||||
function getTabCounts(records: IAutomationRecord[]) {
|
||||
return {
|
||||
all: records.length,
|
||||
live: records.filter((record) => record.state === "live").length,
|
||||
review: records.filter((record) => record.state === "review").length,
|
||||
drafts: records.filter((record) => record.state === "drafts").length,
|
||||
paused: records.filter((record) => record.state === "paused").length,
|
||||
} satisfies Record<AutomationTab, number>
|
||||
}
|
||||
|
||||
export function AutomationLibraryGridView() {
|
||||
const [automations, setAutomations] =
|
||||
useState<IAutomationRecord[]>(AUTOMATIONS)
|
||||
const [activeTab, setActiveTab] = useState<AutomationTab>("all")
|
||||
const [filters, setFilters] = useState<Filter[]>(
|
||||
createDefaultAutomationFilters
|
||||
)
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "updatedAt", desc: true },
|
||||
])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
const [automationPendingArchive, setAutomationPendingArchive] =
|
||||
useState<IAutomationRecord | null>(null)
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) =>
|
||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
|
||||
)
|
||||
}, [])
|
||||
|
||||
const filteredBaseAutomations = useMemo(() => {
|
||||
return applyFiltersToData(automations, filters)
|
||||
}, [automations, filters])
|
||||
|
||||
const filteredAutomations = useMemo(
|
||||
() =>
|
||||
filteredBaseAutomations.filter((automation) =>
|
||||
activeTab === "all"
|
||||
? true
|
||||
: getAutomationTabFromState(automation.state) === activeTab
|
||||
),
|
||||
[activeTab, filteredBaseAutomations]
|
||||
)
|
||||
|
||||
const tabCounts = useMemo(
|
||||
() => getTabCounts(filteredBaseAutomations),
|
||||
[filteredBaseAutomations]
|
||||
)
|
||||
|
||||
const filteredLiveCount = useMemo(
|
||||
() =>
|
||||
filteredAutomations.filter((automation) => automation.state === "live")
|
||||
.length,
|
||||
[filteredAutomations]
|
||||
)
|
||||
|
||||
const filteredReviewCount = useMemo(
|
||||
() =>
|
||||
filteredAutomations.filter((automation) => automation.state === "review")
|
||||
.length,
|
||||
[filteredAutomations]
|
||||
)
|
||||
|
||||
const selectedCount = useMemo(
|
||||
() => Object.keys(rowSelection).length,
|
||||
[rowSelection]
|
||||
)
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createAutomationColumns({
|
||||
onAction: (action, automation) => {
|
||||
if (action === "archive") {
|
||||
setAutomationPendingArchive(automation)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
showAutomationToast({
|
||||
tone: "neutral",
|
||||
title: "Workflow editor",
|
||||
description: `Connect "${automation.title}" to your builder, side panel, or automation step editor.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showAutomationToast({
|
||||
tone: "success",
|
||||
title: "Workflow details",
|
||||
description: `"${automation.title}" is ready for a detail route, run history drawer, or audit panel.`,
|
||||
})
|
||||
},
|
||||
onToggleEnabled: (automation, nextValue) => {
|
||||
const nextState: AutomationState =
|
||||
nextValue && automation.state === "paused"
|
||||
? "live"
|
||||
: nextValue && automation.state === "drafts"
|
||||
? "review"
|
||||
: !nextValue && automation.state === "live"
|
||||
? "paused"
|
||||
: automation.state
|
||||
|
||||
setAutomations((current) =>
|
||||
current.map((item) =>
|
||||
item.id === automation.id
|
||||
? {
|
||||
...item,
|
||||
enabled: nextValue,
|
||||
state: nextState,
|
||||
}
|
||||
: item
|
||||
)
|
||||
)
|
||||
|
||||
showAutomationToast({
|
||||
tone: nextValue ? "success" : "neutral",
|
||||
title: nextValue ? "Workflow enabled" : "Workflow paused",
|
||||
description: nextValue
|
||||
? `"${automation.title}" is ready to run in the ${nextState === "review" ? "review" : "live"} queue.`
|
||||
: `"${automation.title}" will stay available but will not continue running until resumed.`,
|
||||
})
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredAutomations,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
})
|
||||
|
||||
const handleClearControls = useCallback(() => {
|
||||
setFilters(createDefaultAutomationFilters())
|
||||
resetPagination()
|
||||
}, [resetPagination])
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(nextFilters: Filter[]) => {
|
||||
setFilters(nextFilters)
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const handleArchiveAutomation = useCallback(() => {
|
||||
if (!automationPendingArchive) return
|
||||
|
||||
const automationToArchive = automationPendingArchive
|
||||
|
||||
setAutomations((current) =>
|
||||
current.filter(
|
||||
(automation) => automation.id !== automationPendingArchive.id
|
||||
)
|
||||
)
|
||||
setRowSelection((current) => {
|
||||
const next = { ...current }
|
||||
delete next[automationPendingArchive.id]
|
||||
return next
|
||||
})
|
||||
setAutomationPendingArchive(null)
|
||||
resetPagination()
|
||||
|
||||
showAutomationToast({
|
||||
tone: "destructive",
|
||||
title: "Workflow archived",
|
||||
description: `"${automationToArchive.title}" was removed from this automation library.`,
|
||||
})
|
||||
}, [automationPendingArchive, resetPagination])
|
||||
|
||||
const emptyMessage =
|
||||
"No workflows match this automation slice. Switch tabs or clear the filters."
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Table */}
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredAutomations.length}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={{
|
||||
dense: true,
|
||||
}}
|
||||
>
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<FrameTitle className="text-balance">
|
||||
Automation Library
|
||||
</FrameTitle>
|
||||
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
||||
<span>
|
||||
{filteredAutomations.length} workflow
|
||||
{filteredAutomations.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{filteredLiveCount} live</span>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{filteredReviewCount} review</span>
|
||||
{selectedCount > 0 ? (
|
||||
<>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{selectedCount} selected</span>
|
||||
</>
|
||||
) : null}
|
||||
</FrameDescription>
|
||||
</div>
|
||||
|
||||
<Button type="button" className="shrink-0">
|
||||
<PlusIcon className="size-4" aria-hidden="true" />
|
||||
New workflow
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
setActiveTab(value as AutomationTab)
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
<TabsList variant="line" className="gap-5">
|
||||
{AUTOMATION_TABS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="gap-2 px-0 pb-3 text-sm"
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||
{tabCounts[tab.value]}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button variant="outline" aria-label="Filters">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Filters
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
{selectedCount} selected
|
||||
</Badge>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClearControls}
|
||||
>
|
||||
<FunnelXIcon className="size-4" aria-hidden="true" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FrameFooter>
|
||||
<DataGridPagination />
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
|
||||
<AlertDialog
|
||||
open={automationPendingArchive != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setAutomationPendingArchive(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Archive workflow?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{automationPendingArchive
|
||||
? `Archive "${automationPendingArchive.title}" from this automation library. Run history and ownership context can stay available in your backend, but this row will disappear from the grid preview.`
|
||||
: "Archive this workflow from the automation library."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleArchiveAutomation}
|
||||
render={
|
||||
<Button type="button" variant="destructive">
|
||||
Archive
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
export type AutomationTab = "all" | "live" | "review" | "drafts" | "paused"
|
||||
|
||||
export type AutomationKind =
|
||||
| "sequence"
|
||||
| "routing"
|
||||
| "enrichment"
|
||||
| "digest"
|
||||
| "escalation"
|
||||
|
||||
export type OwnerFilter =
|
||||
| "everyone"
|
||||
| "product"
|
||||
| "engineering"
|
||||
| "operations"
|
||||
| "revenue"
|
||||
| "support"
|
||||
|
||||
export type DeliveryFilter =
|
||||
| "any"
|
||||
| "scheduled"
|
||||
| "event-driven"
|
||||
| "manual"
|
||||
| "hybrid"
|
||||
|
||||
export type UpdatedFilter = "any" | "today" | "this-week" | "older"
|
||||
|
||||
export type AutomationState = Exclude<AutomationTab, "all">
|
||||
export type AutomationOwnerAvailability = "online" | "away" | "busy" | "offline"
|
||||
|
||||
export interface IAutomationOwner {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
initials: string
|
||||
avatar?: string
|
||||
availability: AutomationOwnerAvailability
|
||||
team: Exclude<OwnerFilter, "everyone">
|
||||
teamLabel: string
|
||||
}
|
||||
|
||||
export interface IAutomationRecord {
|
||||
id: string
|
||||
title: string
|
||||
kind: AutomationKind
|
||||
state: AutomationState
|
||||
rating: number
|
||||
deliveryMode: Exclude<DeliveryFilter, "any">
|
||||
owner: IAutomationOwner
|
||||
updatedAt: string
|
||||
updatedBucket: Exclude<UpdatedFilter, "any">
|
||||
enabled: boolean
|
||||
approvalRequired: boolean
|
||||
audienceLabel: string
|
||||
runWindowLabel: string
|
||||
}
|
||||
|
||||
const OWNERS: Record<string, IAutomationOwner> = {
|
||||
maya: {
|
||||
id: "maya-patel",
|
||||
name: "Maya Patel",
|
||||
email: "maya@reui.io",
|
||||
initials: "MP",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
availability: "online",
|
||||
team: "product",
|
||||
teamLabel: "Product",
|
||||
},
|
||||
jonas: {
|
||||
id: "jonas-reed",
|
||||
name: "Jonas Reed",
|
||||
email: "jonas@reui.io",
|
||||
initials: "JR",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
availability: "busy",
|
||||
team: "engineering",
|
||||
teamLabel: "Engineering",
|
||||
},
|
||||
priya: {
|
||||
id: "priya-nair",
|
||||
name: "Priya Nair",
|
||||
email: "priya@reui.io",
|
||||
initials: "PN",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
|
||||
availability: "away",
|
||||
team: "operations",
|
||||
teamLabel: "Operations",
|
||||
},
|
||||
emil: {
|
||||
id: "emil-novak",
|
||||
name: "Emil Novak",
|
||||
email: "emil@reui.io",
|
||||
initials: "EN",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1560250097-0b93528c311a?w=96&h=96&dpr=2&q=80",
|
||||
availability: "offline",
|
||||
team: "revenue",
|
||||
teamLabel: "Revenue",
|
||||
},
|
||||
nora: {
|
||||
id: "nora-ibrahim",
|
||||
name: "Nora Ibrahim",
|
||||
email: "nora@reui.io",
|
||||
initials: "NI",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
|
||||
availability: "online",
|
||||
team: "support",
|
||||
teamLabel: "Support",
|
||||
},
|
||||
}
|
||||
|
||||
function automation(
|
||||
input: Omit<IAutomationRecord, "owner"> & {
|
||||
owner: keyof typeof OWNERS
|
||||
}
|
||||
): IAutomationRecord {
|
||||
return {
|
||||
...input,
|
||||
owner: OWNERS[input.owner],
|
||||
}
|
||||
}
|
||||
|
||||
export const AUTOMATION_TABS: { value: AutomationTab; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "live", label: "Live" },
|
||||
{ value: "review", label: "Needs Review" },
|
||||
{ value: "drafts", label: "Drafts" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
]
|
||||
|
||||
export const OWNER_FILTER_OPTIONS: {
|
||||
value: OwnerFilter
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "everyone", label: "Owner team" },
|
||||
{ value: "product", label: "Product" },
|
||||
{ value: "engineering", label: "Engineering" },
|
||||
{ value: "operations", label: "Operations" },
|
||||
{ value: "revenue", label: "Revenue" },
|
||||
{ value: "support", label: "Support" },
|
||||
]
|
||||
|
||||
export const DELIVERY_FILTER_OPTIONS: {
|
||||
value: DeliveryFilter
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "any", label: "Delivery mode" },
|
||||
{ value: "scheduled", label: "Scheduled" },
|
||||
{ value: "event-driven", label: "Event-driven" },
|
||||
{ value: "manual", label: "Manual" },
|
||||
{ value: "hybrid", label: "Hybrid" },
|
||||
]
|
||||
|
||||
export const UPDATED_FILTER_OPTIONS: {
|
||||
value: UpdatedFilter
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "any", label: "Last updated" },
|
||||
{ value: "today", label: "Today" },
|
||||
{ value: "this-week", label: "This week" },
|
||||
{ value: "older", label: "Older" },
|
||||
]
|
||||
|
||||
export function getAutomationTabFromState(
|
||||
state: AutomationState
|
||||
): Exclude<AutomationTab, "all"> {
|
||||
return state
|
||||
}
|
||||
|
||||
export const AUTOMATIONS: IAutomationRecord[] = [
|
||||
automation({
|
||||
id: "renewal-touchpoint-orchestration",
|
||||
title: "Renewal touchpoint orchestration",
|
||||
kind: "sequence",
|
||||
state: "live",
|
||||
rating: 4.8,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "emil",
|
||||
updatedAt: "2026-04-11",
|
||||
updatedBucket: "today",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Renewal accounts",
|
||||
runWindowLabel: "Weekdays 09:00",
|
||||
}),
|
||||
automation({
|
||||
id: "delegated-sender-review-route",
|
||||
title: "Delegated sender review route",
|
||||
kind: "routing",
|
||||
state: "review",
|
||||
rating: 4.2,
|
||||
deliveryMode: "manual",
|
||||
owner: "priya",
|
||||
updatedAt: "2026-04-11",
|
||||
updatedBucket: "today",
|
||||
enabled: false,
|
||||
approvalRequired: true,
|
||||
audienceLabel: "Delegated senders",
|
||||
runWindowLabel: "Queue-based release",
|
||||
}),
|
||||
automation({
|
||||
id: "launch-handoff-digest",
|
||||
title: "Launch handoff digest",
|
||||
kind: "digest",
|
||||
state: "live",
|
||||
rating: 4.7,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "maya",
|
||||
updatedAt: "2026-04-10",
|
||||
updatedBucket: "this-week",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Launch squad",
|
||||
runWindowLabel: "Daily 08:30",
|
||||
}),
|
||||
automation({
|
||||
id: "sla-escalation-watch",
|
||||
title: "SLA escalation watch",
|
||||
kind: "escalation",
|
||||
state: "live",
|
||||
rating: 4.9,
|
||||
deliveryMode: "event-driven",
|
||||
owner: "nora",
|
||||
updatedAt: "2026-04-10",
|
||||
updatedBucket: "this-week",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Priority tickets",
|
||||
runWindowLabel: "On trigger",
|
||||
}),
|
||||
automation({
|
||||
id: "lead-enrichment-pass",
|
||||
title: "Lead enrichment pass",
|
||||
kind: "enrichment",
|
||||
state: "paused",
|
||||
rating: 3.9,
|
||||
deliveryMode: "hybrid",
|
||||
owner: "jonas",
|
||||
updatedAt: "2026-04-09",
|
||||
updatedBucket: "this-week",
|
||||
enabled: false,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Inbound pipeline",
|
||||
runWindowLabel: "Hourly batch",
|
||||
}),
|
||||
automation({
|
||||
id: "sandbox-onboarding-sequence",
|
||||
title: "Sandbox onboarding sequence",
|
||||
kind: "sequence",
|
||||
state: "drafts",
|
||||
rating: 4.1,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "priya",
|
||||
updatedAt: "2026-04-08",
|
||||
updatedBucket: "this-week",
|
||||
enabled: false,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Trial workspaces",
|
||||
runWindowLabel: "Pending QA",
|
||||
}),
|
||||
automation({
|
||||
id: "partner-routing-fallback",
|
||||
title: "Partner routing fallback",
|
||||
kind: "routing",
|
||||
state: "live",
|
||||
rating: 4.4,
|
||||
deliveryMode: "hybrid",
|
||||
owner: "emil",
|
||||
updatedAt: "2026-04-07",
|
||||
updatedBucket: "this-week",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Partner renewals",
|
||||
runWindowLabel: "Live + nightly",
|
||||
}),
|
||||
automation({
|
||||
id: "weekly-adoption-digest",
|
||||
title: "Weekly adoption digest",
|
||||
kind: "digest",
|
||||
state: "paused",
|
||||
rating: 3.8,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "maya",
|
||||
updatedAt: "2026-04-06",
|
||||
updatedBucket: "this-week",
|
||||
enabled: false,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Workspace champions",
|
||||
runWindowLabel: "Fridays 16:00",
|
||||
}),
|
||||
automation({
|
||||
id: "enterprise-risk-escalation",
|
||||
title: "Enterprise risk escalation",
|
||||
kind: "escalation",
|
||||
state: "review",
|
||||
rating: 4.3,
|
||||
deliveryMode: "manual",
|
||||
owner: "nora",
|
||||
updatedAt: "2026-04-05",
|
||||
updatedBucket: "older",
|
||||
enabled: false,
|
||||
approvalRequired: true,
|
||||
audienceLabel: "Enterprise accounts",
|
||||
runWindowLabel: "Manual release",
|
||||
}),
|
||||
automation({
|
||||
id: "crm-enrichment-backfill",
|
||||
title: "CRM enrichment backfill",
|
||||
kind: "enrichment",
|
||||
state: "live",
|
||||
rating: 4.6,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "jonas",
|
||||
updatedAt: "2026-04-04",
|
||||
updatedBucket: "older",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Open opportunities",
|
||||
runWindowLabel: "Nightly 01:00",
|
||||
}),
|
||||
automation({
|
||||
id: "trial-conversion-follow-up",
|
||||
title: "Trial conversion follow-up",
|
||||
kind: "sequence",
|
||||
state: "drafts",
|
||||
rating: 4.0,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "emil",
|
||||
updatedAt: "2026-04-03",
|
||||
updatedBucket: "older",
|
||||
enabled: false,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Product-led signups",
|
||||
runWindowLabel: "Awaiting copy",
|
||||
}),
|
||||
automation({
|
||||
id: "owner-assignment-router",
|
||||
title: "Owner assignment router",
|
||||
kind: "routing",
|
||||
state: "live",
|
||||
rating: 4.5,
|
||||
deliveryMode: "event-driven",
|
||||
owner: "priya",
|
||||
updatedAt: "2026-04-02",
|
||||
updatedBucket: "older",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Workspace requests",
|
||||
runWindowLabel: "Immediate",
|
||||
}),
|
||||
automation({
|
||||
id: "ops-exception-digest",
|
||||
title: "Ops exception digest",
|
||||
kind: "digest",
|
||||
state: "review",
|
||||
rating: 4.1,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "priya",
|
||||
updatedAt: "2026-04-11",
|
||||
updatedBucket: "today",
|
||||
enabled: false,
|
||||
approvalRequired: true,
|
||||
audienceLabel: "Ops leadership",
|
||||
runWindowLabel: "Daily 18:00",
|
||||
}),
|
||||
automation({
|
||||
id: "billing-retry-escalation",
|
||||
title: "Billing retry escalation",
|
||||
kind: "escalation",
|
||||
state: "live",
|
||||
rating: 4.7,
|
||||
deliveryMode: "event-driven",
|
||||
owner: "nora",
|
||||
updatedAt: "2026-04-01",
|
||||
updatedBucket: "older",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Recovery queue",
|
||||
runWindowLabel: "On failure",
|
||||
}),
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AutomationLibraryGridView } from "./components/data-grid-view"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Automation library data grid
|
||||
</h1>
|
||||
<AutomationLibraryGridView />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
import { memo, useMemo, useState, type ComponentProps } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { Row, type ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@evobgp/ui/components/alert-dialog"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import {
|
||||
Progress,
|
||||
ProgressLabel,
|
||||
ProgressValue,
|
||||
} from "@evobgp/ui/components/progress"
|
||||
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
||||
import {
|
||||
type IRenewalRecord,
|
||||
type RenewalInvoiceStatus,
|
||||
type RenewalRisk,
|
||||
type RenewalSponsorStatus,
|
||||
type RenewalStage,
|
||||
} from "./data"
|
||||
import { MoreHorizontalIcon, EyeIcon, FileTextIcon, TriangleAlertIcon } from "lucide-react"
|
||||
|
||||
// Intl formatters are costly to construct; build them once and reuse for every
|
||||
// cell render.
|
||||
const CURRENCY_FORMATTER = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
|
||||
const RENEWAL_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return CURRENCY_FORMATTER.format(value)
|
||||
}
|
||||
|
||||
function formatRenewalDate(iso: string) {
|
||||
return RENEWAL_DATE_FORMATTER.format(new Date(iso))
|
||||
}
|
||||
|
||||
const stageVariant: Record<
|
||||
RenewalStage,
|
||||
ComponentProps<typeof Badge>["variant"]
|
||||
> = {
|
||||
Preparing: "outline",
|
||||
"Commercial review": "outline",
|
||||
"Legal review": "outline",
|
||||
Committed: "outline",
|
||||
}
|
||||
|
||||
const stageDot = {
|
||||
Preparing: "bg-muted-foreground",
|
||||
"Commercial review": "bg-sky-500",
|
||||
"Legal review": "bg-amber-500",
|
||||
Committed: "bg-emerald-500",
|
||||
} satisfies Record<RenewalStage, string>
|
||||
|
||||
const riskVariant: Record<
|
||||
RenewalRisk,
|
||||
ComponentProps<typeof Badge>["variant"]
|
||||
> = {
|
||||
Low: "secondary",
|
||||
Medium: "info-light",
|
||||
High: "warning-light",
|
||||
Critical: "destructive-light",
|
||||
}
|
||||
|
||||
const invoiceVariant: Record<
|
||||
RenewalInvoiceStatus,
|
||||
ComponentProps<typeof Badge>["variant"]
|
||||
> = {
|
||||
Ready: "success-light",
|
||||
"Finance review": "warning-light",
|
||||
Blocked: "destructive-light",
|
||||
}
|
||||
|
||||
const sponsorVariant: Record<
|
||||
RenewalSponsorStatus,
|
||||
ComponentProps<typeof Badge>["variant"]
|
||||
> = {
|
||||
Confirmed: "success-outline",
|
||||
"At risk": "warning-outline",
|
||||
Missing: "destructive-outline",
|
||||
}
|
||||
|
||||
export const StageBadge = memo(function StageBadge({
|
||||
stage,
|
||||
}: {
|
||||
stage: RenewalStage
|
||||
}) {
|
||||
return (
|
||||
<Badge variant={stageVariant[stage]}>
|
||||
<span
|
||||
className={cn("size-1.5 shrink-0 rounded-full", stageDot[stage])}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{stage}
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
|
||||
export const RiskBadge = memo(function RiskBadge({
|
||||
risk,
|
||||
}: {
|
||||
risk: RenewalRisk
|
||||
}) {
|
||||
return <Badge variant={riskVariant[risk]}>{risk}</Badge>
|
||||
})
|
||||
|
||||
export const InvoiceStatusBadge = memo(function InvoiceStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: RenewalInvoiceStatus
|
||||
}) {
|
||||
return <Badge variant={invoiceVariant[status]}>{status}</Badge>
|
||||
})
|
||||
|
||||
export const SponsorStatusBadge = memo(function SponsorStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: RenewalSponsorStatus
|
||||
}) {
|
||||
return <Badge variant={sponsorVariant[status]}>{status}</Badge>
|
||||
})
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
}
|
||||
|
||||
const AccountCell = memo(function AccountCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<IRenewalRecord>
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Item className="flex size-6 shrink-0 items-center justify-center border-0 p-0 [&_svg]:size-5">
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{row.original.accountLogo}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="text-foreground truncate font-medium">
|
||||
{row.original.accountName}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<Avatar className="size-4">
|
||||
<AvatarImage src={row.original.ownerAvatar} alt="" />
|
||||
<AvatarFallback className="text-[9px] font-medium">
|
||||
{initials(row.original.ownerName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">{row.original.ownerName}</span>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{row.original.segment}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const RenewalWindowCell = memo(function RenewalWindowCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<IRenewalRecord>
|
||||
}) {
|
||||
const dueSoon = row.original.daysToRenewal <= 30
|
||||
const urgencyTone =
|
||||
row.original.daysToRenewal <= 14
|
||||
? "text-destructive"
|
||||
: row.original.daysToRenewal <= 30
|
||||
? "text-warning"
|
||||
: "text-foreground"
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className={cn("font-medium tabular-nums", urgencyTone)}>
|
||||
{dueSoon
|
||||
? `Due in ${row.original.daysToRenewal}d`
|
||||
: `${row.original.daysToRenewal}d out`}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatRenewalDate(row.original.renewalDate)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const RevenueCell = memo(function RevenueCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<IRenewalRecord>
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{formatCurrency(row.original.arr)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
row.original.expansionPotential > 0
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{row.original.expansionPotential > 0 ? "+" : ""}
|
||||
{formatCurrency(row.original.expansionPotential)} upside
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const HealthCell = memo(function HealthCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<IRenewalRecord>
|
||||
}) {
|
||||
const score = row.original.healthScore
|
||||
const label = score >= 75 ? "Healthy" : score >= 55 ? "Watchlist" : "At risk"
|
||||
const indicatorClass =
|
||||
score >= 75
|
||||
? "**:data-[slot=progress-indicator]:bg-emerald-500"
|
||||
: score >= 55
|
||||
? "**:data-[slot=progress-indicator]:bg-amber-500"
|
||||
: "**:data-[slot=progress-indicator]:bg-red-500"
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<Progress
|
||||
value={score}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 flex-row flex-nowrap items-center gap-2 **:data-[slot=progress-track]:order-1 **:data-[slot=progress-track]:min-w-14 **:data-[slot=progress-track]:flex-1 **:data-[slot=progress-value]:order-2",
|
||||
indicatorClass
|
||||
)}
|
||||
>
|
||||
<ProgressLabel className="sr-only">Health score</ProgressLabel>
|
||||
<ProgressValue className="text-muted-foreground shrink-0 text-[10px] leading-none tabular-nums">
|
||||
{(_, value) => `${value ?? score}%`}
|
||||
</ProgressValue>
|
||||
</Progress>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function buildSmoothLinePath(points: { x: number; y: number }[]) {
|
||||
if (points.length === 0) return ""
|
||||
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`
|
||||
|
||||
let path = `M ${points[0].x} ${points[0].y}`
|
||||
|
||||
for (let index = 0; index < points.length - 1; index++) {
|
||||
const p0 = points[Math.max(0, index - 1)]
|
||||
const p1 = points[index]
|
||||
const p2 = points[index + 1]
|
||||
const p3 = points[Math.min(points.length - 1, index + 2)]
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6
|
||||
|
||||
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
const UsageTrendCell = memo(function UsageTrendCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<IRenewalRecord>
|
||||
}) {
|
||||
const data = row.original.usageTrend
|
||||
const width = 92
|
||||
const height = 26
|
||||
const padX = 2
|
||||
const padY = 2
|
||||
const innerW = width - padX * 2
|
||||
const innerH = height - padY * 2
|
||||
|
||||
const { linePath, strokeClass, deltaLabel } = useMemo(() => {
|
||||
const max = Math.max(...data)
|
||||
const min = Math.min(...data)
|
||||
const range = max - min || 1
|
||||
const step = innerW / Math.max(1, data.length - 1)
|
||||
|
||||
const points = data.map((value, index) => ({
|
||||
x: padX + index * step,
|
||||
y: padY + ((max - value) / range) * innerH,
|
||||
}))
|
||||
|
||||
const delta = data[data.length - 1] - data[0]
|
||||
|
||||
return {
|
||||
linePath: buildSmoothLinePath(points),
|
||||
strokeClass:
|
||||
delta > 0
|
||||
? "stroke-emerald-600 dark:stroke-emerald-400"
|
||||
: delta < 0
|
||||
? "stroke-red-600 dark:stroke-red-400"
|
||||
: "stroke-muted-foreground",
|
||||
deltaLabel:
|
||||
delta > 0
|
||||
? `Expanding ${delta} pts`
|
||||
: delta < 0
|
||||
? `Contracting ${Math.abs(delta)} pts`
|
||||
: "Stable",
|
||||
}
|
||||
}, [data, innerH, innerW, padX, padY])
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
className={strokeClass}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-muted-foreground text-xs">{deltaLabel}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function RenewalActionsCell({
|
||||
row,
|
||||
onOpenAccount,
|
||||
onCreateBrief,
|
||||
onEscalateReview,
|
||||
}: {
|
||||
row: Row<IRenewalRecord>
|
||||
onOpenAccount: (renewal: IRenewalRecord) => void
|
||||
onCreateBrief: (renewal: IRenewalRecord) => void
|
||||
onEscalateReview: (renewal: IRenewalRecord) => void
|
||||
}) {
|
||||
const [escalateOpen, setEscalateOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label="Renewal actions"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-44">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onOpenAccount(row.original)}>
|
||||
<EyeIcon className="size-4" aria-hidden="true" />
|
||||
Open account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onCreateBrief(row.original)}>
|
||||
<FileTextIcon className="size-4" aria-hidden="true" />
|
||||
{row.original.stage === "Legal review"
|
||||
? "Legal redlines"
|
||||
: row.original.invoiceStatus !== "Ready"
|
||||
? "Finance sign-off"
|
||||
: "Renewal brief"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setEscalateOpen(true)}
|
||||
>
|
||||
<TriangleAlertIcon className="size-4" aria-hidden="true" />
|
||||
Escalate
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={escalateOpen} onOpenChange={setEscalateOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Create an exec escalation?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will push{" "}
|
||||
<span className="text-foreground font-medium">
|
||||
{row.original.accountName}
|
||||
</span>{" "}
|
||||
into an urgent board-review path in a real revenue-ops workspace.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => onEscalateReview(row.original)}>
|
||||
Create escalation
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function createRenewalColumns({
|
||||
onOpenAccount,
|
||||
onCreateBrief,
|
||||
onEscalateReview,
|
||||
}: {
|
||||
onOpenAccount: (renewal: IRenewalRecord) => void
|
||||
onCreateBrief: (renewal: IRenewalRecord) => void
|
||||
onEscalateReview: (renewal: IRenewalRecord) => void
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
accessorKey: "id",
|
||||
id: "select",
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
enableHiding: false,
|
||||
size: 36,
|
||||
meta: {
|
||||
headerClassName: "ps-4!",
|
||||
cellClassName: "ps-4!",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "accountName",
|
||||
id: "account",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="Account"
|
||||
visibility={true}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <AccountCell row={row} />,
|
||||
minSize: 280,
|
||||
size: 320,
|
||||
enableSorting: true,
|
||||
enableResizing: true,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
autoSize: true,
|
||||
skeleton: (
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<Skeleton className="mt-0.5 size-5 shrink-0 rounded" />
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.daysToRenewal,
|
||||
id: "renewalWindow",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="Renewal"
|
||||
visibility={true}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <RenewalWindowCell row={row} />,
|
||||
size: 150,
|
||||
minSize: 140,
|
||||
enableSorting: true,
|
||||
enableResizing: true,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
skeleton: (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "arr",
|
||||
id: "arr",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="ARR" visibility={true} column={column} />
|
||||
),
|
||||
cell: ({ row }) => <RevenueCell row={row} />,
|
||||
size: 150,
|
||||
minSize: 140,
|
||||
enableSorting: true,
|
||||
enableResizing: true,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
skeleton: (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "healthScore",
|
||||
id: "health",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="Health"
|
||||
visibility={true}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <HealthCell row={row} />,
|
||||
size: 140,
|
||||
minSize: 130,
|
||||
enableSorting: true,
|
||||
enableResizing: true,
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "usageTrend",
|
||||
id: "usage",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Usage" visibility={true} column={column} />
|
||||
),
|
||||
cell: ({ row }) => <UsageTrendCell row={row} />,
|
||||
size: 120,
|
||||
minSize: 112,
|
||||
enableSorting: false,
|
||||
enableResizing: true,
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "stage",
|
||||
id: "stage",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Stage" visibility={true} column={column} />
|
||||
),
|
||||
cell: ({ row }) => <StageBadge stage={row.original.stage} />,
|
||||
size: 168,
|
||||
minSize: 160,
|
||||
enableSorting: true,
|
||||
enableResizing: true,
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "risk",
|
||||
id: "risk",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Risk" visibility={true} column={column} />
|
||||
),
|
||||
cell: ({ row }) => <RiskBadge risk={row.original.risk} />,
|
||||
size: 116,
|
||||
minSize: 108,
|
||||
enableSorting: true,
|
||||
enableResizing: true,
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "invoiceStatus",
|
||||
id: "invoiceStatus",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="Invoice"
|
||||
visibility={true}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<InvoiceStatusBadge status={row.original.invoiceStatus} />
|
||||
),
|
||||
size: 148,
|
||||
minSize: 138,
|
||||
enableSorting: true,
|
||||
enableResizing: true,
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "sponsorStatus",
|
||||
id: "sponsorStatus",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="Sponsor"
|
||||
visibility={true}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<SponsorStatusBadge status={row.original.sponsorStatus} />
|
||||
),
|
||||
size: 136,
|
||||
minSize: 128,
|
||||
enableSorting: true,
|
||||
enableResizing: true,
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "region",
|
||||
id: "region",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="Region"
|
||||
visibility={true}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-foreground text-sm">{row.original.region}</span>
|
||||
),
|
||||
size: 124,
|
||||
minSize: 116,
|
||||
enableSorting: true,
|
||||
enableResizing: true,
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<RenewalActionsCell
|
||||
row={row}
|
||||
onOpenAccount={onOpenAccount}
|
||||
onCreateBrief={onCreateBrief}
|
||||
onEscalateReview={onEscalateReview}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
enableHiding: false,
|
||||
size: 56,
|
||||
meta: {
|
||||
cellClassName: "pe-4!",
|
||||
headerClassName: "pe-4!",
|
||||
},
|
||||
},
|
||||
] satisfies ColumnDef<IRenewalRecord>[]
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { DataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
createFilter,
|
||||
Filters,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from "@/components/reui/filters"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
import { TooltipProvider } from "@evobgp/ui/components/tooltip"
|
||||
import { createRenewalColumns, RiskBadge, StageBadge } from "./columns"
|
||||
import {
|
||||
getRenewalWindowValue,
|
||||
RENEWAL_OWNERS,
|
||||
RENEWAL_RECORDS,
|
||||
RENEWAL_RISK_ORDER,
|
||||
RENEWAL_SEGMENT_ORDER,
|
||||
RENEWAL_STAGE_ORDER,
|
||||
RENEWAL_WINDOW_OPTIONS,
|
||||
type IRenewalRecord,
|
||||
type RenewalRisk,
|
||||
type RenewalStage,
|
||||
} from "./data"
|
||||
import { RenewalSelectionBar } from "./renewal-selection-bar"
|
||||
import { RenewalsCommandCard } from "./renewals-command-card"
|
||||
import { FilterIcon, Settings2Icon, CheckIcon, Building2Icon, LayersIcon, GitBranchIcon, TriangleAlertIcon, CalendarClockIcon, UserRoundIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
function getActiveFilters(filters: Filter[]) {
|
||||
return filters.filter((filter) => {
|
||||
const { values } = filter
|
||||
if (!values || values.length === 0) return false
|
||||
if (
|
||||
values.every((value) => typeof value === "string" && value.trim() === "")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => value === null || value === undefined)) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function filterFieldValue(item: IRenewalRecord, field: string): unknown {
|
||||
if (field === "renewalWindow") {
|
||||
return getRenewalWindowValue(item.daysToRenewal)
|
||||
}
|
||||
return item[field as keyof IRenewalRecord]
|
||||
}
|
||||
|
||||
function applyFiltersToData(
|
||||
data: IRenewalRecord[],
|
||||
filters: Filter[]
|
||||
): IRenewalRecord[] {
|
||||
const active = getActiveFilters(filters)
|
||||
let result = [...data]
|
||||
|
||||
active.forEach((filter) => {
|
||||
const { field, operator, values } = filter
|
||||
|
||||
result = result.filter((item) => {
|
||||
const raw = filterFieldValue(item, field)
|
||||
const fieldValue = raw != null ? raw : ""
|
||||
|
||||
switch (operator) {
|
||||
case "is":
|
||||
return values.includes(fieldValue)
|
||||
case "is_not":
|
||||
return !values.includes(fieldValue)
|
||||
case "is_any_of":
|
||||
return values.some((value) => fieldValue === value)
|
||||
case "is_not_any_of":
|
||||
return !values.some((value) => fieldValue === value)
|
||||
case "contains": {
|
||||
const tokens = values
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean)
|
||||
if (tokens.length === 0) return true
|
||||
return tokens.some((token) =>
|
||||
String(fieldValue).toLowerCase().includes(token.toLowerCase())
|
||||
)
|
||||
}
|
||||
case "not_contains":
|
||||
return !values.some((value) =>
|
||||
String(fieldValue)
|
||||
.toLowerCase()
|
||||
.includes(String(value).toLowerCase())
|
||||
)
|
||||
case "starts_with":
|
||||
return values.some((value) =>
|
||||
String(fieldValue)
|
||||
.toLowerCase()
|
||||
.startsWith(String(value).toLowerCase())
|
||||
)
|
||||
case "ends_with":
|
||||
return values.some((value) =>
|
||||
String(fieldValue)
|
||||
.toLowerCase()
|
||||
.endsWith(String(value).toLowerCase())
|
||||
)
|
||||
case "equals":
|
||||
return fieldValue === values[0]
|
||||
case "not_equals":
|
||||
return fieldValue !== values[0]
|
||||
case "greater_than":
|
||||
return Number(fieldValue) > Number(values[0])
|
||||
case "less_than":
|
||||
return Number(fieldValue) < Number(values[0])
|
||||
case "greater_than_or_equal":
|
||||
return Number(fieldValue) >= Number(values[0])
|
||||
case "less_than_or_equal":
|
||||
return Number(fieldValue) <= Number(values[0])
|
||||
case "between":
|
||||
if (values.length >= 2) {
|
||||
const min = Number(values[0])
|
||||
const max = Number(values[1])
|
||||
return Number(fieldValue) >= min && Number(fieldValue) <= max
|
||||
}
|
||||
return true
|
||||
case "not_between":
|
||||
if (values.length >= 2) {
|
||||
const min = Number(values[0])
|
||||
const max = Number(values[1])
|
||||
return Number(fieldValue) < min || Number(fieldValue) > max
|
||||
}
|
||||
return true
|
||||
case "empty":
|
||||
return fieldValue === "" || fieldValue == null
|
||||
case "not_empty":
|
||||
return fieldValue !== "" && fieldValue != null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function renderSelectedCount(values: unknown[]) {
|
||||
if (values.length === 0) return "Select..."
|
||||
if (values.length > 1) return `${values.length} selected`
|
||||
return null
|
||||
}
|
||||
|
||||
function formatCompactCurrency(value: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function createDefaultRenewalFilters(): Filter[] {
|
||||
return [createFilter("accountName", "contains", [""])]
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMN_ORDER = [
|
||||
"select",
|
||||
"account",
|
||||
"renewalWindow",
|
||||
"arr",
|
||||
"health",
|
||||
"usage",
|
||||
"stage",
|
||||
"risk",
|
||||
"invoiceStatus",
|
||||
"sponsorStatus",
|
||||
"region",
|
||||
"actions",
|
||||
]
|
||||
|
||||
type TableDensity = "compact" | "comfortable"
|
||||
type DisplayColumn =
|
||||
| "health"
|
||||
| "usage"
|
||||
| "stage"
|
||||
| "risk"
|
||||
| "invoiceStatus"
|
||||
| "sponsorStatus"
|
||||
| "region"
|
||||
|
||||
const TABLE_DENSITY_OPTIONS: { value: TableDensity; label: string }[] = [
|
||||
{ value: "compact", label: "Compact" },
|
||||
{ value: "comfortable", label: "Comfortable" },
|
||||
]
|
||||
|
||||
const DISPLAY_COLUMNS: { key: DisplayColumn; label: string }[] = [
|
||||
{ key: "health", label: "Health" },
|
||||
{ key: "usage", label: "Usage" },
|
||||
{ key: "stage", label: "Stage" },
|
||||
{ key: "risk", label: "Risk" },
|
||||
{ key: "invoiceStatus", label: "Invoice" },
|
||||
{ key: "sponsorStatus", label: "Sponsor" },
|
||||
{ key: "region", label: "Region" },
|
||||
]
|
||||
|
||||
interface ToolbarProps {
|
||||
filters: Filter[]
|
||||
fields: FilterFieldConfig[]
|
||||
onFiltersChange: (filters: Filter[]) => void
|
||||
onClearFilters: () => void
|
||||
showClearButton: boolean
|
||||
tableDensity: TableDensity
|
||||
onTableDensityChange: (value: TableDensity) => void
|
||||
columnsResizable: boolean
|
||||
onColumnsResizableChange: (value: boolean) => void
|
||||
columnsMovable: boolean
|
||||
onColumnsMovableChange: (value: boolean) => void
|
||||
visibleColumns: Record<DisplayColumn, boolean>
|
||||
onToggleColumn: (columnId: DisplayColumn) => void
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
filters,
|
||||
fields,
|
||||
onFiltersChange,
|
||||
onClearFilters,
|
||||
showClearButton,
|
||||
tableDensity,
|
||||
onTableDensityChange,
|
||||
columnsResizable,
|
||||
onColumnsResizableChange,
|
||||
columnsMovable,
|
||||
onColumnsMovableChange,
|
||||
visibleColumns,
|
||||
onToggleColumn,
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
{/* Actions */}
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={fields}
|
||||
onChange={onFiltersChange}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label="Renewal filters"
|
||||
>
|
||||
<FilterIcon aria-hidden="true" />
|
||||
Filters
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{showClearButton ? (
|
||||
<Button type="button" variant="outline" onClick={onClearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label="Table settings"
|
||||
>
|
||||
<Settings2Icon className="size-4" aria-hidden="true" />
|
||||
Settings
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-[320px] p-0">
|
||||
<FieldGroup className="gap-3 px-3.5 py-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
Table
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className="min-h-9 items-center justify-between gap-3"
|
||||
>
|
||||
<FieldLabel className="text-sm font-normal">
|
||||
Density
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={tableDensity}
|
||||
onValueChange={(value) =>
|
||||
onTableDensityChange(value as TableDensity)
|
||||
}
|
||||
items={TABLE_DENSITY_OPTIONS}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-[132px] shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectGroup>
|
||||
{TABLE_DENSITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className="min-h-9 items-center justify-between gap-3"
|
||||
>
|
||||
<FieldLabel className="text-sm font-normal">
|
||||
Resizable columns
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={columnsResizable}
|
||||
onCheckedChange={onColumnsResizableChange}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className="min-h-9 items-center justify-between gap-3"
|
||||
>
|
||||
<FieldLabel className="text-sm font-normal">
|
||||
Movable columns
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={columnsMovable}
|
||||
onCheckedChange={onColumnsMovableChange}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldSeparator className="-mx-3.5" />
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
Display columns
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DISPLAY_COLUMNS.map((column) => {
|
||||
const active = visibleColumns[column.key]
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={column.key}
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={active ? "secondary" : "outline"}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
active && "border-foreground/10"
|
||||
)}
|
||||
onClick={() => onToggleColumn(column.key)}
|
||||
>
|
||||
{active ? (
|
||||
<CheckIcon className="size-3.5" aria-hidden="true" />
|
||||
) : null}
|
||||
{column.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RenewalsCommandGridView() {
|
||||
const [renewals, setRenewals] = useState<IRenewalRecord[]>(RENEWAL_RECORDS)
|
||||
const [tableDensity, setTableDensity] = useState<TableDensity>("compact")
|
||||
const [columnsResizable, setColumnsResizable] = useState(true)
|
||||
const [columnsMovable, setColumnsMovable] = useState(true)
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "renewalWindow", desc: false },
|
||||
{ id: "arr", desc: true },
|
||||
])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
health: true,
|
||||
stage: true,
|
||||
risk: true,
|
||||
usage: false,
|
||||
invoiceStatus: false,
|
||||
sponsorStatus: false,
|
||||
region: false,
|
||||
})
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [columnOrder, setColumnOrder] = useState(DEFAULT_COLUMN_ORDER)
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultRenewalFilters)
|
||||
const [bulkOwnerValue, setBulkOwnerValue] = useState(RENEWAL_OWNERS[0].value)
|
||||
const [bulkStageValue, setBulkStageValue] =
|
||||
useState<RenewalStage>("Commercial review")
|
||||
|
||||
const filterFields: FilterFieldConfig[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "accountName",
|
||||
label: "Account",
|
||||
icon: (
|
||||
<Building2Icon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
type: "text",
|
||||
className: "w-[200px]",
|
||||
placeholder: "Search...",
|
||||
},
|
||||
{
|
||||
key: "segment",
|
||||
label: "Segment",
|
||||
icon: (
|
||||
<LayersIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[160px]",
|
||||
options: RENEWAL_SEGMENT_ORDER.map((segment) => ({
|
||||
value: segment,
|
||||
label: segment,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "stage",
|
||||
label: "Stage",
|
||||
icon: (
|
||||
<GitBranchIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[180px]",
|
||||
options: RENEWAL_STAGE_ORDER.map((stage) => ({
|
||||
value: stage,
|
||||
label: stage,
|
||||
})),
|
||||
customValueRenderer: (values) => {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
return <StageBadge stage={values[0] as RenewalStage} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "risk",
|
||||
label: "Risk",
|
||||
icon: (
|
||||
<TriangleAlertIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[140px]",
|
||||
options: RENEWAL_RISK_ORDER.map((risk) => ({
|
||||
value: risk,
|
||||
label: risk,
|
||||
})),
|
||||
customValueRenderer: (values) => {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
return <RiskBadge risk={values[0] as RenewalRisk} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "renewalWindow",
|
||||
label: "Renewal window",
|
||||
icon: (
|
||||
<CalendarClockIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[170px]",
|
||||
options: RENEWAL_WINDOW_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "ownerName",
|
||||
label: "Owner",
|
||||
icon: (
|
||||
<UserRoundIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[170px]",
|
||||
options: RENEWAL_OWNERS.map((owner) => ({
|
||||
value: owner.label,
|
||||
label: owner.label,
|
||||
})),
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const filteredData = useMemo(
|
||||
() => applyFiltersToData(renewals, filters),
|
||||
[filters, renewals]
|
||||
)
|
||||
const visibleColumns = useMemo<Record<DisplayColumn, boolean>>(
|
||||
() => ({
|
||||
health: columnVisibility.health !== false,
|
||||
usage: columnVisibility.usage !== false,
|
||||
stage: columnVisibility.stage !== false,
|
||||
risk: columnVisibility.risk !== false,
|
||||
invoiceStatus: columnVisibility.invoiceStatus !== false,
|
||||
sponsorStatus: columnVisibility.sponsorStatus !== false,
|
||||
region: columnVisibility.region !== false,
|
||||
}),
|
||||
[columnVisibility]
|
||||
)
|
||||
|
||||
const dueInThirtyCount = useMemo(
|
||||
() => filteredData.filter((renewal) => renewal.daysToRenewal <= 30).length,
|
||||
[filteredData]
|
||||
)
|
||||
|
||||
const blockerCount = useMemo(
|
||||
() =>
|
||||
filteredData.filter(
|
||||
(renewal) =>
|
||||
renewal.invoiceStatus !== "Ready" ||
|
||||
renewal.sponsorStatus !== "Confirmed"
|
||||
).length,
|
||||
[filteredData]
|
||||
)
|
||||
|
||||
const arrAtRisk = useMemo(
|
||||
() =>
|
||||
filteredData.reduce(
|
||||
(total, renewal) =>
|
||||
renewal.risk === "High" || renewal.risk === "Critical"
|
||||
? total + renewal.arr
|
||||
: total,
|
||||
0
|
||||
),
|
||||
[filteredData]
|
||||
)
|
||||
|
||||
const handleOpenAccount = useCallback((renewal: IRenewalRecord) => {
|
||||
toast.info("Open account workspace", {
|
||||
description: `${renewal.accountName} · ${renewal.ownerName}`,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleCreateBrief = useCallback((renewal: IRenewalRecord) => {
|
||||
toast.success("Renewal brief created", {
|
||||
description: `${renewal.accountName} is ready for executive review.`,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleEscalateReview = useCallback((renewal: IRenewalRecord) => {
|
||||
setRenewals((current) =>
|
||||
current.map((record) =>
|
||||
record.id === renewal.id
|
||||
? {
|
||||
...record,
|
||||
risk: "Critical",
|
||||
stage:
|
||||
record.stage === "Committed" ? record.stage : "Legal review",
|
||||
}
|
||||
: record
|
||||
)
|
||||
)
|
||||
|
||||
toast.success("Exec escalation created", {
|
||||
description: `${renewal.accountName} moved into an urgent board-review track.`,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createRenewalColumns({
|
||||
onOpenAccount: handleOpenAccount,
|
||||
onCreateBrief: handleCreateBrief,
|
||||
onEscalateReview: handleEscalateReview,
|
||||
}),
|
||||
[handleCreateBrief, handleEscalateReview, handleOpenAccount]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: filteredData,
|
||||
getRowId: (row) => row.id,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnOrder,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
columnResizeMode: "onChange",
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
const selectedRows = table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => row.original.id)
|
||||
|
||||
const selectedCount = selectedRows.length
|
||||
const showClearButton = filters.length > 0
|
||||
|
||||
const handleFiltersChange = useCallback((nextFilters: Filter[]) => {
|
||||
setFilters(nextFilters)
|
||||
}, [])
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilters(createDefaultRenewalFilters())
|
||||
}, [])
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setRowSelection({})
|
||||
}, [])
|
||||
|
||||
const handleToggleColumn = useCallback((columnId: DisplayColumn) => {
|
||||
setColumnVisibility((current) => ({
|
||||
...current,
|
||||
[columnId]: current[columnId] === false,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleApplySelected = useCallback(() => {
|
||||
if (selectedRows.length === 0) return
|
||||
|
||||
const nextOwner = RENEWAL_OWNERS.find(
|
||||
(owner) => owner.value === bulkOwnerValue
|
||||
)
|
||||
if (!nextOwner) return
|
||||
|
||||
setRenewals((current) =>
|
||||
current.map((renewal) =>
|
||||
selectedRows.includes(renewal.id)
|
||||
? {
|
||||
...renewal,
|
||||
ownerName: nextOwner.label,
|
||||
ownerAvatar: nextOwner.avatar,
|
||||
stage: bulkStageValue,
|
||||
}
|
||||
: renewal
|
||||
)
|
||||
)
|
||||
|
||||
setRowSelection({})
|
||||
toast.success("Renewals updated", {
|
||||
description: `${selectedRows.length} account${selectedRows.length === 1 ? "" : "s"} moved to ${nextOwner.label} and ${bulkStageValue}.`,
|
||||
})
|
||||
}, [bulkOwnerValue, bulkStageValue, selectedRows])
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={200}>
|
||||
{/* Table */}
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage="No renewals match this view. Clear filters or widen the search."
|
||||
tableLayout={{
|
||||
columnsResizable,
|
||||
columnsMovable,
|
||||
columnsVisibility: true,
|
||||
dense: tableDensity === "compact",
|
||||
width: "auto",
|
||||
}}
|
||||
>
|
||||
<RenewalsCommandCard
|
||||
title="Renewals Review"
|
||||
description={`${dueInThirtyCount} due in 30d, ${blockerCount} blocked, ${formatCompactCurrency(arrAtRisk)} ARR at risk.`}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
toast.success("Transaction started", {
|
||||
description: "New renewal entry opened.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
New transaction
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Toolbar
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
showClearButton={showClearButton}
|
||||
tableDensity={tableDensity}
|
||||
onTableDensityChange={setTableDensity}
|
||||
columnsResizable={columnsResizable}
|
||||
onColumnsResizableChange={setColumnsResizable}
|
||||
columnsMovable={columnsMovable}
|
||||
onColumnsMovableChange={setColumnsMovable}
|
||||
visibleColumns={visibleColumns}
|
||||
onToggleColumn={handleToggleColumn}
|
||||
/>
|
||||
|
||||
{selectedCount > 0 ? (
|
||||
<RenewalSelectionBar
|
||||
selectedCount={selectedCount}
|
||||
ownerValue={bulkOwnerValue}
|
||||
stageValue={bulkStageValue}
|
||||
ownerOptions={RENEWAL_OWNERS}
|
||||
onOwnerChange={setBulkOwnerValue}
|
||||
onStageChange={setBulkStageValue}
|
||||
onApply={handleApplySelected}
|
||||
onClear={handleClearSelection}
|
||||
/>
|
||||
) : (
|
||||
<Separator />
|
||||
)}
|
||||
|
||||
<DataGridScrollArea className="h-[540px]">
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</RenewalsCommandCard>
|
||||
</DataGrid>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { AnthropicBlack } from "@evobgp/ui/components/svgs/anthropicBlack"
|
||||
import { AnthropicWhite } from "@evobgp/ui/components/svgs/anthropicWhite"
|
||||
import { Convex } from "@evobgp/ui/components/svgs/convex"
|
||||
import { GoogleCloud } from "@evobgp/ui/components/svgs/googleCloud"
|
||||
import { Hono } from "@evobgp/ui/components/svgs/hono"
|
||||
import { Mintlify } from "@evobgp/ui/components/svgs/mintlify"
|
||||
import { N8n } from "@evobgp/ui/components/svgs/n8n"
|
||||
import { Neon } from "@evobgp/ui/components/svgs/neon"
|
||||
import { Openai } from "@evobgp/ui/components/svgs/openai"
|
||||
import { OpenaiDark } from "@evobgp/ui/components/svgs/openaiDark"
|
||||
import { Paper } from "@evobgp/ui/components/svgs/paper"
|
||||
import { Planetscale } from "@evobgp/ui/components/svgs/planetscale"
|
||||
import { PlanetscaleDark } from "@evobgp/ui/components/svgs/planetscaleDark"
|
||||
import { Prisma } from "@evobgp/ui/components/svgs/prisma"
|
||||
import { PrismaDark } from "@evobgp/ui/components/svgs/prismaDark"
|
||||
import { RemixDark } from "@evobgp/ui/components/svgs/remixDark"
|
||||
import { RemixLight } from "@evobgp/ui/components/svgs/remixLight"
|
||||
import { ResendIconBlack } from "@evobgp/ui/components/svgs/resendIconBlack"
|
||||
import { ResendIconWhite } from "@evobgp/ui/components/svgs/resendIconWhite"
|
||||
import { Slack } from "@evobgp/ui/components/svgs/slack"
|
||||
import { Stripe } from "@evobgp/ui/components/svgs/stripe"
|
||||
import { Supabase } from "@evobgp/ui/components/svgs/supabase"
|
||||
import { Zoom } from "@evobgp/ui/components/svgs/zoom"
|
||||
|
||||
export type RenewalStage =
|
||||
| "Preparing"
|
||||
| "Commercial review"
|
||||
| "Legal review"
|
||||
| "Committed"
|
||||
|
||||
export type RenewalRisk = "Low" | "Medium" | "High" | "Critical"
|
||||
|
||||
export type RenewalSegment = "Enterprise" | "Scale" | "Mid-market"
|
||||
|
||||
export type RenewalInvoiceStatus = "Ready" | "Finance review" | "Blocked"
|
||||
|
||||
export type RenewalSponsorStatus = "Confirmed" | "At risk" | "Missing"
|
||||
|
||||
export interface RenewalOwnerOption {
|
||||
value: string
|
||||
label: string
|
||||
avatar: string
|
||||
teamLabel: string
|
||||
}
|
||||
|
||||
export interface IRenewalRecord {
|
||||
id: string
|
||||
accountName: string
|
||||
accountLogo: ReactNode
|
||||
ownerName: string
|
||||
ownerAvatar: string
|
||||
segment: RenewalSegment
|
||||
region: string
|
||||
renewalDate: string
|
||||
daysToRenewal: number
|
||||
arr: number
|
||||
expansionPotential: number
|
||||
healthScore: number
|
||||
usageTrend: number[]
|
||||
stage: RenewalStage
|
||||
risk: RenewalRisk
|
||||
invoiceStatus: RenewalInvoiceStatus
|
||||
sponsorStatus: RenewalSponsorStatus
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export const RENEWAL_STAGE_ORDER: RenewalStage[] = [
|
||||
"Preparing",
|
||||
"Commercial review",
|
||||
"Legal review",
|
||||
"Committed",
|
||||
]
|
||||
|
||||
export const RENEWAL_RISK_ORDER: RenewalRisk[] = [
|
||||
"Low",
|
||||
"Medium",
|
||||
"High",
|
||||
"Critical",
|
||||
]
|
||||
|
||||
export const RENEWAL_SEGMENT_ORDER: RenewalSegment[] = [
|
||||
"Enterprise",
|
||||
"Scale",
|
||||
"Mid-market",
|
||||
]
|
||||
|
||||
export const RENEWAL_WINDOW_OPTIONS = [
|
||||
{ value: "next-30", label: "Next 30 days" },
|
||||
{ value: "31-60", label: "31-60 days" },
|
||||
{ value: "61-90", label: "61-90 days" },
|
||||
{ value: "90-plus", label: "90+ days" },
|
||||
] as const
|
||||
|
||||
const OPENAI_LOGO = (
|
||||
<>
|
||||
<span aria-hidden className="dark:hidden">
|
||||
<Openai className="size-5" />
|
||||
</span>
|
||||
<span aria-hidden className="hidden dark:block">
|
||||
<OpenaiDark className="size-5" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
const ANTHROPIC_LOGO = (
|
||||
<>
|
||||
<span aria-hidden className="dark:hidden">
|
||||
<AnthropicBlack className="size-5" />
|
||||
</span>
|
||||
<span aria-hidden className="hidden dark:block">
|
||||
<AnthropicWhite className="size-5" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
const PRISMA_LOGO = (
|
||||
<>
|
||||
<span aria-hidden className="dark:hidden">
|
||||
<Prisma className="size-5" />
|
||||
</span>
|
||||
<span aria-hidden className="hidden dark:block">
|
||||
<PrismaDark className="size-5" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
const PLANETSCALE_LOGO = (
|
||||
<>
|
||||
<span aria-hidden className="dark:hidden">
|
||||
<Planetscale className="text-foreground size-5" />
|
||||
</span>
|
||||
<span aria-hidden className="hidden dark:block">
|
||||
<PlanetscaleDark className="size-5" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
const RESEND_LOGO = (
|
||||
<>
|
||||
<span aria-hidden className="dark:hidden">
|
||||
<ResendIconBlack className="size-5" />
|
||||
</span>
|
||||
<span aria-hidden className="hidden dark:block">
|
||||
<ResendIconWhite className="size-5" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
const REMIX_LOGO = (
|
||||
<>
|
||||
<span aria-hidden className="dark:hidden">
|
||||
<RemixLight className="size-5" />
|
||||
</span>
|
||||
<span aria-hidden className="hidden dark:block">
|
||||
<RemixDark className="size-5" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
function VercelMark() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 76 65"
|
||||
className="text-foreground size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fill="currentColor" d="M37.5274 0L75.0548 65H0L37.5274 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function getRenewalWindowValue(daysToRenewal: number) {
|
||||
if (daysToRenewal <= 30) return "next-30"
|
||||
if (daysToRenewal <= 60) return "31-60"
|
||||
if (daysToRenewal <= 90) return "61-90"
|
||||
return "90-plus"
|
||||
}
|
||||
|
||||
export function getRenewalWindowLabel(daysToRenewal: number) {
|
||||
const match = RENEWAL_WINDOW_OPTIONS.find(
|
||||
(option) => option.value === getRenewalWindowValue(daysToRenewal)
|
||||
)
|
||||
|
||||
return match?.label ?? "90+ days"
|
||||
}
|
||||
|
||||
export const RENEWAL_OWNERS: RenewalOwnerOption[] = [
|
||||
{
|
||||
value: "maya-patel",
|
||||
label: "Maya Patel",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
teamLabel: "Enterprise coverage",
|
||||
},
|
||||
{
|
||||
value: "jonah-lee",
|
||||
label: "Jonah Lee",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
teamLabel: "Strategic expansion",
|
||||
},
|
||||
{
|
||||
value: "nina-santos",
|
||||
label: "Nina Santos",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
|
||||
teamLabel: "Commercial renewals",
|
||||
},
|
||||
{
|
||||
value: "omar-haddad",
|
||||
label: "Omar Haddad",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
|
||||
teamLabel: "Expansion programs",
|
||||
},
|
||||
{
|
||||
value: "priya-menon",
|
||||
label: "Priya Menon",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1557296387-5358ad7997bb?w=96&h=96&dpr=2&q=80",
|
||||
teamLabel: "Finance alignment",
|
||||
},
|
||||
]
|
||||
|
||||
const RENEWAL_RECORD_SEEDS: IRenewalRecord[] = [
|
||||
{
|
||||
id: "REN-1842",
|
||||
accountName: "Stripe",
|
||||
accountLogo: <Stripe className="size-5" aria-hidden="true" />,
|
||||
ownerName: "Maya Patel",
|
||||
ownerAvatar: RENEWAL_OWNERS[0].avatar,
|
||||
segment: "Enterprise",
|
||||
region: "North America",
|
||||
renewalDate: "2026-05-12",
|
||||
daysToRenewal: 7,
|
||||
arr: 420000,
|
||||
expansionPotential: 95000,
|
||||
healthScore: 46,
|
||||
usageTrend: [86, 84, 82, 79, 74, 68, 63, 57],
|
||||
stage: "Legal review",
|
||||
risk: "Critical",
|
||||
invoiceStatus: "Blocked",
|
||||
sponsorStatus: "Missing",
|
||||
tags: ["Usage drop", "Legal redlines"],
|
||||
},
|
||||
{
|
||||
id: "REN-1837",
|
||||
accountName: "Supabase",
|
||||
accountLogo: <Supabase className="size-5" aria-hidden="true" />,
|
||||
ownerName: "Nina Santos",
|
||||
ownerAvatar: RENEWAL_OWNERS[2].avatar,
|
||||
segment: "Enterprise",
|
||||
region: "North America",
|
||||
renewalDate: "2026-05-17",
|
||||
daysToRenewal: 12,
|
||||
arr: 365000,
|
||||
expansionPotential: 120000,
|
||||
healthScore: 58,
|
||||
usageTrend: [72, 73, 71, 69, 66, 63, 61, 60],
|
||||
stage: "Commercial review",
|
||||
risk: "High",
|
||||
invoiceStatus: "Finance review",
|
||||
sponsorStatus: "At risk",
|
||||
tags: ["Procurement", "Price sensitivity"],
|
||||
},
|
||||
{
|
||||
id: "REN-1831",
|
||||
accountName: "OpenAI",
|
||||
accountLogo: OPENAI_LOGO,
|
||||
ownerName: "Jonah Lee",
|
||||
ownerAvatar: RENEWAL_OWNERS[1].avatar,
|
||||
segment: "Scale",
|
||||
region: "EMEA",
|
||||
renewalDate: "2026-05-29",
|
||||
daysToRenewal: 24,
|
||||
arr: 182000,
|
||||
expansionPotential: 42000,
|
||||
healthScore: 63,
|
||||
usageTrend: [68, 69, 70, 71, 70, 68, 66, 64],
|
||||
stage: "Preparing",
|
||||
risk: "High",
|
||||
invoiceStatus: "Ready",
|
||||
sponsorStatus: "Confirmed",
|
||||
tags: ["Champion change", "Board visibility"],
|
||||
},
|
||||
{
|
||||
id: "REN-1828",
|
||||
accountName: "Mintlify",
|
||||
accountLogo: <Mintlify className="size-5" aria-hidden="true" />,
|
||||
ownerName: "Omar Haddad",
|
||||
ownerAvatar: RENEWAL_OWNERS[3].avatar,
|
||||
segment: "Scale",
|
||||
region: "North America",
|
||||
renewalDate: "2026-06-04",
|
||||
daysToRenewal: 30,
|
||||
arr: 148000,
|
||||
expansionPotential: 86000,
|
||||
healthScore: 74,
|
||||
usageTrend: [64, 66, 68, 70, 73, 76, 79, 82],
|
||||
stage: "Preparing",
|
||||
risk: "Medium",
|
||||
invoiceStatus: "Ready",
|
||||
sponsorStatus: "Confirmed",
|
||||
tags: ["Expansion ready", "Multi-team rollout"],
|
||||
},
|
||||
{
|
||||
id: "REN-1822",
|
||||
accountName: "Anthropic",
|
||||
accountLogo: ANTHROPIC_LOGO,
|
||||
ownerName: "Priya Menon",
|
||||
ownerAvatar: RENEWAL_OWNERS[4].avatar,
|
||||
segment: "Enterprise",
|
||||
region: "North America",
|
||||
renewalDate: "2026-06-11",
|
||||
daysToRenewal: 37,
|
||||
arr: 515000,
|
||||
expansionPotential: 0,
|
||||
healthScore: 54,
|
||||
usageTrend: [78, 76, 74, 71, 69, 66, 64, 61],
|
||||
stage: "Commercial review",
|
||||
risk: "High",
|
||||
invoiceStatus: "Blocked",
|
||||
sponsorStatus: "At risk",
|
||||
tags: ["Budget freeze", "CFO review"],
|
||||
},
|
||||
{
|
||||
id: "REN-1819",
|
||||
accountName: "Convex",
|
||||
accountLogo: <Convex className="size-5" aria-hidden="true" />,
|
||||
ownerName: "Maya Patel",
|
||||
ownerAvatar: RENEWAL_OWNERS[0].avatar,
|
||||
segment: "Mid-market",
|
||||
region: "APAC",
|
||||
renewalDate: "2026-06-18",
|
||||
daysToRenewal: 44,
|
||||
arr: 92000,
|
||||
expansionPotential: 18000,
|
||||
healthScore: 71,
|
||||
usageTrend: [62, 63, 64, 64, 65, 67, 68, 69],
|
||||
stage: "Preparing",
|
||||
risk: "Medium",
|
||||
invoiceStatus: "Ready",
|
||||
sponsorStatus: "Confirmed",
|
||||
tags: ["Stable adoption", "Good champion"],
|
||||
},
|
||||
{
|
||||
id: "REN-1814",
|
||||
accountName: "Neon",
|
||||
accountLogo: <Neon className="size-5" aria-hidden="true" />,
|
||||
ownerName: "Jonah Lee",
|
||||
ownerAvatar: RENEWAL_OWNERS[1].avatar,
|
||||
segment: "Scale",
|
||||
region: "North America",
|
||||
renewalDate: "2026-06-26",
|
||||
daysToRenewal: 52,
|
||||
arr: 206000,
|
||||
expansionPotential: 112000,
|
||||
healthScore: 67,
|
||||
usageTrend: [59, 60, 61, 60, 62, 64, 67, 70],
|
||||
stage: "Commercial review",
|
||||
risk: "Medium",
|
||||
invoiceStatus: "Finance review",
|
||||
sponsorStatus: "Confirmed",
|
||||
tags: ["Cross-sell", "Security add-on"],
|
||||
},
|
||||
{
|
||||
id: "REN-1810",
|
||||
accountName: "PlanetScale",
|
||||
accountLogo: PLANETSCALE_LOGO,
|
||||
ownerName: "Nina Santos",
|
||||
ownerAvatar: RENEWAL_OWNERS[2].avatar,
|
||||
segment: "Enterprise",
|
||||
region: "EMEA",
|
||||
renewalDate: "2026-07-02",
|
||||
daysToRenewal: 58,
|
||||
arr: 438000,
|
||||
expansionPotential: 64000,
|
||||
healthScore: 81,
|
||||
usageTrend: [74, 75, 77, 79, 81, 83, 84, 86],
|
||||
stage: "Preparing",
|
||||
risk: "Low",
|
||||
invoiceStatus: "Ready",
|
||||
sponsorStatus: "Confirmed",
|
||||
tags: ["Healthy usage", "Multi-year ask"],
|
||||
},
|
||||
{
|
||||
id: "REN-1806",
|
||||
accountName: "Prisma",
|
||||
accountLogo: PRISMA_LOGO,
|
||||
ownerName: "Omar Haddad",
|
||||
ownerAvatar: RENEWAL_OWNERS[3].avatar,
|
||||
segment: "Mid-market",
|
||||
region: "North America",
|
||||
renewalDate: "2026-07-08",
|
||||
daysToRenewal: 64,
|
||||
arr: 124000,
|
||||
expansionPotential: 52000,
|
||||
healthScore: 77,
|
||||
usageTrend: [61, 63, 65, 68, 70, 73, 76, 78],
|
||||
stage: "Preparing",
|
||||
risk: "Low",
|
||||
invoiceStatus: "Ready",
|
||||
sponsorStatus: "Confirmed",
|
||||
tags: ["Expansion champion", "Product depth"],
|
||||
},
|
||||
{
|
||||
id: "REN-1801",
|
||||
accountName: "Resend",
|
||||
accountLogo: RESEND_LOGO,
|
||||
ownerName: "Priya Menon",
|
||||
ownerAvatar: RENEWAL_OWNERS[4].avatar,
|
||||
segment: "Scale",
|
||||
region: "North America",
|
||||
renewalDate: "2026-07-13",
|
||||
daysToRenewal: 69,
|
||||
arr: 164000,
|
||||
expansionPotential: 0,
|
||||
healthScore: 52,
|
||||
usageTrend: [67, 66, 64, 62, 59, 57, 54, 52],
|
||||
stage: "Commercial review",
|
||||
risk: "High",
|
||||
invoiceStatus: "Finance review",
|
||||
sponsorStatus: "At risk",
|
||||
tags: ["Seat contraction", "Procurement loop"],
|
||||
},
|
||||
{
|
||||
id: "REN-1798",
|
||||
accountName: "Slack",
|
||||
accountLogo: <Slack className="size-5 shrink-0" aria-hidden="true" />,
|
||||
ownerName: "Maya Patel",
|
||||
ownerAvatar: RENEWAL_OWNERS[0].avatar,
|
||||
segment: "Mid-market",
|
||||
region: "EMEA",
|
||||
renewalDate: "2026-07-18",
|
||||
daysToRenewal: 74,
|
||||
arr: 88000,
|
||||
expansionPotential: 24000,
|
||||
healthScore: 69,
|
||||
usageTrend: [54, 55, 56, 58, 60, 61, 63, 64],
|
||||
stage: "Preparing",
|
||||
risk: "Medium",
|
||||
invoiceStatus: "Ready",
|
||||
sponsorStatus: "Confirmed",
|
||||
tags: ["Team growth", "Regional rollout"],
|
||||
},
|
||||
{
|
||||
id: "REN-1794",
|
||||
accountName: "Zoom",
|
||||
accountLogo: <Zoom className="size-5 shrink-0" aria-hidden="true" />,
|
||||
ownerName: "Jonah Lee",
|
||||
ownerAvatar: RENEWAL_OWNERS[1].avatar,
|
||||
segment: "Scale",
|
||||
region: "North America",
|
||||
renewalDate: "2026-07-24",
|
||||
daysToRenewal: 80,
|
||||
arr: 212000,
|
||||
expansionPotential: 135000,
|
||||
healthScore: 83,
|
||||
usageTrend: [70, 72, 74, 77, 80, 83, 86, 88],
|
||||
stage: "Preparing",
|
||||
risk: "Low",
|
||||
invoiceStatus: "Ready",
|
||||
sponsorStatus: "Confirmed",
|
||||
tags: ["Expansion approved", "Executive sponsor"],
|
||||
},
|
||||
{
|
||||
id: "REN-1791",
|
||||
accountName: "Hono",
|
||||
accountLogo: <Hono className="size-5" aria-hidden="true" />,
|
||||
ownerName: "Nina Santos",
|
||||
ownerAvatar: RENEWAL_OWNERS[2].avatar,
|
||||
segment: "Enterprise",
|
||||
region: "North America",
|
||||
renewalDate: "2026-07-31",
|
||||
daysToRenewal: 87,
|
||||
arr: 610000,
|
||||
expansionPotential: 180000,
|
||||
healthScore: 79,
|
||||
usageTrend: [73, 75, 77, 78, 80, 82, 83, 85],
|
||||
stage: "Commercial review",
|
||||
risk: "Medium",
|
||||
invoiceStatus: "Finance review",
|
||||
sponsorStatus: "Confirmed",
|
||||
tags: ["Security pack", "Platform standard"],
|
||||
},
|
||||
{
|
||||
id: "REN-1787",
|
||||
accountName: "Remix",
|
||||
accountLogo: REMIX_LOGO,
|
||||
ownerName: "Omar Haddad",
|
||||
ownerAvatar: RENEWAL_OWNERS[3].avatar,
|
||||
segment: "Mid-market",
|
||||
region: "APAC",
|
||||
renewalDate: "2026-08-05",
|
||||
daysToRenewal: 92,
|
||||
arr: 76000,
|
||||
expansionPotential: 21000,
|
||||
healthScore: 72,
|
||||
usageTrend: [58, 59, 60, 62, 64, 65, 67, 68],
|
||||
stage: "Preparing",
|
||||
risk: "Low",
|
||||
invoiceStatus: "Ready",
|
||||
sponsorStatus: "Confirmed",
|
||||
tags: ["Partner motion", "Upsell path"],
|
||||
},
|
||||
{
|
||||
id: "REN-1783",
|
||||
accountName: "Paper",
|
||||
accountLogo: <Paper className="size-5" aria-hidden="true" />,
|
||||
ownerName: "Priya Menon",
|
||||
ownerAvatar: RENEWAL_OWNERS[4].avatar,
|
||||
segment: "Scale",
|
||||
region: "EMEA",
|
||||
renewalDate: "2026-08-11",
|
||||
daysToRenewal: 98,
|
||||
arr: 154000,
|
||||
expansionPotential: 0,
|
||||
healthScore: 57,
|
||||
usageTrend: [63, 62, 60, 58, 57, 55, 54, 53],
|
||||
stage: "Preparing",
|
||||
risk: "Medium",
|
||||
invoiceStatus: "Finance review",
|
||||
sponsorStatus: "At risk",
|
||||
tags: ["Utilization drift", "Renewal watch"],
|
||||
},
|
||||
{
|
||||
id: "REN-1778",
|
||||
accountName: "n8n",
|
||||
accountLogo: <N8n className="size-5" aria-hidden="true" />,
|
||||
ownerName: "Maya Patel",
|
||||
ownerAvatar: RENEWAL_OWNERS[0].avatar,
|
||||
segment: "Enterprise",
|
||||
region: "North America",
|
||||
renewalDate: "2026-08-20",
|
||||
daysToRenewal: 107,
|
||||
arr: 448000,
|
||||
expansionPotential: 132000,
|
||||
healthScore: 85,
|
||||
usageTrend: [79, 80, 82, 84, 86, 87, 89, 90],
|
||||
stage: "Preparing",
|
||||
risk: "Low",
|
||||
invoiceStatus: "Ready",
|
||||
sponsorStatus: "Confirmed",
|
||||
tags: ["Expansion mapped", "Board champion"],
|
||||
},
|
||||
{
|
||||
id: "REN-1772",
|
||||
accountName: "Vercel",
|
||||
accountLogo: <VercelMark />,
|
||||
ownerName: "Jonah Lee",
|
||||
ownerAvatar: RENEWAL_OWNERS[1].avatar,
|
||||
segment: "Scale",
|
||||
region: "LATAM",
|
||||
renewalDate: "2026-08-29",
|
||||
daysToRenewal: 116,
|
||||
arr: 132000,
|
||||
expansionPotential: 34000,
|
||||
healthScore: 68,
|
||||
usageTrend: [60, 61, 62, 63, 65, 66, 66, 67],
|
||||
stage: "Preparing",
|
||||
risk: "Medium",
|
||||
invoiceStatus: "Ready",
|
||||
sponsorStatus: "Confirmed",
|
||||
tags: ["Regional growth", "Elastic usage"],
|
||||
},
|
||||
{
|
||||
id: "REN-1768",
|
||||
accountName: "Google Cloud",
|
||||
accountLogo: <GoogleCloud className="size-5" aria-hidden="true" />,
|
||||
ownerName: "Nina Santos",
|
||||
ownerAvatar: RENEWAL_OWNERS[2].avatar,
|
||||
segment: "Enterprise",
|
||||
region: "North America",
|
||||
renewalDate: "2026-09-09",
|
||||
daysToRenewal: 127,
|
||||
arr: 388000,
|
||||
expansionPotential: 74000,
|
||||
healthScore: 64,
|
||||
usageTrend: [66, 67, 68, 68, 67, 66, 65, 64],
|
||||
stage: "Preparing",
|
||||
risk: "Medium",
|
||||
invoiceStatus: "Finance review",
|
||||
sponsorStatus: "Confirmed",
|
||||
tags: ["Compliance review", "Migration timing"],
|
||||
},
|
||||
]
|
||||
|
||||
const REGIONS = ["North America", "EMEA", "APAC", "LATAM"] as const
|
||||
|
||||
const TAG_VARIANTS = [
|
||||
"Budget review",
|
||||
"Champion shift",
|
||||
"Upsell path",
|
||||
"Contract cleanup",
|
||||
"Security review",
|
||||
"Procurement lane",
|
||||
"Usage rebound",
|
||||
"Expansion plan",
|
||||
] as const
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function roundCurrency(value: number) {
|
||||
return Math.round(value / 1000) * 1000
|
||||
}
|
||||
|
||||
function addDays(isoDate: string, days: number) {
|
||||
const nextDate = new Date(`${isoDate}T00:00:00`)
|
||||
nextDate.setDate(nextDate.getDate() + days)
|
||||
|
||||
return nextDate.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function shiftUsageTrend(values: number[], cycle: number, risk: RenewalRisk) {
|
||||
const directionalOffset = risk === "Low" ? 1 : risk === "Critical" ? -2 : 0
|
||||
const cycleOffset = ((cycle % 4) - 1.5) * 1.5
|
||||
|
||||
return values.map((value, index) =>
|
||||
clamp(Math.round(value + directionalOffset * index + cycleOffset), 34, 96)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveOwner(seed: IRenewalRecord, cycle: number, index: number) {
|
||||
const baseOwnerIndex = RENEWAL_OWNERS.findIndex(
|
||||
(owner) => owner.label === seed.ownerName
|
||||
)
|
||||
|
||||
return RENEWAL_OWNERS[
|
||||
(Math.max(baseOwnerIndex, 0) + cycle + index) % RENEWAL_OWNERS.length
|
||||
]
|
||||
}
|
||||
|
||||
function resolveRisk(score: number, daysToRenewal: number): RenewalRisk {
|
||||
if (score < 50 || daysToRenewal <= 14) return "Critical"
|
||||
if (score < 62 || daysToRenewal <= 35) return "High"
|
||||
if (score < 78) return "Medium"
|
||||
return "Low"
|
||||
}
|
||||
|
||||
function resolveStage(
|
||||
baseStage: RenewalStage,
|
||||
risk: RenewalRisk,
|
||||
daysToRenewal: number
|
||||
): RenewalStage {
|
||||
if (risk === "Critical" || daysToRenewal <= 14) return "Legal review"
|
||||
if (risk === "High") return "Commercial review"
|
||||
if (baseStage === "Committed" && risk === "Low") return "Committed"
|
||||
return "Preparing"
|
||||
}
|
||||
|
||||
function resolveInvoiceStatus(
|
||||
baseStatus: RenewalInvoiceStatus,
|
||||
risk: RenewalRisk
|
||||
): RenewalInvoiceStatus {
|
||||
if (risk === "Critical") return "Blocked"
|
||||
if (risk === "High") return "Finance review"
|
||||
return baseStatus === "Blocked" ? "Finance review" : "Ready"
|
||||
}
|
||||
|
||||
function resolveSponsorStatus(risk: RenewalRisk): RenewalSponsorStatus {
|
||||
if (risk === "Critical") return "Missing"
|
||||
if (risk === "High") return "At risk"
|
||||
return "Confirmed"
|
||||
}
|
||||
|
||||
function buildRenewalRecord(
|
||||
seed: IRenewalRecord,
|
||||
cycle: number,
|
||||
index: number
|
||||
) {
|
||||
if (cycle === 0) {
|
||||
return seed
|
||||
}
|
||||
|
||||
const owner = resolveOwner(seed, cycle, index)
|
||||
const daysOffset = cycle * 14 + (index % 3) * 3
|
||||
const daysToRenewal = seed.daysToRenewal + daysOffset
|
||||
const healthScore = clamp(
|
||||
seed.healthScore + (((cycle + index) % 5) - 2) * 4,
|
||||
42,
|
||||
92
|
||||
)
|
||||
const risk = resolveRisk(healthScore, daysToRenewal)
|
||||
const stage = resolveStage(seed.stage, risk, daysToRenewal)
|
||||
const invoiceStatus = resolveInvoiceStatus(seed.invoiceStatus, risk)
|
||||
const sponsorStatus = resolveSponsorStatus(risk)
|
||||
const arrFactor = 1 + cycle * 0.045 + ((index % 4) - 1.5) * 0.03
|
||||
const expansionFactor =
|
||||
seed.expansionPotential === 0
|
||||
? risk === "Low"
|
||||
? 0.12
|
||||
: 0
|
||||
: 1 + ((cycle % 3) - 1) * 0.12 + (risk === "Low" ? 0.14 : 0)
|
||||
|
||||
return {
|
||||
...seed,
|
||||
id: `REN-${2000 + cycle * 100 + index * 3}`,
|
||||
accountName: seed.accountName,
|
||||
ownerName: owner.label,
|
||||
ownerAvatar: owner.avatar,
|
||||
segment:
|
||||
RENEWAL_SEGMENT_ORDER[(cycle + index) % RENEWAL_SEGMENT_ORDER.length],
|
||||
region: REGIONS[(cycle + index) % REGIONS.length],
|
||||
renewalDate: addDays(seed.renewalDate, daysOffset),
|
||||
daysToRenewal,
|
||||
arr: roundCurrency(seed.arr * Math.max(0.68, arrFactor)),
|
||||
expansionPotential: roundCurrency(
|
||||
Math.max(0, seed.expansionPotential * expansionFactor)
|
||||
),
|
||||
healthScore,
|
||||
usageTrend: shiftUsageTrend(seed.usageTrend, cycle + index, risk),
|
||||
stage,
|
||||
risk,
|
||||
invoiceStatus,
|
||||
sponsorStatus,
|
||||
tags: [seed.tags[0], TAG_VARIANTS[(cycle + index) % TAG_VARIANTS.length]],
|
||||
}
|
||||
}
|
||||
|
||||
export const RENEWAL_RECORDS: IRenewalRecord[] = Array.from(
|
||||
{ length: 2 },
|
||||
(_, cycle) =>
|
||||
RENEWAL_RECORD_SEEDS.map((seed, index) =>
|
||||
buildRenewalRecord(seed, cycle, index)
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.sort((left, right) => {
|
||||
if (left.daysToRenewal !== right.daysToRenewal) {
|
||||
return left.daysToRenewal - right.daysToRenewal
|
||||
}
|
||||
|
||||
return right.arr - left.arr
|
||||
})
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
|
||||
import {
|
||||
RENEWAL_STAGE_ORDER,
|
||||
type RenewalOwnerOption,
|
||||
type RenewalStage,
|
||||
} from "./data"
|
||||
|
||||
interface RenewalSelectionBarProps {
|
||||
selectedCount: number
|
||||
ownerValue: string
|
||||
stageValue: RenewalStage
|
||||
ownerOptions: RenewalOwnerOption[]
|
||||
onOwnerChange: (value: string) => void
|
||||
onStageChange: (value: RenewalStage) => void
|
||||
onApply: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export function RenewalSelectionBar({
|
||||
selectedCount,
|
||||
ownerValue,
|
||||
stageValue,
|
||||
ownerOptions,
|
||||
onOwnerChange,
|
||||
onStageChange,
|
||||
onApply,
|
||||
onClear,
|
||||
}: RenewalSelectionBarProps) {
|
||||
return (
|
||||
<div className="bg-muted/30 flex flex-col gap-3 border-y px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge size="sm" variant="outline" radius="full">
|
||||
{selectedCount} selected
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Update owner or stage.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
<Select
|
||||
value={ownerValue}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return
|
||||
onOwnerChange(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-full sm:w-[190px]"
|
||||
aria-label="Assign owner"
|
||||
>
|
||||
<SelectValue placeholder="Assign owner" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectGroup>
|
||||
{ownerOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={stageValue}
|
||||
onValueChange={(value) => onStageChange(value as RenewalStage)}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-full sm:w-[190px]"
|
||||
aria-label="Set stage"
|
||||
>
|
||||
<SelectValue placeholder="Set stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectGroup>
|
||||
{RENEWAL_STAGE_ORDER.map((stage) => (
|
||||
<SelectItem key={stage} value={stage}>
|
||||
{stage}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onClear}>
|
||||
Deselect
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={onApply}>
|
||||
Apply updates
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@evobgp/ui/components/card"
|
||||
|
||||
interface RenewalsCommandCardProps {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
meta?: ReactNode
|
||||
action?: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
export function RenewalsCommandCard({
|
||||
title,
|
||||
description,
|
||||
meta,
|
||||
action,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
contentClassName,
|
||||
}: RenewalsCommandCardProps) {
|
||||
return (
|
||||
<Card className={cn("w-full gap-0 p-0", className)}>
|
||||
{/* Header */}
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4 border-b px-5 py-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description ? (
|
||||
<CardDescription className="text-xs">{description}</CardDescription>
|
||||
) : null}
|
||||
{meta ? (
|
||||
<div className="flex flex-wrap items-center gap-2">{meta}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? (
|
||||
<CardAction className="self-center">{action}</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
{/* Content */}
|
||||
<CardContent className={cn("p-0", contentClassName)}>
|
||||
{children}
|
||||
</CardContent>
|
||||
|
||||
{footer ? (
|
||||
<CardFooter className="border-t px-5 py-3">{footer}</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { RenewalsCommandGridView } from "./components/data-grid-view"
|
||||
|
||||
export function Page() {
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsReady(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Renewals command data grid
|
||||
</h1>
|
||||
{isReady ? (
|
||||
<RenewalsCommandGridView />
|
||||
) : (
|
||||
<div
|
||||
className="bg-card border-border w-full rounded-xl border"
|
||||
style={{ height: 640 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { IconStack } from "@/components/reui/icon-stack"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@evobgp/ui/components/empty"
|
||||
import { RouteIcon } from "lucide-react"
|
||||
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<div className="flex w-full max-w-4xl flex-col gap-4">
|
||||
{/* Heading */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-semibold tracking-tight">
|
||||
Routing Signals
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Shape incoming work before it reaches the roadmap.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="button" size="sm" className="w-full sm:w-auto">
|
||||
Add signal
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<Card className="bg-muted min-h-[330px] p-0 shadow-none">
|
||||
<CardContent className="flex min-h-[330px] items-center justify-center p-6 sm:p-10">
|
||||
<Empty className="max-w-md gap-5 bg-transparent p-0">
|
||||
<EmptyHeader className="items-center gap-5 text-center">
|
||||
<EmptyMedia className="mb-0">
|
||||
<IconStack aria-hidden="true">
|
||||
<RouteIcon strokeWidth="1.9" aria-hidden="true" />
|
||||
</IconStack>
|
||||
</EmptyMedia>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">
|
||||
Create signals to route new work
|
||||
</EmptyTitle>
|
||||
<EmptyDescription className="max-w-sm text-sm/relaxed">
|
||||
Define signals so every intake item starts with context.
|
||||
</EmptyDescription>
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { EmptyState } from "./components/empty-state"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh w-full items-center justify-center p-4 sm:p-8 md:p-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Signal routing empty state
|
||||
</h1>
|
||||
<EmptyState />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@evobgp/ui/components/empty"
|
||||
import { ProjectsEmptyIllustration } from "./projects-empty-illustration"
|
||||
import { QuickStartTemplates } from "./quick-start-templates"
|
||||
import { PlusIcon, LayoutTemplateIcon } from "lucide-react"
|
||||
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<div className="flex min-h-[820px] w-full max-w-[52rem] flex-col items-center justify-center gap-24 py-8 sm:gap-32 md:py-12">
|
||||
{/* Empty State */}
|
||||
<Empty className="flex-none gap-7 rounded-none border-0 p-0 md:p-0">
|
||||
<EmptyHeader className="max-w-[28rem] gap-4">
|
||||
<EmptyMedia className="mb-0">
|
||||
<ProjectsEmptyIllustration />
|
||||
</EmptyMedia>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-2xl font-semibold tracking-tight">
|
||||
No projects to show
|
||||
</EmptyTitle>
|
||||
<EmptyDescription className="max-w-[25rem] text-sm/relaxed">
|
||||
Start a project from scratch or pick a template to launch your
|
||||
first workspace and begin tracking tasks, goals, and progress.
|
||||
</EmptyDescription>
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
|
||||
<EmptyContent className="max-w-none gap-0">
|
||||
<div className="flex w-full flex-wrap items-center justify-center gap-2">
|
||||
<Button type="button">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
New project
|
||||
</Button>
|
||||
|
||||
<Button type="button" variant="outline">
|
||||
<LayoutTemplateIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Explore templates
|
||||
</Button>
|
||||
</div>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<QuickStartTemplates />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
export function ProjectsEmptyIllustration({
|
||||
variant = "hero",
|
||||
}: {
|
||||
variant?: "hero" | "compact"
|
||||
}) {
|
||||
const isCompact = variant === "compact"
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"relative isolate",
|
||||
isCompact ? "h-11 w-20" : "h-32 w-[13.5rem]"
|
||||
)}
|
||||
>
|
||||
{isCompact ? <CompactView /> : <HeroView />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeroView() {
|
||||
return (
|
||||
<svg viewBox="0 0 220 140" fill="none" className="h-full w-full">
|
||||
<line
|
||||
x1="40"
|
||||
y1="0"
|
||||
x2="40"
|
||||
y2="140"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="110"
|
||||
y1="0"
|
||||
x2="110"
|
||||
y2="140"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="180"
|
||||
y1="0"
|
||||
x2="180"
|
||||
y2="140"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="25"
|
||||
x2="220"
|
||||
y2="25"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="70"
|
||||
x2="220"
|
||||
y2="70"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="115"
|
||||
x2="220"
|
||||
y2="115"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Cube A - bottom step */}
|
||||
<polygon
|
||||
points="89,58 110,70 110,94 89,106 68,94 68,70"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.28"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="89"
|
||||
y1="82"
|
||||
x2="68"
|
||||
y2="70"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="89"
|
||||
y1="82"
|
||||
x2="110"
|
||||
y2="70"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="89"
|
||||
y1="82"
|
||||
x2="89"
|
||||
y2="106"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Cube B - middle step */}
|
||||
<polygon
|
||||
points="110,46 131,58 131,82 110,94 89,82 89,58"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.38"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="110"
|
||||
y1="70"
|
||||
x2="89"
|
||||
y2="58"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="110"
|
||||
y1="70"
|
||||
x2="131"
|
||||
y2="58"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="110"
|
||||
y1="70"
|
||||
x2="110"
|
||||
y2="94"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Cube C - top step */}
|
||||
<polygon
|
||||
points="131,34 152,46 152,70 131,82 110,70 110,46"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.5"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="131"
|
||||
y1="58"
|
||||
x2="110"
|
||||
y2="46"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="131"
|
||||
y1="58"
|
||||
x2="152"
|
||||
y2="46"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="131"
|
||||
y1="58"
|
||||
x2="131"
|
||||
y2="82"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Floating diamond - ghost block suggesting growth */}
|
||||
<path
|
||||
d="M148 22 L155 26 L148 30 L141 26 Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
|
||||
{/* Accent dots */}
|
||||
<circle cx="40" cy="70" r="1.5" fill="currentColor" opacity="0.1" />
|
||||
<circle cx="180" cy="70" r="1.5" fill="currentColor" opacity="0.1" />
|
||||
<circle cx="110" cy="25" r="1.5" fill="currentColor" opacity="0.07" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactView() {
|
||||
return (
|
||||
<svg viewBox="0 0 80 44" fill="none" className="h-full w-full">
|
||||
{/* Cube A - bottom step */}
|
||||
<polygon
|
||||
points="33,18 40,22 40,30 33,34 26,30 26,22"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.28"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="33"
|
||||
y1="26"
|
||||
x2="26"
|
||||
y2="22"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="33"
|
||||
y1="26"
|
||||
x2="40"
|
||||
y2="22"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="33"
|
||||
y1="26"
|
||||
x2="33"
|
||||
y2="34"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.18"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Cube B - middle step */}
|
||||
<polygon
|
||||
points="40,14 47,18 47,26 40,30 33,26 33,18"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.38"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="40"
|
||||
y1="22"
|
||||
x2="33"
|
||||
y2="18"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="40"
|
||||
y1="22"
|
||||
x2="47"
|
||||
y2="18"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="40"
|
||||
y1="22"
|
||||
x2="40"
|
||||
y2="30"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Cube C - top step */}
|
||||
<polygon
|
||||
points="47,10 54,14 54,22 47,26 40,22 40,14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.5"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="47"
|
||||
y1="18"
|
||||
x2="40"
|
||||
y2="14"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="47"
|
||||
y1="18"
|
||||
x2="54"
|
||||
y2="14"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line
|
||||
x1="47"
|
||||
y1="18"
|
||||
x2="47"
|
||||
y2="26"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@evobgp/ui/components/item"
|
||||
import { KanbanIcon, ChevronRightIcon, TimerIcon } from "lucide-react"
|
||||
|
||||
export function QuickStartTemplates() {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground text-sm">Quick starts</p>
|
||||
{/* Grid */}
|
||||
<div className="grid w-full gap-3 sm:grid-cols-2">
|
||||
<Item
|
||||
variant="outline"
|
||||
render={<a href="#" aria-label="Task board" />}
|
||||
className="hover:bg-muted/30 min-w-0 transition-colors"
|
||||
>
|
||||
<ItemMedia className="text-muted-foreground">
|
||||
<KanbanIcon className="size-3.5" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="truncate">Task board</ItemTitle>
|
||||
</ItemContent>
|
||||
|
||||
<ItemActions className="text-muted-foreground ml-auto">
|
||||
<ChevronRightIcon className="size-4" aria-hidden="true" />
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<Item
|
||||
variant="outline"
|
||||
render={<a href="#" aria-label="Sprint tracker" />}
|
||||
className="hover:bg-muted/30 min-w-0 transition-colors"
|
||||
>
|
||||
<ItemMedia className="text-muted-foreground">
|
||||
<TimerIcon className="size-3.5" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="truncate">Sprint tracker</ItemTitle>
|
||||
</ItemContent>
|
||||
|
||||
<ItemActions className="text-muted-foreground ml-auto">
|
||||
<ChevronRightIcon className="size-4" aria-hidden="true" />
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { EmptyState } from "./components/empty-state"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12">
|
||||
<EmptyState />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import type { BadgeProps } from "@/components/reui/badge"
|
||||
|
||||
export type EventStatus = "Upcoming" | "Cancelled" | "Pending" | "Completed"
|
||||
|
||||
export interface Attendee {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export interface MockEvent {
|
||||
id: string
|
||||
title: string
|
||||
status: EventStatus
|
||||
date: string
|
||||
time: string
|
||||
location: string
|
||||
attendees: Attendee[]
|
||||
}
|
||||
|
||||
export interface ScheduleCalendarProps {
|
||||
selected: Date | undefined
|
||||
onSelect: (date: Date | undefined) => void
|
||||
datesWithEvents?: Set<string>
|
||||
className?: string
|
||||
}
|
||||
|
||||
export type EventsFilter =
|
||||
| "all"
|
||||
| "upcoming"
|
||||
| "completed"
|
||||
| "cancelled"
|
||||
| "pending"
|
||||
|
||||
export interface EventsListProps {
|
||||
selectedDate?: Date
|
||||
filter?: EventsFilter
|
||||
onFilterChange?: (filter: EventsFilter) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface EventCardProps {
|
||||
event: MockEvent
|
||||
}
|
||||
|
||||
// ── Attendee pool ──
|
||||
|
||||
const POOL: Attendee[] = [
|
||||
{
|
||||
id: "u01",
|
||||
name: "Sarah Chen",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u02",
|
||||
name: "Michael Torres",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u03",
|
||||
name: "Emma Wilson",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u04",
|
||||
name: "James Park",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1463453091185-61582044d556?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u05",
|
||||
name: "Olivia Nguyen",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u06",
|
||||
name: "Alex Johnson",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u07",
|
||||
name: "Nina Patel",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1531746020798-e6953c6e8e04?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u08",
|
||||
name: "Ryan Murphy",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u09",
|
||||
name: "Zoe Martinez",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1580489944761-15a19d654956?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u10",
|
||||
name: "Tom Anderson",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u11",
|
||||
name: "Priya Sharma",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
{
|
||||
id: "u12",
|
||||
name: "Lucas Ferreira",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1560250097-0b93528c311a?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
]
|
||||
|
||||
// ── Event template pool ──
|
||||
|
||||
const TEMPLATES: Array<{ title: string; location: string }> = [
|
||||
{ title: "Daily Standup", location: "Slack Huddle" },
|
||||
{ title: "Design System Review", location: "Figma / Remote" },
|
||||
{ title: "Sprint Planning", location: "Conference Room A" },
|
||||
{ title: "API Contract Review", location: "Remote" },
|
||||
{ title: "Investor Briefing", location: "22nd Floor Boardroom" },
|
||||
{ title: "Product Roadmap Review", location: "Conference Room B" },
|
||||
{ title: "UX Research Session", location: "Lab 2" },
|
||||
{ title: "Security Briefing", location: "Remote" },
|
||||
{ title: "Analytics Kick-off", location: "Google Meet" },
|
||||
{ title: "Backend Guild", location: "Meeting Room A" },
|
||||
{ title: "Frontend Perf Deep-dive", location: "Remote" },
|
||||
{ title: "Mobile QA Handoff", location: "Remote" },
|
||||
{ title: "Hiring Interview · Senior FE", location: "Zoom" },
|
||||
{ title: "Q2 OKR Planning", location: "Conference Room A" },
|
||||
{ title: "Tech Talk: Next.js 15", location: "Main Hall" },
|
||||
{ title: "Release Planning v3.0", location: "Zoom" },
|
||||
{ title: "Accessibility Workshop", location: "Lab 1" },
|
||||
{ title: "Partner API Workshop", location: "HQ · Floor 3" },
|
||||
{ title: "Quarterly All-Hands", location: "Main Hall" },
|
||||
{ title: "Customer Feedback Review", location: "Google Meet" },
|
||||
{ title: "Board Meeting", location: "HQ · Floor 3" },
|
||||
{ title: "Brand Identity Refresh", location: "Figma / Remote" },
|
||||
{ title: "Infrastructure Cost Review", location: "Remote" },
|
||||
{ title: "Database Migration Planning", location: "Remote" },
|
||||
{ title: "Figma Handoff · Billing", location: "Figma / Remote" },
|
||||
{ title: "Marketing Campaign Review", location: "Google Meet" },
|
||||
{ title: "Platform Architecture Review", location: "HQ · Floor 3" },
|
||||
{ title: "Payment Gateway Review", location: "Zoom" },
|
||||
{ title: "Email A/B Results 2", location: "Google Meet" },
|
||||
{ title: "Weekly Retro", location: "Slack Huddle" },
|
||||
{ title: "Security Audit Debrief", location: "Remote" },
|
||||
{ title: "Contractor Onboarding", location: "Zoom" },
|
||||
{ title: "Hiring Panel · Design", location: "Zoom" },
|
||||
{ title: "Growth Strategy Workshop", location: "HQ · Floor 2" },
|
||||
{ title: "i18n Sprint Kick-off", location: "Remote" },
|
||||
{ title: "Product Launch Q1", location: "1200 Innovation Way" },
|
||||
{ title: "Partnership Summit", location: "Grand Plaza Hotel" },
|
||||
{ title: "Tech Debt Triage", location: "Remote" },
|
||||
{ title: "1:1 with Engineering Lead", location: "Zoom" },
|
||||
{ title: "Release Sign-off", location: "Zoom" },
|
||||
]
|
||||
|
||||
const TIMES = [
|
||||
"8:00 am",
|
||||
"8:30 am",
|
||||
"9:00 am",
|
||||
"9:30 am",
|
||||
"10:00 am",
|
||||
"10:30 am",
|
||||
"11:00 am",
|
||||
"11:30 am",
|
||||
"12:00 pm",
|
||||
"1:00 pm",
|
||||
"1:30 pm",
|
||||
"2:00 pm",
|
||||
"2:30 pm",
|
||||
"3:00 pm",
|
||||
"3:30 pm",
|
||||
"4:00 pm",
|
||||
"4:30 pm",
|
||||
"5:00 pm",
|
||||
"5:30 pm",
|
||||
]
|
||||
|
||||
// ── Calendar data ──
|
||||
|
||||
export const MONTHS = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
]
|
||||
|
||||
// Fixed demo clock: every "today" bucket derives from this reference date.
|
||||
const DEMO_REFERENCE_DATE_ISO = "2026-06-10"
|
||||
// Noon keeps UTC-based date keys (toISOString) on the same calendar day across timezones.
|
||||
export const DEMO_REFERENCE_DATE = new Date(
|
||||
`${DEMO_REFERENCE_DATE_ISO}T12:00:00`
|
||||
)
|
||||
|
||||
export const CURRENT_YEAR = DEMO_REFERENCE_DATE.getFullYear()
|
||||
export const YEARS = Array.from({ length: 21 }, (_, i) => CURRENT_YEAR - 10 + i)
|
||||
|
||||
// ── Event card data ──
|
||||
|
||||
export const STATUS_VARIANT: Record<EventStatus, BadgeProps["variant"]> = {
|
||||
Upcoming: "info-light",
|
||||
Cancelled: "destructive-light",
|
||||
Pending: "warning-light",
|
||||
Completed: "success-light",
|
||||
}
|
||||
|
||||
export const MAX_VISIBLE = 3
|
||||
|
||||
export function parseDateParts(dateStr: string) {
|
||||
const d = new Date(dateStr + "T12:00:00")
|
||||
return {
|
||||
day: d.getDate().toString().padStart(2, "0"),
|
||||
monthShort: d.toLocaleString("en-US", { month: "short" }),
|
||||
}
|
||||
}
|
||||
|
||||
export function initials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
// ── Events list data ──
|
||||
|
||||
export const FILTER_ITEMS = [
|
||||
{ label: "All events", value: "all" },
|
||||
{ label: "Upcoming", value: "upcoming" },
|
||||
{ label: "Completed", value: "completed" },
|
||||
{ label: "Cancelled", value: "cancelled" },
|
||||
{ label: "Pending", value: "pending" },
|
||||
]
|
||||
|
||||
export function toDateKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function matchesFilter(event: MockEvent, filter: EventsFilter): boolean {
|
||||
if (filter === "all") return true
|
||||
return event.status.toLowerCase() === filter
|
||||
}
|
||||
|
||||
// ── Mock event generation ──
|
||||
|
||||
function hash(n: number): number {
|
||||
let x = n
|
||||
x = ((x >> 16) ^ x) * 0x45d9f3b
|
||||
x = ((x >> 16) ^ x) * 0x45d9f3b
|
||||
x = (x >> 16) ^ x
|
||||
return Math.abs(x)
|
||||
}
|
||||
|
||||
function toDateStr(d: Date): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function buildMockEvents(ref: Date = DEMO_REFERENCE_DATE): MockEvent[] {
|
||||
const Y = ref.getFullYear()
|
||||
const M = ref.getMonth()
|
||||
const todayNum = ref.getDate()
|
||||
const daysInMonth = new Date(Y, M + 1, 0).getDate()
|
||||
|
||||
// Current week's Monday day-of-month (may be <= 0 → last month, skip)
|
||||
const dow = ref.getDay() // 0=Sun … 6=Sat
|
||||
const mondayNum = todayNum - (dow === 0 ? 6 : dow - 1)
|
||||
|
||||
const seed = Y * 100 + M // stable per month
|
||||
|
||||
const events: MockEvent[] = []
|
||||
|
||||
for (let dayNum = 1; dayNum <= daysInMonth; dayNum++) {
|
||||
const isToday = dayNum === todayNum
|
||||
const isMonday = dayNum === mondayNum
|
||||
const dayHash = hash(seed + dayNum)
|
||||
|
||||
// Decide whether this day gets events
|
||||
const guaranteed = isToday || isMonday
|
||||
const randomlyChosen = dayHash % 10 < 4 // ~40 %
|
||||
if (!guaranteed && !randomlyChosen) continue
|
||||
|
||||
// How many events (4-9)
|
||||
const baseCount = isToday ? 5 : 4
|
||||
const count = baseCount + (dayHash % (isToday ? 3 : 6))
|
||||
|
||||
const date = toDateStr(new Date(Y, M, dayNum, 12, 0, 0, 0))
|
||||
|
||||
// Status for each slot
|
||||
const defaultStatus: EventStatus =
|
||||
dayNum < todayNum ? "Completed" : "Upcoming"
|
||||
|
||||
// Spread events through the day using distinct templates and times
|
||||
for (let i = 0; i < count; i++) {
|
||||
const slot = hash(seed + dayNum * 37 + i * 13)
|
||||
const template = TEMPLATES[(dayHash + i * 7) % TEMPLATES.length]
|
||||
const time = TIMES[(slot + i * 3) % TIMES.length]
|
||||
|
||||
// Occasionally vary status for non-today days
|
||||
let status: EventStatus = defaultStatus
|
||||
if (!isToday) {
|
||||
const r = (slot * 31 + i * 17) % 10
|
||||
if (dayNum < todayNum) {
|
||||
status = r < 2 ? "Cancelled" : "Completed"
|
||||
} else {
|
||||
status = r < 3 ? "Pending" : "Upcoming"
|
||||
}
|
||||
}
|
||||
|
||||
// Pick 2-6 attendees deterministically
|
||||
const attendeeCount = 2 + (slot % 5)
|
||||
const attendees = Array.from(
|
||||
{ length: attendeeCount },
|
||||
(_, k) => POOL[(slot + k * 3) % POOL.length]
|
||||
)
|
||||
// Deduplicate
|
||||
const seen = new Set<string>()
|
||||
const uniqueAttendees = attendees.filter((a) => {
|
||||
if (seen.has(a.id)) return false
|
||||
seen.add(a.id)
|
||||
return true
|
||||
})
|
||||
|
||||
events.push({
|
||||
id: `${Y}-${M}-${dayNum}-${i}`,
|
||||
title: template.title,
|
||||
status,
|
||||
date,
|
||||
time,
|
||||
location: template.location,
|
||||
attendees: uniqueAttendees,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
export const MOCK_EVENTS: MockEvent[] = buildMockEvents()
|
||||
|
||||
export function getDatesWithEvents(events: MockEvent[]): Set<string> {
|
||||
return new Set(events.map((e) => e.date))
|
||||
}
|
||||
|
||||
export function getEventsForDate(
|
||||
events: MockEvent[],
|
||||
date: Date | undefined,
|
||||
filter: "all" | "upcoming" | "completed" | "cancelled" | "pending"
|
||||
): MockEvent[] {
|
||||
if (!date) return events
|
||||
const dateStr = toDateStr(date)
|
||||
let list = events.filter((e) => e.date === dateStr)
|
||||
if (filter === "upcoming") list = list.filter((e) => e.status === "Upcoming")
|
||||
else if (filter === "completed")
|
||||
list = list.filter((e) => e.status === "Completed")
|
||||
else if (filter === "cancelled")
|
||||
list = list.filter((e) => e.status === "Cancelled")
|
||||
else if (filter === "pending")
|
||||
list = list.filter((e) => e.status === "Pending")
|
||||
return list
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Item } from "@evobgp/ui/components/item"
|
||||
import {
|
||||
initials,
|
||||
MAX_VISIBLE,
|
||||
parseDateParts,
|
||||
STATUS_VARIANT,
|
||||
type Attendee,
|
||||
type EventCardProps,
|
||||
} from "./data"
|
||||
import { ClockIcon, MapPinIcon } from "lucide-react"
|
||||
|
||||
function AttendeeGroup({ attendees }: { attendees: Attendee[] }) {
|
||||
if (attendees.length === 0) return null
|
||||
const visible = attendees.slice(0, MAX_VISIBLE)
|
||||
const overflow = attendees.length - MAX_VISIBLE
|
||||
|
||||
return (
|
||||
<AvatarGroup className="-space-x-1.5">
|
||||
{visible.map((a) => (
|
||||
<Avatar key={a.id} className="size-4!" title={a.name}>
|
||||
<AvatarImage src={a.avatar} alt={a.name} />
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{initials(a.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<AvatarGroupCount className="size-4! text-[10px]">
|
||||
+{overflow}
|
||||
</AvatarGroupCount>
|
||||
)}
|
||||
</AvatarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export function EventCard({ event }: EventCardProps) {
|
||||
const { day, monthShort } = parseDateParts(event.date)
|
||||
const variant = STATUS_VARIANT[event.status]
|
||||
|
||||
return (
|
||||
<Item variant="outline" size="xs" className="flex items-start gap-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<a
|
||||
href="#"
|
||||
className="text-foreground hover:text-primary text-sm leading-tight font-medium"
|
||||
>
|
||||
{event.title}
|
||||
</a>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<Badge variant={variant} size="sm">
|
||||
{event.status}
|
||||
</Badge>
|
||||
|
||||
<AttendeeGroup attendees={event.attendees} />
|
||||
|
||||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<ClockIcon className="size-3 shrink-0" aria-hidden="true" />
|
||||
{monthShort} {day} · {event.time}
|
||||
</span>
|
||||
|
||||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<MapPinIcon className="size-3 shrink-0" aria-hidden="true" />
|
||||
{event.location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import {
|
||||
FILTER_ITEMS,
|
||||
matchesFilter,
|
||||
MOCK_EVENTS,
|
||||
toDateKey,
|
||||
type EventsFilter,
|
||||
type EventsListProps,
|
||||
type MockEvent,
|
||||
} from "./data"
|
||||
import { EventCard } from "./event-card"
|
||||
import { PlusIcon, CalendarIcon } from "lucide-react"
|
||||
|
||||
export function EventsList({
|
||||
selectedDate,
|
||||
filter = "all",
|
||||
onFilterChange,
|
||||
}: EventsListProps) {
|
||||
const dateKey = selectedDate ? toDateKey(selectedDate) : null
|
||||
|
||||
const events = MOCK_EVENTS.filter((event) => {
|
||||
if (dateKey && event.date !== dateKey) return false
|
||||
return matchesFilter(event, filter)
|
||||
})
|
||||
|
||||
const headingLabel = selectedDate
|
||||
? selectedDate.toLocaleString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
: "All Events"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
{/* Heading */}
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-foreground text-sm font-semibold">
|
||||
{headingLabel}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{events.length > 0
|
||||
? `${events.length} event${events.length !== 1 ? "s" : ""}`
|
||||
: "No events found"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex shrink-0 items-center gap-2 sm:w-auto">
|
||||
<Select
|
||||
value={filter}
|
||||
onValueChange={(v) => onFilterChange?.(v as EventsFilter)}
|
||||
items={FILTER_ITEMS}
|
||||
>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue placeholder="All events" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button size="sm">
|
||||
<PlusIcon className="size-4" aria-hidden="true" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
<div className="min-h-[320px]">
|
||||
<div className="relative flex max-h-full">
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"-mr-3.5 max-h-[320px] grow pr-3.5",
|
||||
"**:data-[slot=scroll-area-thumb]:bg-foreground/15 **:data-[slot=scroll-area-thumb]:rounded-full",
|
||||
"**:data-[slot=scroll-area-viewport]:mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))]",
|
||||
"**:data-[slot=scroll-area-viewport]:mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))]",
|
||||
"**:data-[slot=scroll-area-viewport]:mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))]",
|
||||
"**:data-[slot=scroll-area-viewport]:mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))]",
|
||||
"**:data-[slot=scroll-area-viewport]:[--fade-size:1.5rem]"
|
||||
)}
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<Item
|
||||
render={<span />}
|
||||
className="bg-muted flex size-10 items-center justify-center rounded-full p-0"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<CalendarIcon className="text-muted-foreground size-5" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="space-y-1">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
No events found
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{dateKey
|
||||
? "Nothing scheduled for this day."
|
||||
: "Try changing the filter."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{events.map((event: MockEvent) => (
|
||||
<li key={event.id}>
|
||||
<EventCard event={event} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState } from "react"
|
||||
import { DayButton } from "react-day-picker"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Calendar, CalendarDayButton } from "@evobgp/ui/components/calendar"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import {
|
||||
DEMO_REFERENCE_DATE,
|
||||
MONTHS,
|
||||
YEARS,
|
||||
type ScheduleCalendarProps,
|
||||
} from "./data"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
// Weekday header highlight tracks the fixed demo clock, not the live date.
|
||||
const TODAY_WEEKDAY_NAME = DEMO_REFERENCE_DATE.toLocaleString("en-US", {
|
||||
weekday: "short",
|
||||
}).toUpperCase()
|
||||
|
||||
export function ScheduleCalendar({
|
||||
selected,
|
||||
onSelect,
|
||||
datesWithEvents = new Set(),
|
||||
}: ScheduleCalendarProps) {
|
||||
const [month, setMonth] = useState<Date>(selected ?? DEMO_REFERENCE_DATE)
|
||||
|
||||
const stepMonth = (delta: number) =>
|
||||
setMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + delta, 1))
|
||||
|
||||
const handleMonthSelect = (value: string) => {
|
||||
const i = MONTHS.indexOf(value)
|
||||
if (i >= 0) setMonth(new Date(month.getFullYear(), i, 1))
|
||||
}
|
||||
|
||||
const handleYearSelect = (value: string) => {
|
||||
const y = parseInt(value, 10)
|
||||
if (!isNaN(y)) setMonth(new Date(y, month.getMonth(), 1))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 select-none">
|
||||
{/* ── Custom header ── */}
|
||||
<div className="flex w-full grow items-center justify-between gap-1">
|
||||
{/* Prev month */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 shrink-0 p-0"
|
||||
onClick={() => stepMonth(-1)}
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<ChevronLeftIcon className="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
{/* Month select */}
|
||||
<Select
|
||||
value={MONTHS[month.getMonth()]}
|
||||
onValueChange={(value) => handleMonthSelect(value ?? "")}
|
||||
>
|
||||
<SelectTrigger size="sm" className="min-w-0 flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Year select */}
|
||||
<Select
|
||||
value={String(month.getFullYear())}
|
||||
onValueChange={(value) => handleYearSelect(value ?? "")}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-22 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{YEARS.map((y) => (
|
||||
<SelectItem key={y} value={String(y)}>
|
||||
{y}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Next month */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 shrink-0 p-0"
|
||||
onClick={() => stepMonth(1)}
|
||||
aria-label="Next month"
|
||||
>
|
||||
<ChevronRightIcon className="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Calendar ── */}
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
showOutsideDays
|
||||
hideNavigation
|
||||
className="w-full bg-transparent p-0 md:[--cell-size:--spacing(11)]"
|
||||
formatters={{
|
||||
formatWeekdayName: (date) =>
|
||||
date.toLocaleString("en-US", { weekday: "short" }).toUpperCase(),
|
||||
}}
|
||||
classNames={{
|
||||
month_caption: "hidden",
|
||||
nav: "hidden",
|
||||
weekdays: "flex gap-1",
|
||||
weekday:
|
||||
"flex-1 flex items-center justify-center h-14 text-[0.65rem] font-medium text-muted-foreground",
|
||||
week: "flex gap-1 mt-1",
|
||||
day: "flex-1 aspect-square p-0",
|
||||
day_button: cn(
|
||||
"bg-muted/50 hover:bg-muted",
|
||||
" rounded-md ",
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[selected-single=true]:hover:bg-primary data-[selected-single=true]:hover:text-primary-foreground!"
|
||||
),
|
||||
outside: "opacity-60",
|
||||
disabled: "opacity-60",
|
||||
today: cn("bg-accent text-foreground", " rounded-md "),
|
||||
}}
|
||||
components={{
|
||||
Weekday: ({
|
||||
children,
|
||||
className: cls,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"th">) => {
|
||||
const isToday = children === TODAY_WEEKDAY_NAME
|
||||
return (
|
||||
<th
|
||||
scope="col"
|
||||
className={cn(
|
||||
"flex h-6! flex-1 items-center justify-center rounded-md text-xs font-medium",
|
||||
isToday
|
||||
? "bg-accent text-foreground!"
|
||||
: "text-muted-foreground",
|
||||
cls
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
},
|
||||
|
||||
DayButton: ({
|
||||
children,
|
||||
modifiers,
|
||||
day,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) => {
|
||||
const dateKey = day.date.toISOString().slice(0, 10)
|
||||
const hasEvents = !modifiers.outside && datesWithEvents.has(dateKey)
|
||||
|
||||
return (
|
||||
<CalendarDayButton day={day} modifiers={modifiers} {...props}>
|
||||
{hasEvents ? (
|
||||
<span
|
||||
className="bg-primary text-primary-foreground in-data-[selected-single=true]:bg-primary-foreground! size-1 rounded-full"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span className="size-1" aria-hidden />
|
||||
)}
|
||||
{children}
|
||||
</CalendarDayButton>
|
||||
)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import {
|
||||
DEMO_REFERENCE_DATE,
|
||||
EventsFilter,
|
||||
getDatesWithEvents,
|
||||
MOCK_EVENTS,
|
||||
} from "./data"
|
||||
import { EventsList } from "./events-list"
|
||||
import { ScheduleCalendar } from "./schedule-calendar"
|
||||
|
||||
export function Schedule() {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
|
||||
DEMO_REFERENCE_DATE
|
||||
)
|
||||
const [filter, setFilter] = useState<EventsFilter>("all")
|
||||
const datesWithEvents = getDatesWithEvents(MOCK_EVENTS)
|
||||
|
||||
return (
|
||||
<Frame className="w-full max-w-4xl">
|
||||
<FramePanel className="flex flex-col p-0! lg:flex-row">
|
||||
{/* Left - calendar */}
|
||||
<div className="lg:border-border shrink-0 p-5 pt-5.5 lg:w-[370px] lg:border-r">
|
||||
<ScheduleCalendar
|
||||
selected={selectedDate}
|
||||
onSelect={setSelectedDate}
|
||||
datesWithEvents={datesWithEvents}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right - events */}
|
||||
<div className="flex-1 p-5">
|
||||
<EventsList
|
||||
selectedDate={selectedDate}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Schedule } from "./components/schedule"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-10 md:p-20">
|
||||
<Schedule />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { DataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
import { TooltipProvider } from "@evobgp/ui/components/tooltip"
|
||||
import { createColumns } from "./columns"
|
||||
import { API_INTEGRATIONS } from "./data"
|
||||
import { BriefcaseBusinessIcon } from "lucide-react"
|
||||
|
||||
// ── Main component ──
|
||||
|
||||
export function ApiIntegrationsGrid() {
|
||||
const [rows, setRows] = useState(API_INTEGRATIONS)
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
|
||||
const pauseAll = rows.every((row) => !row.enabled)
|
||||
|
||||
const handleToggle = (id: string, enabled: boolean) => {
|
||||
setRows((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, enabled } : row))
|
||||
)
|
||||
}
|
||||
|
||||
const handlePauseAllChange = (checked: boolean) => {
|
||||
const nextEnabled = !checked
|
||||
setRows((current) =>
|
||||
current.map((row) => ({ ...row, enabled: nextEnabled }))
|
||||
)
|
||||
toast.message(
|
||||
checked ? "All integrations paused" : "Integrations resumed",
|
||||
{
|
||||
description: checked
|
||||
? "API traffic is temporarily disabled for every listed integration."
|
||||
: "The listed integrations can receive traffic again.",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const columns = useMemo(() => createColumns({ onToggle: handleToggle }), [])
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={200}>
|
||||
{/* Table */}
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
tableLayout={{
|
||||
columnsResizable: true,
|
||||
columnsVisibility: true,
|
||||
headerSticky: true,
|
||||
dense: true,
|
||||
}}
|
||||
>
|
||||
<Frame className="w-full max-w-6xl">
|
||||
<FrameHeader className="flex-row items-center justify-between px-2! py-2.5!">
|
||||
<div className="space-y-px">
|
||||
<FrameTitle>API Integrations</FrameTitle>
|
||||
<FrameDescription>
|
||||
Oversee endpoints, key rotation, and traffic availability.
|
||||
</FrameDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground hidden text-sm md:inline">
|
||||
Pause all
|
||||
</span>
|
||||
<Switch
|
||||
checked={pauseAll}
|
||||
onCheckedChange={handlePauseAllChange}
|
||||
aria-label="Pause all integrations"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
toast.info("Positions", {
|
||||
description:
|
||||
"Review which integration seats are currently assigned across the workspace.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<BriefcaseBusinessIcon aria-hidden="true" />
|
||||
<span className="hidden md:inline">Positions</span>
|
||||
</Button>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0!">
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="px-2! py-2.5!">
|
||||
<DataGridPagination />
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { memo } from "react"
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import type { ColumnDef, Row } from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
import type { ApiIntegration } from "./data"
|
||||
import { CheckIcon, CopyIcon, SquarePenIcon } from "lucide-react"
|
||||
|
||||
// ── Formatters ──
|
||||
|
||||
function formatCalls(value: number) {
|
||||
return new Intl.NumberFormat("en-US").format(value)
|
||||
}
|
||||
|
||||
// ── Cell components ──
|
||||
|
||||
const StatusSwitch = memo(function StatusSwitch({
|
||||
row,
|
||||
onToggle,
|
||||
}: {
|
||||
row: Row<ApiIntegration>
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Switch
|
||||
checked={row.original.enabled}
|
||||
onCheckedChange={(checked) => onToggle(row.original.id, checked)}
|
||||
aria-label={`Toggle ${row.original.name}`}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const ApiKeyCell = memo(function ApiKeyCell({ value }: { value: string }) {
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard({
|
||||
onCopy: () => {
|
||||
toast.success("API key copied", { description: value })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={isCopied ? "API key copied" : "Copy API key"}
|
||||
onClick={() => copyToClipboard(value)}
|
||||
title={value}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="max-w-full min-w-0 gap-1.5"
|
||||
>
|
||||
<span className="min-w-0 truncate font-mono tabular-nums">{value}</span>
|
||||
{isCopied ? (
|
||||
<CheckIcon className="text-green-500" aria-hidden="true" />
|
||||
) : (
|
||||
<CopyIcon aria-hidden="true" />
|
||||
)}
|
||||
</Badge>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
|
||||
const ActionsCell = memo(function ActionsCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<ApiIntegration>
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${row.original.name}`}
|
||||
onClick={() =>
|
||||
toast.message("Edit integration", {
|
||||
description: `Open ${row.original.name} settings in your configuration flow.`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SquarePenIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
|
||||
// ── Column definitions ──
|
||||
|
||||
export function createColumns({
|
||||
onToggle,
|
||||
}: {
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
}): ColumnDef<ApiIntegration>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "select",
|
||||
id: "select",
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
size: 38,
|
||||
meta: {
|
||||
headerClassName: "ps-4!",
|
||||
cellClassName: "ps-4!",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Integration" column={column} visibility />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Item className="flex size-9 shrink-0 items-center justify-center border-0 p-0 [&_svg]:size-5">
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{row.original.logo}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate text-sm font-medium">
|
||||
{row.original.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{row.original.provider} · {row.original.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size: 450,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
headerTitle: "Integration",
|
||||
skeleton: (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Skeleton className="size-9 shrink-0 rounded" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<Skeleton className="h-3.5 w-28" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "apiKey",
|
||||
id: "apiKey",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="API Key" column={column} visibility />
|
||||
),
|
||||
cell: ({ row }) => <ApiKeyCell value={row.original.apiKey} />,
|
||||
size: 300,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
headerTitle: "API Key",
|
||||
skeleton: <Skeleton className="h-5 w-44 rounded-md" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "dailyCalls",
|
||||
id: "dailyCalls",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Daily Calls" column={column} visibility />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm tabular-nums">
|
||||
{formatCalls(row.original.dailyCalls)}
|
||||
</span>
|
||||
),
|
||||
size: 140,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
headerTitle: "Daily Calls",
|
||||
skeleton: <Skeleton className="h-4 w-16" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
id: "enabled",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Status" column={column} visibility />
|
||||
),
|
||||
cell: ({ row }) => <StatusSwitch row={row} onToggle={onToggle} />,
|
||||
size: 110,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
meta: {
|
||||
headerTitle: "Status",
|
||||
skeleton: <Skeleton className="h-5 w-9 rounded-full" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => <ActionsCell row={row} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
size: 60,
|
||||
meta: {
|
||||
skeleton: <Skeleton className="size-7 rounded" />,
|
||||
headerClassName: "",
|
||||
cellClassName: "",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { Convex } from "@evobgp/ui/components/svgs/convex"
|
||||
import { Discord } from "@evobgp/ui/components/svgs/discord"
|
||||
import { Gemini } from "@evobgp/ui/components/svgs/gemini"
|
||||
import { Loom } from "@evobgp/ui/components/svgs/loom"
|
||||
import { Mintlify } from "@evobgp/ui/components/svgs/mintlify"
|
||||
import { N8n } from "@evobgp/ui/components/svgs/n8n"
|
||||
import { Neon } from "@evobgp/ui/components/svgs/neon"
|
||||
import { Slack } from "@evobgp/ui/components/svgs/slack"
|
||||
import { Stripe } from "@evobgp/ui/components/svgs/stripe"
|
||||
import { Supabase } from "@evobgp/ui/components/svgs/supabase"
|
||||
import { Zoom } from "@evobgp/ui/components/svgs/zoom"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface ApiIntegration {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
description: string
|
||||
logo: ReactNode
|
||||
apiKey: string
|
||||
dailyCalls: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
const logoClassName = "size-5"
|
||||
|
||||
export const API_INTEGRATIONS: ApiIntegration[] = [
|
||||
{
|
||||
id: "auth-relay",
|
||||
name: "User Auth System",
|
||||
provider: "Supabase",
|
||||
description: "Session webhooks and identity sync",
|
||||
logo: <Supabase className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "f6g7Z8h9R0TfUaSdTf",
|
||||
dailyCalls: 15000,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "social-mgr",
|
||||
name: "Social Media Manager",
|
||||
provider: "Slack",
|
||||
description: "Content scheduling and post distribution",
|
||||
logo: <Slack className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "s1t2u3v4w5x6y7z8a9",
|
||||
dailyCalls: 13000,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "sms-notify",
|
||||
name: "SMS Notification Service",
|
||||
provider: "n8n",
|
||||
description: "Transactional message delivery pipeline",
|
||||
logo: <N8n className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "t2u3v4w5x6y7z8a9b1",
|
||||
dailyCalls: 19000,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "ship-coord",
|
||||
name: "Shipping Coordinator",
|
||||
provider: "Convex",
|
||||
description: "Carrier rate lookups and label generation",
|
||||
logo: <Convex className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "t6u7v8w9x0CvBnNlSc",
|
||||
dailyCalls: 14000,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "seo-scan",
|
||||
name: "SEO Analyzer",
|
||||
provider: "Mintlify",
|
||||
description: "Site audit scores and keyword tracking",
|
||||
logo: <Mintlify className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "b1c2d3e4f5g6h7i8j9",
|
||||
dailyCalls: 6000,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "sales-fc",
|
||||
name: "Sales Forecasting",
|
||||
provider: "Stripe",
|
||||
description: "Revenue trend projections and pipeline modeling",
|
||||
logo: <Stripe className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "z8a9b1c2d3e4f5g6h7",
|
||||
dailyCalls: 11500,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "quick-pay",
|
||||
name: "Quick Pay Service",
|
||||
provider: "Stripe",
|
||||
description: "One-tap checkout and express payment links",
|
||||
logo: <Stripe className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "a1b2Xc3dY4ZxQvPlQp",
|
||||
dailyCalls: 10000,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "proj-mgmt",
|
||||
name: "Project Management",
|
||||
provider: "Loom",
|
||||
description: "Sprint planning and milestone handoff recordings",
|
||||
logo: <Loom className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "v4w5x6y7z8a9b1c2d3",
|
||||
dailyCalls: 14500,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "pay-gate",
|
||||
name: "Payment Gateway",
|
||||
provider: "Stripe",
|
||||
description: "Card processing and dispute lifecycle events",
|
||||
logo: <Stripe className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "1p2q3r4s5DfGhPgPy",
|
||||
dailyCalls: 25000,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "order-track",
|
||||
name: "Order Tracking Sys",
|
||||
provider: "Neon",
|
||||
description: "Shipment status queries and delivery confirmations",
|
||||
logo: <Neon className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "e1E2gH3hB4iYtUvOtS",
|
||||
dailyCalls: 9500,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "ops-notifier",
|
||||
name: "Ops Notifier",
|
||||
provider: "Slack",
|
||||
description: "High-priority workspace alerts",
|
||||
logo: <Slack className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "slk_live_p2d7n4w8v5q1m6r3",
|
||||
dailyCalls: 9600,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "workflow-bridge",
|
||||
name: "Workflow Bridge",
|
||||
provider: "n8n",
|
||||
description: "Automation run intake and callbacks",
|
||||
logo: <N8n className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "n8n_live_v7x2m5q9a4c1p8d6",
|
||||
dailyCalls: 13750,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "community-sync",
|
||||
name: "Community Sync",
|
||||
provider: "Discord",
|
||||
description: "Community reports and moderation escalations",
|
||||
logo: <Discord className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "dsc_live_m3q9v2k7r5n1x8c4",
|
||||
dailyCalls: 6400,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "review-clips",
|
||||
name: "Review Clips",
|
||||
provider: "Loom",
|
||||
description: "Async review uploads and callback events",
|
||||
logo: <Loom className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "lom_live_r8t4m1c6p9v2x5q7",
|
||||
dailyCalls: 7100,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "meeting-webhooks",
|
||||
name: "Meeting Webhooks",
|
||||
provider: "Zoom",
|
||||
description: "Recording ready and host events",
|
||||
logo: <Zoom className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "zom_live_q5n8r2m4v7c1p9d3",
|
||||
dailyCalls: 11300,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "edge-cache-sync",
|
||||
name: "Edge Cache Sync",
|
||||
provider: "Convex",
|
||||
description: "State snapshots for frontend refreshes",
|
||||
logo: <Convex className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "cvx_live_t1p6m9q3r7v2x4c8",
|
||||
dailyCalls: 15400,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "support-routing",
|
||||
name: "Support Routing",
|
||||
provider: "Slack",
|
||||
description: "Escalation queue dispatch automation",
|
||||
logo: <Slack className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "slk_live_x9c4m2p7v1q8r5n6",
|
||||
dailyCalls: 8900,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "revenue-monitor",
|
||||
name: "Revenue Monitor",
|
||||
provider: "Stripe",
|
||||
description: "Charge anomalies and recovery alerts",
|
||||
logo: <Stripe className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "stp_live_c6v2m8q1r4n7p5x9",
|
||||
dailyCalls: 12850,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "member-audit",
|
||||
name: "Member Audit",
|
||||
provider: "Supabase",
|
||||
description: "Role changes and access review logs",
|
||||
logo: <Supabase className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "sbp_live_n4r7m1q8x5c2v9p6",
|
||||
dailyCalls: 5800,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "retention-flows",
|
||||
name: "Retention Flows",
|
||||
provider: "n8n",
|
||||
description: "Churn prevention automations and retries",
|
||||
logo: <N8n className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "n8n_live_m2v5q8p1c7r4x9d6",
|
||||
dailyCalls: 14600,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "content-gen",
|
||||
name: "Content Generator",
|
||||
provider: "Gemini",
|
||||
description: "Draft generation and content summarization",
|
||||
logo: <Gemini className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "gem_live_k4r9m2q7v1p8x3n5",
|
||||
dailyCalls: 8200,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "db-replication",
|
||||
name: "DB Replication",
|
||||
provider: "Neon",
|
||||
description: "Cross-region read replica synchronization",
|
||||
logo: <Neon className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "neo_live_p7x3m1q9v4r2n8c6",
|
||||
dailyCalls: 22100,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "docs-webhook",
|
||||
name: "Docs Webhook",
|
||||
provider: "Mintlify",
|
||||
description: "Documentation deploy and page-view events",
|
||||
logo: <Mintlify className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "mnt_live_v2q8m5r1p9x4n7c3",
|
||||
dailyCalls: 4300,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "incident-bot",
|
||||
name: "Incident Bot",
|
||||
provider: "Discord",
|
||||
description: "Automated incident threads and status page sync",
|
||||
logo: <Discord className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "dsc_live_r6n1m4q9v7p2x8c5",
|
||||
dailyCalls: 3200,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "video-analytics",
|
||||
name: "Video Analytics",
|
||||
provider: "Zoom",
|
||||
description: "Participation heatmaps and engagement scoring",
|
||||
logo: <Zoom className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "zom_live_m8v3q1r5p9x2n4c7",
|
||||
dailyCalls: 5600,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "signup-funnel",
|
||||
name: "Signup Funnel",
|
||||
provider: "Supabase",
|
||||
description: "Registration step tracking and drop-off alerts",
|
||||
logo: <Supabase className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "sbp_live_q3r7m9v2p1x5n8c4",
|
||||
dailyCalls: 17800,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "async-reviews",
|
||||
name: "Async Reviews",
|
||||
provider: "Loom",
|
||||
description: "Code review recordings and feedback collection",
|
||||
logo: <Loom className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "lom_live_x5n2m8q4v1r9p7c3",
|
||||
dailyCalls: 2900,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "pipeline-monitor",
|
||||
name: "Pipeline Monitor",
|
||||
provider: "n8n",
|
||||
description: "CI/CD build status and deployment tracking",
|
||||
logo: <N8n className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "n8n_live_r4v7m2q9p1x8n5c3",
|
||||
dailyCalls: 16200,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "real-time-sync",
|
||||
name: "Real-time Sync",
|
||||
provider: "Convex",
|
||||
description: "Live data propagation for collaborative editors",
|
||||
logo: <Convex className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "cvx_live_m9q2r5v1p4x7n3c8",
|
||||
dailyCalls: 19400,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: "invoice-relay",
|
||||
name: "Invoice Relay",
|
||||
provider: "Stripe",
|
||||
description: "Automated invoice generation and delivery tracking",
|
||||
logo: <Stripe className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "stp_live_v3m8q1r6p9x2n4c7",
|
||||
dailyCalls: 7800,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "ai-summarizer",
|
||||
name: "AI Summarizer",
|
||||
provider: "Gemini",
|
||||
description: "Meeting transcript distillation and action extraction",
|
||||
logo: <Gemini className={logoClassName} aria-hidden="true" />,
|
||||
apiKey: "gem_live_r1m5q8v3p9x7n2c4",
|
||||
dailyCalls: 4100,
|
||||
enabled: false,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiIntegrationsGrid } from "./components/api-integrations-grid"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12">
|
||||
<ApiIntegrationsGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { FieldLegend, FieldSet } from "@evobgp/ui/components/field"
|
||||
import { ACCENT_COLORS } from "./data"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
// ── Color Picker ──
|
||||
|
||||
export function ColorPicker() {
|
||||
const [selected, setSelected] = useState(ACCENT_COLORS[0].value)
|
||||
|
||||
return (
|
||||
<FieldSet className="gap-0">
|
||||
<FieldLegend className="sr-only">Accent color</FieldLegend>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{ACCENT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
aria-label={color.name}
|
||||
onClick={() => setSelected(color.value)}
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full transition-shadow",
|
||||
selected === color.value &&
|
||||
"ring-ring ring-offset-background ring-2 ring-offset-2"
|
||||
)}
|
||||
style={{ backgroundColor: color.value }}
|
||||
>
|
||||
{selected === color.value ? (
|
||||
<CheckIcon className="size-3 text-white" aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type SelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type Preference = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
defaultChecked: boolean
|
||||
}
|
||||
|
||||
export type AccentColorOption = {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type SettingBadgeVariant =
|
||||
| "primary-light"
|
||||
| "destructive-light"
|
||||
| "info-light"
|
||||
|
||||
export type SettingFieldProps = {
|
||||
title: string
|
||||
description: string
|
||||
badge?: { label: string; variant: SettingBadgeVariant }
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
}
|
||||
|
||||
export type DateFormatOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const DAYS: SelectOption[] = [
|
||||
{ value: "monday", label: "Monday" },
|
||||
{ value: "tuesday", label: "Tuesday" },
|
||||
{ value: "wednesday", label: "Wednesday" },
|
||||
{ value: "thursday", label: "Thursday" },
|
||||
{ value: "friday", label: "Friday" },
|
||||
{ value: "saturday", label: "Saturday" },
|
||||
{ value: "sunday", label: "Sunday" },
|
||||
]
|
||||
|
||||
export const CURRENCIES: SelectOption[] = [
|
||||
{ value: "usd", label: "USD · US Dollar" },
|
||||
{ value: "eur", label: "EUR · Euro" },
|
||||
{ value: "gbp", label: "GBP · British Pound" },
|
||||
{ value: "jpy", label: "JPY · Japanese Yen" },
|
||||
]
|
||||
|
||||
export const REGIONS: SelectOption[] = [
|
||||
{ value: "us", label: "United States" },
|
||||
{ value: "eu", label: "European Union" },
|
||||
{ value: "uk", label: "United Kingdom" },
|
||||
{ value: "jp", label: "Japan" },
|
||||
{ value: "au", label: "Australia" },
|
||||
]
|
||||
|
||||
export const ACCENT_COLORS: AccentColorOption[] = [
|
||||
{ name: "Slate", value: "#64748b" },
|
||||
{ name: "Rose", value: "#f43f5e" },
|
||||
{ name: "Orange", value: "#f97316" },
|
||||
{ name: "Violet", value: "#8b5cf6" },
|
||||
{ name: "Emerald", value: "#10b981" },
|
||||
{ name: "Sky", value: "#0ea5e9" },
|
||||
]
|
||||
|
||||
export const PREFERENCES: Preference[] = [
|
||||
{
|
||||
id: "keyboard-shortcuts",
|
||||
label: "Enable keyboard shortcuts.",
|
||||
description: "Use shortcuts to speed up your workflow.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "spellcheck",
|
||||
label: "Auto spell-check.",
|
||||
description: "Highlight spelling errors in text fields.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "compact-mode",
|
||||
label: "Compact Mode.",
|
||||
description: "Reduce spacing for a denser interface layout.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: "animations",
|
||||
label: "Reduce animations.",
|
||||
description: "Minimize motion effects throughout the UI.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const DATE_FORMAT_OPTIONS: DateFormatOption[] = [
|
||||
{ value: "mdy", label: "MM/DD/YYYY" },
|
||||
{ value: "dmy", label: "DD/MM/YYYY" },
|
||||
{ value: "ymd", label: "YYYY/MM/DD" },
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
|
||||
import { PREFERENCES } from "./data"
|
||||
|
||||
// ── Editor Preferences ──
|
||||
|
||||
export function EditorPreferences() {
|
||||
const [values, setValues] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(PREFERENCES.map((p) => [p.id, p.defaultChecked]))
|
||||
)
|
||||
|
||||
return (
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Editor preferences</FieldLegend>
|
||||
{/* Description */}
|
||||
<FieldDescription className="sr-only">
|
||||
Configure editing behavior and interface density.
|
||||
</FieldDescription>
|
||||
|
||||
{/* List */}
|
||||
<FieldGroup className="gap-3">
|
||||
{PREFERENCES.map((pref) => (
|
||||
<Field key={pref.id} orientation="horizontal" className="gap-3">
|
||||
<Switch
|
||||
id={`settings-3-${pref.id}`}
|
||||
checked={values[pref.id]}
|
||||
onCheckedChange={(value) =>
|
||||
setValues((prev) => ({ ...prev, [pref.id]: value }))
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
|
||||
<FieldContent className="gap-0.5">
|
||||
<FieldLabel htmlFor={`settings-3-${pref.id}`}>
|
||||
{pref.label}
|
||||
</FieldLabel>
|
||||
<FieldDescription className="text-xs">
|
||||
{pref.description}
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@evobgp/ui/components/toggle-group"
|
||||
import { ColorPicker } from "./color-picker"
|
||||
import { CURRENCIES, DAYS, REGIONS } from "./data"
|
||||
import { EditorPreferences } from "./editor-preferences"
|
||||
import { SettingField } from "./setting-field"
|
||||
import { PlusIcon, UploadIcon, GlobeIcon, CircleDollarSignIcon } from "lucide-react"
|
||||
|
||||
export function GeneralSettings() {
|
||||
const [dateFormat, setDateFormat] = useState(["mdy"])
|
||||
const [startOfWeek, setStartOfWeek] = useState("monday")
|
||||
const [region, setRegion] = useState("us")
|
||||
const [currency, setCurrency] = useState("usd")
|
||||
const handleStartOfWeekChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setStartOfWeek(value)
|
||||
}
|
||||
}
|
||||
const handleRegionChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setRegion(value)
|
||||
}
|
||||
}
|
||||
const handleCurrencyChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setCurrency(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className="w-full max-w-3xl">
|
||||
<FrameHeader className="px-2! py-2.5!">
|
||||
<FrameTitle>General Settings</FrameTitle>
|
||||
<FrameDescription>Core app preferences.</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0">
|
||||
<FieldGroup className="gap-0">
|
||||
{/* ── Project name ── */}
|
||||
<SettingField
|
||||
title="Project name"
|
||||
description="The display name for your project across the platform."
|
||||
labelFor="settings-3-project-name"
|
||||
>
|
||||
<Input id="settings-3-project-name" defaultValue="Acme Dashboard" />
|
||||
</SettingField>
|
||||
|
||||
{/* ── API endpoint ── */}
|
||||
<SettingField
|
||||
title="API endpoint"
|
||||
description="Set the base URL for your project API."
|
||||
badge={{ label: "Required", variant: "destructive-light" }}
|
||||
labelFor="settings-3-api-endpoint"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>https://</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-3-api-endpoint"
|
||||
defaultValue="api.acme.io"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Start of week ── */}
|
||||
<SettingField
|
||||
title="Start of week"
|
||||
description="Choose which day marks the start of your week."
|
||||
labelFor="settings-3-start-of-week"
|
||||
>
|
||||
<Select value={startOfWeek} onValueChange={handleStartOfWeekChange}>
|
||||
<SelectTrigger id="settings-3-start-of-week" className="w-full">
|
||||
<SelectValue>{getOptionLabel(DAYS, startOfWeek)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{DAYS.map((day) => (
|
||||
<SelectItem key={day.value} value={day.value}>
|
||||
{day.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Allowed origins ── */}
|
||||
<SettingField
|
||||
title="Allowed origins"
|
||||
description="Define the trusted domains for CORS requests."
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add origin
|
||||
</Button>
|
||||
|
||||
<Button variant="outline">
|
||||
<UploadIcon aria-hidden="true" />
|
||||
Import from file
|
||||
</Button>
|
||||
</div>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Accent color ── */}
|
||||
<SettingField
|
||||
title="Accent color"
|
||||
description="Select a color to represent your brand."
|
||||
badge={{ label: "Upgrade to Pro", variant: "primary-light" }}
|
||||
>
|
||||
<ColorPicker />
|
||||
</SettingField>
|
||||
|
||||
{/* ── Region & currency ── */}
|
||||
<SettingField
|
||||
title="Region & currency"
|
||||
description="Adjust your regional preferences and currency."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Region and currency</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Regional and financial formatting preferences.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-3-region">Region</FieldLabel>
|
||||
<Select value={region} onValueChange={handleRegionChange}>
|
||||
<SelectTrigger id="settings-3-region" className="w-full">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
<SelectValue>
|
||||
{getOptionLabel(REGIONS, region)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{REGIONS.map((region) => (
|
||||
<SelectItem key={region.value} value={region.value}>
|
||||
{region.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-3-currency">
|
||||
Currency
|
||||
</FieldLabel>
|
||||
<Select value={currency} onValueChange={handleCurrencyChange}>
|
||||
<SelectTrigger id="settings-3-currency" className="w-full">
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
<SelectValue>
|
||||
{getOptionLabel(CURRENCIES, currency)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<SelectItem
|
||||
key={currency.value}
|
||||
value={currency.value}
|
||||
>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Date display format ── */}
|
||||
<SettingField
|
||||
title="Date display format"
|
||||
description="Choose your preferred format for dates."
|
||||
>
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={dateFormat}
|
||||
onValueChange={(value) => {
|
||||
if (value.length > 0) setDateFormat(value)
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Date display format"
|
||||
>
|
||||
<ToggleGroupItem value="mdy">MM/DD/YYYY</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dmy">DD/MM/YYYY</ToggleGroupItem>
|
||||
<ToggleGroupItem value="ymd">YYYY/MM/DD</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Editor preferences ── */}
|
||||
<SettingField
|
||||
title="Editor preferences"
|
||||
description="Adjust your editing environment and display options."
|
||||
last
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<EditorPreferences />
|
||||
</SettingField>
|
||||
</FieldGroup>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="flex flex-row justify-end gap-3">
|
||||
<Button variant="outline">Reset</Button>
|
||||
<Button>Save changes</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helper functions ──
|
||||
|
||||
function getOptionLabel(
|
||||
options: Array<{ value: string; label: string }>,
|
||||
value: string
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client"
|
||||
|
||||
import { type ComponentProps, type ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from "@evobgp/ui/components/field"
|
||||
|
||||
interface SettingFieldProps {
|
||||
title: string
|
||||
description: string
|
||||
badge?: {
|
||||
label: string
|
||||
variant: ComponentProps<typeof Badge>["variant"]
|
||||
}
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
// ── Setting Field ──
|
||||
|
||||
export function SettingField({
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
children,
|
||||
last,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
}: SettingFieldProps) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
)}
|
||||
|
||||
{badge ? (
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FieldDescription className="text-xs">{description}</FieldDescription>
|
||||
</div>
|
||||
|
||||
<FieldContent
|
||||
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
||||
>
|
||||
{children}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralSettings } from "./components/general-settings"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-8 md:p-16">
|
||||
<GeneralSettings />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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
Reference in New Issue
Block a user