Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
632adaa63f | ||
|
|
cc80858952 | ||
|
|
b8dbf15254 | ||
|
|
f45b2bed90 | ||
|
|
b13c233679 | ||
|
|
37f28dfcfd | ||
|
|
6c6e76fca3 | ||
|
|
26f5172f88 | ||
|
|
8fe74c1d3b | ||
|
|
13e3d21ce2 | ||
|
|
a0cfbcdab1 | ||
|
|
c3369059af | ||
|
|
53ce80c9ff | ||
|
|
1e04e91dd8 | ||
|
|
5fbe9c9b56 | ||
|
|
fd3a217cbe | ||
|
|
ff6efec4c5 |
@@ -98,7 +98,8 @@ pnpm --filter @evobgp/web run build
|
||||
**WEB-21** | MUST | Data fetching — TanStack Query (`useQuery`, `useMutation`, `queryOptions`); query-key factories в `apps/web/src/queries/`. Mutations invalidate keys, не refetch вручную.
|
||||
*Проверка:* review `queries/*.ts`.
|
||||
|
||||
**WEB-22** | MUST | Legacy Svelte — в `web-legacy-svelte/` (archive). Не использовать импорты оттуда в новом коде; только как референс при миграции роутов.
|
||||
**WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код.
|
||||
*Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
## Summary
|
||||
|
||||
<!-- 1–3 bullets: what and why -->
|
||||
|
||||
## Checklist
|
||||
|
||||
### General
|
||||
|
||||
- [ ] Docs updated if behavior/API changed (DOC-02)
|
||||
- [ ] Conventional Commits (EN title / RU body)
|
||||
|
||||
### Backend (if Go / OpenAPI / migrations)
|
||||
|
||||
- [ ] `go test ./... -race -count=1` (or scoped packages) when touching jobs/pipeline/httpapi
|
||||
- [ ] OpenAPI lint + regenerate `apps/web/src/types/api.gen.ts` if `docs/openapi.yaml` changed
|
||||
- [ ] Migration pairs postgres+sqlite (DEP-03)
|
||||
|
||||
### Frontend (if `apps/web` / `packages/ui`) — WEB-19
|
||||
|
||||
- [ ] `pnpm --filter @evobgp/web run typecheck`
|
||||
- [ ] `pnpm --filter @evobgp/web run lint`
|
||||
- [ ] `pnpm --filter @evobgp/web run build`
|
||||
- [ ] `pnpm --filter @evobgp/web run test`
|
||||
- [ ] UI follows ReUI PRO + `docs/ui-design-contract.md` (surface `frame`, kit; cite previewUrl)
|
||||
|
||||
### KPI / Quick Actions
|
||||
|
||||
- [ ] If changing `kpi-stat-grid.tsx` / `quick-action-grid.tsx`, note sibling-app sync (vps / CFDM / fw / auth-portal)
|
||||
@@ -150,15 +150,23 @@ jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
openapi:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.openapi == 'true'
|
||||
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: Lint OpenAPI (Redocly)
|
||||
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
||||
- name: Check OpenAPI→TS codegen is fresh
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
chmod +x scripts/check-openapi-gen.sh
|
||||
sh scripts/check-openapi-gen.sh
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
web:
|
||||
@@ -172,12 +180,13 @@ jobs:
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: pnpm install, typecheck, lint, build
|
||||
- name: pnpm install, typecheck, lint, test, build
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run test
|
||||
pnpm --filter @evobgp/web run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -69,7 +69,7 @@ pnpm --filter @evobgp/web run build
|
||||
|
||||
Все три команды должны exit 0. CI job `web` не пропускает без этого.
|
||||
|
||||
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
|
||||
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte UI удалён (миграция завершена).
|
||||
|
||||
**UI design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `apps/web/src/components/reui-kit/`.
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Smoke against local API (+ optional UI). Profile: compose microvps-full.
|
||||
*
|
||||
* Env:
|
||||
* - EVOBGP_E2E_API_URL (default http://127.0.0.1:8080)
|
||||
* - EVOBGP_E2E_BASE_URL (default http://127.0.0.1:5173)
|
||||
* - EVOBGP_E2E_TOKEN (default dev)
|
||||
*/
|
||||
const apiBase = process.env.EVOBGP_E2E_API_URL ?? 'http://127.0.0.1:8080'
|
||||
const uiBase = process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173'
|
||||
const token = process.env.EVOBGP_E2E_TOKEN ?? 'dev'
|
||||
|
||||
test.describe('EvoBGP smoke', () => {
|
||||
test('API health and modules list', async ({ request }) => {
|
||||
const health = await request.get(`${apiBase}/v1/health`)
|
||||
expect(health.ok()).toBeTruthy()
|
||||
|
||||
const mods = await request.get(`${apiBase}/v1/modules?limit=10`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect([200, 401]).toContain(mods.status())
|
||||
})
|
||||
|
||||
test('UI loads when available', async ({ page }) => {
|
||||
test.skip(!process.env.EVOBGP_E2E_UI, 'set EVOBGP_E2E_UI=1 to enable UI smoke')
|
||||
await page.goto(uiBase)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,10 @@
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsr generate && tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"openapi:gen": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.ts",
|
||||
"openapi:check": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.check.ts && diff -q src/types/api.gen.ts src/types/api.gen.check.ts && rm -f src/types/api.gen.check.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.0.0",
|
||||
@@ -40,22 +43,24 @@
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@tanstack/router-plugin": "^1.130.0",
|
||||
"@tanstack/router-cli": "^1.130.0",
|
||||
"@tanstack/router-plugin": "^1.130.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.0.0",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.0",
|
||||
"globals": "^15.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"happy-dom": "^18.0.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"tw-animate-css": "^1.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 60_000,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
use: {
|
||||
baseURL: process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { Plus, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
||||
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
@@ -47,7 +47,7 @@ export function AccessApiKeysCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
<FrameDataGrid
|
||||
title="API-ключи"
|
||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
||||
actions={
|
||||
@@ -85,7 +85,7 @@ export function AccessApiKeysCard({
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</FrameDataGrid>
|
||||
|
||||
<ApiKeyCreateDialog
|
||||
open={createOpen}
|
||||
|
||||
@@ -10,4 +10,3 @@ export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
|
||||
export { DashboardPlatformCard } from './dashboard-platform-card'
|
||||
export { MonitoringHealthCard } from './monitoring-health-card'
|
||||
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
|
||||
export { OperationsAnalyticsCard } from './operations-analytics-card'
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
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 { jobStatusBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, RevisionRow } from '@/types/api'
|
||||
|
||||
export function OperationsAnalyticsCard({
|
||||
jobs,
|
||||
revisions,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
revisions: RevisionRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
|
||||
const failed = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const slices = jobStatusBreakdown(jobs)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Операции и задачи"
|
||||
description="Статистика ревизий и фоновых jobs"
|
||||
info="Данные из GET /v1/jobs и /v1/revisions."
|
||||
>
|
||||
<AnalyticsKpiRow
|
||||
items={[
|
||||
{
|
||||
label: 'Ревизий',
|
||||
value: loading ? '—' : String(revisions.length),
|
||||
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: loading ? '—' : String(running),
|
||||
delta: {
|
||||
direction: running > 0 ? 'up' : 'neutral',
|
||||
label: running > 0 ? 'выполняются' : 'очередь пуста',
|
||||
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'С ошибкой',
|
||||
value: loading ? '—' : String(failed),
|
||||
delta: {
|
||||
direction: failed > 0 ? 'down' : 'up',
|
||||
label: failed > 0 ? 'требуют внимания' : 'в норме',
|
||||
tone: failed > 0 ? 'destructive' : 'success',
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
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",
|
||||
},
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { AppShell } from "./components/app-shell"
|
||||
|
||||
export function Page() {
|
||||
return <AppShell />
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
// Deterministic per-dot value so the field reads as noise, not a flat grid.
|
||||
function grain(i: number, j: number) {
|
||||
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
|
||||
return n - Math.floor(n)
|
||||
}
|
||||
|
||||
/**
|
||||
* Static dot field adapted from auth-3's WaveDots backdrop with the twinkle
|
||||
* animation removed: a dense grid of dots at a fixed per-dot brightness,
|
||||
* painted once and again on resize or theme change. The dot color resolves
|
||||
* from the canvas `color` token so it stays neutral in light and dark.
|
||||
* customize: GAP (density), DOT (size), PEAK (max brightness).
|
||||
*/
|
||||
export function CardDotField({ className }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const GAP = 3 // dot spacing in px (tight grid, still distinct dots)
|
||||
const DOT = 1.5 // dot side in px
|
||||
const BASE = 0.05 // dim end of a dot
|
||||
const PEAK = 0.32 // bright end of a dot (kept subtle for a card surface)
|
||||
|
||||
const draw = () => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
|
||||
// Resetting width clears the canvas and restores the identity transform,
|
||||
// so the color probe below reads a raw device pixel.
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
canvas.width = Math.round(rect.width * dpr)
|
||||
canvas.height = Math.round(rect.height * dpr)
|
||||
|
||||
// Resolve the token to concrete sRGB by painting + reading it back, so
|
||||
// oklch never casts a color and dark mode recolors on theme change.
|
||||
ctx.fillStyle = getComputedStyle(canvas).color || "rgb(115,115,115)"
|
||||
ctx.fillRect(0, 0, 1, 1)
|
||||
const px = ctx.getImageData(0, 0, 1, 1).data
|
||||
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
ctx.fillStyle = color
|
||||
|
||||
const cols = Math.ceil(rect.width / GAP) + 1
|
||||
const rows = Math.ceil(rect.height / GAP) + 1
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const x = i * GAP
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const q = grain(i, j)
|
||||
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
|
||||
let a = (BASE + (PEAK - BASE) * q * q) * amp
|
||||
if (a > 1) a = 1
|
||||
ctx.globalAlpha = a
|
||||
ctx.fillRect(x, j * GAP, DOT, DOT)
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => draw())
|
||||
resizeObserver.observe(canvas)
|
||||
|
||||
// Repaint when the theme class toggles so the resolved color stays correct.
|
||||
const themeObserver = new MutationObserver(() => draw())
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style"],
|
||||
})
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
themeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Frame } from "@/components/reui/frame"
|
||||
|
||||
import { CardItem } from "./card-item"
|
||||
import { CARDS } from "./data"
|
||||
|
||||
export function CardGrid() {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
{/* Grid */}
|
||||
<div className="grid gap-1 @2xl:grid-cols-2">
|
||||
{CARDS.map((card) => (
|
||||
<CardItem key={card.title} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import { CardDotField } from "./card-dot-field"
|
||||
import { ICard } from "./data"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
|
||||
export function CardItem({ card }: { card: ICard }) {
|
||||
return (
|
||||
<FramePanel className="isolate">
|
||||
{/* Card */}
|
||||
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
|
||||
<div className="relative z-10 space-y-7.5">
|
||||
<Item
|
||||
className={cn(
|
||||
"p-0",
|
||||
"border-background flex size-11 items-center justify-center border-2 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white",
|
||||
card.iconBg
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="block text-sm leading-tight font-medium">
|
||||
{card.title}
|
||||
</span>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{card.description}
|
||||
</p>
|
||||
<a
|
||||
href="#"
|
||||
className="text-primay inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{card.link}
|
||||
<ChevronRightIcon aria-hidden="true" className="size-2.5 shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { ShoppingBagIcon, TrendingUp, BarChart3Icon, Settings2Icon } from "lucide-react"
|
||||
|
||||
export interface ICard {
|
||||
title: string
|
||||
description: string
|
||||
link: string
|
||||
icon: ReactNode
|
||||
iconBg: string
|
||||
}
|
||||
|
||||
export const CARDS: ICard[] = [
|
||||
{
|
||||
title: "Recent Orders Overview",
|
||||
description:
|
||||
"Track and review all recent purchases, updates, and status changes in one place.",
|
||||
link: "View Orders",
|
||||
icon: (
|
||||
<ShoppingBagIcon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "bg-green-600",
|
||||
},
|
||||
{
|
||||
title: "Active Opportunities Pipeline",
|
||||
description:
|
||||
"Monitor ongoing deals, check potential revenue, and update opportunity stages.",
|
||||
link: "Open Pipeline",
|
||||
icon: (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
),
|
||||
iconBg: "bg-indigo-600",
|
||||
},
|
||||
{
|
||||
title: "Performance & Sales Reports",
|
||||
description:
|
||||
"Analyze weekly and monthly reports to gain deeper insights into performance trends.",
|
||||
link: "View Reports",
|
||||
icon: (
|
||||
<BarChart3Icon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "bg-sky-600",
|
||||
},
|
||||
{
|
||||
title: "Integration Settings & Sync",
|
||||
description:
|
||||
"Manage connections with third-party tools and ensure data stays in sync.",
|
||||
link: "Manage Integrations",
|
||||
icon: (
|
||||
<Settings2Icon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "bg-orange-600",
|
||||
},
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
import { CardGrid } from "./components/card-grid"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full max-w-4xl items-center justify-center p-8">
|
||||
<CardGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { CardItem } from "./card-item"
|
||||
import { CARDS } from "./data"
|
||||
|
||||
export function CardGrid() {
|
||||
return (
|
||||
<div className="@container w-full">
|
||||
{/* Grid */}
|
||||
<div className="grid gap-5 @2xl:grid-cols-3">
|
||||
{CARDS.map((card) => (
|
||||
<CardItem key={card.label} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
} from "@/components/reui/frame"
|
||||
import { ICard } from "./data"
|
||||
import { LinkIcon } from "lucide-react"
|
||||
|
||||
export function CardItem({ card }: { card: ICard }) {
|
||||
return (
|
||||
<Frame spacing="sm">
|
||||
{/* Header */}
|
||||
<FrameHeader className="px-1! py-1!">
|
||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||
{card.icon}
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
{/* Content */}
|
||||
<FramePanel className="space-y-3.5">
|
||||
<p className="text-xs leading-relaxed">{card.description}</p>
|
||||
<a
|
||||
href="#"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
<LinkIcon aria-hidden="true" className="size-2.5 shrink-0" />
|
||||
{card.link}
|
||||
</a>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { PackageIcon, TrendingUp, MapPinIcon } from "lucide-react"
|
||||
|
||||
export interface ICard {
|
||||
label: string
|
||||
icon: ReactNode
|
||||
description: string
|
||||
link: string
|
||||
}
|
||||
|
||||
export const CARDS: ICard[] = [
|
||||
{
|
||||
label: "Binance",
|
||||
icon: (
|
||||
<PackageIcon aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Track trading volumes, liquidity shifts, and price movements for informed decisions",
|
||||
link: "https://www.binance.com/en/markets/over..",
|
||||
},
|
||||
{
|
||||
label: "Revenue",
|
||||
icon: (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Get instant insights into earnings and cash flow performance.",
|
||||
link: "https://nexo.io/earn/crypto-detailed-portfol..",
|
||||
},
|
||||
{
|
||||
label: "Shipments",
|
||||
icon: (
|
||||
<MapPinIcon aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Stay on top of deliveries and track shipment statuses efficiently.",
|
||||
link: "https://www.educare.io/platform/analytics/e..",
|
||||
},
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
import { CardGrid } from "./components/card-grid"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full max-w-5xl items-center justify-center p-6">
|
||||
<CardGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
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
|
||||
@@ -1,9 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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)",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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 },
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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)" },
|
||||
],
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Cell, Pie, PieChart } from "recharts"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@evobgp/ui/components/chart"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
allocationMemberCount,
|
||||
allocationMembers,
|
||||
allocationPeriods,
|
||||
inflowChartConfig,
|
||||
inflowPeriods,
|
||||
SEGMENT_COUNT,
|
||||
type AllocationPeriod,
|
||||
type InflowFund,
|
||||
type InflowPeriod,
|
||||
} from "./data"
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
const segments = Array.from({ length: SEGMENT_COUNT }, (_, index) => index)
|
||||
|
||||
const CHART_REVEAL_STYLE = `
|
||||
@keyframes dashboard-1-flow-reveal-up {
|
||||
from {
|
||||
clip-path: inset(100% 0 0 0);
|
||||
opacity: 0.75;
|
||||
}
|
||||
to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-1-flow-reveal-up {
|
||||
animation: dashboard-1-flow-reveal-up 680ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-1-flow-reveal-up {
|
||||
animation: none;
|
||||
clip-path: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
function AllocationMeter({ period }: { period: AllocationPeriod }) {
|
||||
return (
|
||||
<div
|
||||
aria-label={`${period.label} capacity allocation is ${period.allocation}`}
|
||||
className="flex h-7 w-full items-stretch justify-between"
|
||||
role="img"
|
||||
>
|
||||
{segments.map((segment) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
key={segment}
|
||||
className={cn(
|
||||
"h-full w-1 shrink-0 rounded-full",
|
||||
segment < period.filledSegments ? "bg-success" : "bg-muted"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberStack() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<AvatarGroup className="-space-x-2">
|
||||
{allocationMembers.map((member) => (
|
||||
<Avatar key={member.name} className="size-6">
|
||||
{member.avatar ? (
|
||||
<AvatarImage src={member.avatar} alt={member.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="bg-background text-xs font-medium">
|
||||
{member.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{allocationMemberCount} Members
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AllocationChart() {
|
||||
return (
|
||||
<Frame className="@container h-full w-full">
|
||||
<FramePanel>
|
||||
<Tabs
|
||||
defaultValue={allocationPeriods[0].value}
|
||||
className="h-full w-full min-w-0 gap-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h2 className="text-sm font-medium">Capacity Allocation</h2>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground/70 hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex shrink-0 rounded-full p-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label="Capacity Allocation info"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="max-w-56 px-2.5 py-1.5 text-xs leading-5"
|
||||
>
|
||||
Fulfillment capacity by selected period.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<TabsList>
|
||||
{allocationPeriods.map((period) => (
|
||||
<TabsTrigger key={period.value} value={period.value}>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{allocationPeriods.map((period) => (
|
||||
<TabsContent
|
||||
key={period.value}
|
||||
value={period.value}
|
||||
className="mt-0"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Metric */}
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="text-[26px] font-medium">
|
||||
{period.allocation}
|
||||
</span>
|
||||
<span className="text-success text-xs font-medium">
|
||||
{period.delta}
|
||||
</span>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
{period.comparison}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<AllocationMeter period={period} />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
|
||||
<p>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
Queued Orders:
|
||||
</span>{" "}
|
||||
<span className="text-sm font-medium">
|
||||
{period.exposure}
|
||||
</span>
|
||||
</p>
|
||||
<MemberStack />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
type DonutSlice =
|
||||
| InflowFund
|
||||
| {
|
||||
key: "reserve"
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
function getDonutData(period: InflowPeriod) {
|
||||
const trackedShare = period.funds.reduce(
|
||||
(total, fund) => total + fund.share,
|
||||
0
|
||||
)
|
||||
const reserveShare = Math.max(100 - trackedShare, 0)
|
||||
|
||||
return [
|
||||
...period.funds,
|
||||
{
|
||||
key: "reserve",
|
||||
name: "Reserve Capacity",
|
||||
amount: "",
|
||||
share: reserveShare,
|
||||
color: "var(--muted)",
|
||||
fill: "var(--color-reserve)",
|
||||
},
|
||||
] satisfies DonutSlice[]
|
||||
}
|
||||
|
||||
function ChartTooltipFormatter(item: unknown) {
|
||||
const fund = item as DonutSlice
|
||||
const value = fund.key === "reserve" ? `${fund.share}%` : fund.amount
|
||||
|
||||
return (
|
||||
<div className="flex min-w-40 items-center justify-between gap-6">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground truncate">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-foreground font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoTooltip() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="About Decision Flow"
|
||||
className="text-muted-foreground/70 -my-1"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<InfoIcon aria-hidden="true" className="text-sm" data-icon="inline-start" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<p>Tracked decisions entering fulfillment lanes.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowDonut({ period }: { period: InflowPeriod }) {
|
||||
const chartData = getDonutData(period)
|
||||
|
||||
return (
|
||||
<div className="dashboard-1-flow-reveal-up relative size-[8.25rem] shrink-0">
|
||||
<ChartContainer
|
||||
aria-label={`Decision Flow: ${period.total} total for ${period.label}`}
|
||||
className="aspect-square size-[8.25rem]"
|
||||
config={inflowChartConfig}
|
||||
initialDimension={{ width: 132, height: 132 }}
|
||||
>
|
||||
<PieChart margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
wrapperStyle={{ zIndex: 30 }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
hideIndicator
|
||||
formatter={(_value, _name, item) =>
|
||||
ChartTooltipFormatter(item.payload)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="share"
|
||||
endAngle={-230}
|
||||
innerRadius={47}
|
||||
isAnimationActive={false}
|
||||
nameKey="name"
|
||||
outerRadius={62}
|
||||
paddingAngle={1}
|
||||
cornerRadius={3}
|
||||
startAngle={130}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{chartData.map((item) => (
|
||||
<Cell key={item.key} fill={item.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="bg-background/90 border-border/70 flex size-[5.25rem] flex-col items-center justify-center rounded-full border border-dashed">
|
||||
<span className="text-muted-foreground/70 text-xs">Flow</span>
|
||||
<span className="mt-0.5 text-sm font-semibold">{period.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowLegend({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<ul className="flex min-w-0 flex-1 flex-col">
|
||||
{period.funds.map((fund, index) => (
|
||||
<li key={fund.key}>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{fund.amount}</span>
|
||||
<span className="text-muted-foreground/70 w-8 text-right text-xs">
|
||||
{fund.share}%
|
||||
</span>
|
||||
</div>
|
||||
{index < period.funds.length - 1 ? (
|
||||
<Separator className="w-auto" />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowPeriodPanel({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<div className="grid gap-6 @sm:grid-cols-[8.25rem_minmax(0,1fr)] @sm:items-center">
|
||||
<InflowDonut period={period} />
|
||||
<InflowLegend period={period} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowChart() {
|
||||
return (
|
||||
<TooltipProvider delay={150}>
|
||||
<style>{CHART_REVEAL_STYLE}</style>
|
||||
<Frame className="@container h-full w-full">
|
||||
<FramePanel className="ps-3.5! pe-5! pt-5! pb-3.5!">
|
||||
<Tabs defaultValue="week" className="gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-0.5 ps-1.5">
|
||||
<h2 className="text-sm font-medium">Decision Flow</h2>
|
||||
<InfoTooltip />
|
||||
</div>
|
||||
|
||||
<TabsList className="w-full @sm:w-auto">
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsTrigger key={period.value} value={period.value}>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsContent
|
||||
key={period.value}
|
||||
value={period.value}
|
||||
className="mt-0"
|
||||
>
|
||||
<InflowPeriodPanel period={period} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<div className="@container grid h-full w-full min-w-0 auto-rows-fr gap-3">
|
||||
<AllocationChart />
|
||||
<InflowChart />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
|
||||
import { FULFILLMENT_CARDS, type FulfillmentCard } from "./data"
|
||||
|
||||
function CardItem({ card }: { card: FulfillmentCard }) {
|
||||
return (
|
||||
<FramePanel>
|
||||
{/* Heading */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Item
|
||||
className={cn(
|
||||
"p-0",
|
||||
"border-background flex size-10 items-center justify-center border-2 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white",
|
||||
card.iconBg
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.typeLabel}
|
||||
</p>
|
||||
<h3 className="text-sm leading-tight font-medium">{card.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-1.5">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.metricLabel}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xl font-medium tracking-tight">
|
||||
{card.balance}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
card.change.positive ? "text-teal-600" : "text-rose-600"
|
||||
)}
|
||||
>
|
||||
{card.change.percent} ({card.change.amount})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
{/* Grid */}
|
||||
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
|
||||
{FULFILLMENT_CARDS.map((card) => (
|
||||
<CardItem key={card.title} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameFooter,
|
||||
FramePanel,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Progress } from "@evobgp/ui/components/progress"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import {
|
||||
PERFORMANCE_RANGE_OPTIONS,
|
||||
SHIFT_ACTIVITY,
|
||||
SHIFT_PERFORMANCE,
|
||||
SHIFT_PIPELINE_PROGRESS,
|
||||
} from "./data"
|
||||
import { TrendingUp, TrendingDown, CircleCheckIcon } from "lucide-react"
|
||||
|
||||
export function InvestorCard() {
|
||||
return (
|
||||
<Frame className="h-full w-full">
|
||||
{/* Content */}
|
||||
<FramePanel>
|
||||
<div className="mb-6 flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h3 className="text-base font-semibold">Shift Performance</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select defaultValue="today" items={PERFORMANCE_RANGE_OPTIONS}>
|
||||
<SelectTrigger className="h-8! w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
align="start"
|
||||
alignItemWithTrigger={false}
|
||||
className="w-28"
|
||||
>
|
||||
{PERFORMANCE_RANGE_OPTIONS.map((range) => (
|
||||
<SelectItem key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SHIFT_PERFORMANCE.map((item) => (
|
||||
<div
|
||||
className="flex flex-col items-start justify-start"
|
||||
key={item.label}
|
||||
>
|
||||
<div className="text-foreground text-xl font-bold">
|
||||
{item.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
{item.label}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 text-xs font-semibold [&_svg]:h-3 [&_svg]:w-3",
|
||||
item.trend === "positive"
|
||||
? "text-emerald-500"
|
||||
: "text-destructive"
|
||||
)}
|
||||
>
|
||||
{item.trend === "positive" ? (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
) : (
|
||||
<TrendingDown aria-hidden="true" />
|
||||
)}
|
||||
{item.delta}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
Pipeline Progress
|
||||
</span>
|
||||
<span className="text-foreground text-xs font-semibold">
|
||||
{SHIFT_PIPELINE_PROGRESS}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={SHIFT_PIPELINE_PROGRESS}
|
||||
className="h-1! **:data-[slot=progress-track]:h-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="text-foreground mb-2.5 text-sm font-medium">
|
||||
Recent Activity
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{SHIFT_ACTIVITY.map((activity) => (
|
||||
<li
|
||||
key={activity.id}
|
||||
className="flex items-center justify-between gap-2.5 text-sm"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<CircleCheckIcon className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
activity.tone === "success" && "text-emerald-500",
|
||||
activity.tone === "info" && "text-sky-500",
|
||||
activity.tone === "warning" && "text-amber-500"
|
||||
)} aria-hidden="true" />
|
||||
<span className="text-foreground truncate text-xs">
|
||||
{activity.title}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
activity.tone === "success"
|
||||
? "success-light"
|
||||
: activity.tone === "info"
|
||||
? "info-light"
|
||||
: "warning-light"
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
{activity.status}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
{/* Footer */}
|
||||
<FrameFooter className="flex-row items-center gap-2.5 p-2!">
|
||||
<Button variant="outline" className="flex-1">
|
||||
Schedule
|
||||
</Button>
|
||||
<Button className="flex-1">Full Report</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Chart as CapacityChart } from "./capacity-chart"
|
||||
import { Chart as ChartCards } from "./chart-cards"
|
||||
import { InvestorCard as CommanderCard } from "./commander-card"
|
||||
import { ExceptionGrid } from "./exception-grid"
|
||||
import { Navbar } from "./navbar"
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-2">
|
||||
<Navbar />
|
||||
|
||||
<section aria-label="Fulfillment metrics">
|
||||
<ChartCards />
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Fulfillment operations"
|
||||
className="grid min-w-0 items-stretch gap-3 @5xl:grid-cols-2"
|
||||
>
|
||||
<div className="flex min-w-0">
|
||||
<CommanderCard />
|
||||
</div>
|
||||
<div className="flex min-w-0">
|
||||
<CapacityChart />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="Fulfillment exception queue">
|
||||
<ExceptionGrid />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,676 +0,0 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { type BadgeProps } from "@/components/reui/badge"
|
||||
|
||||
import { type ChartConfig } from "@evobgp/ui/components/chart"
|
||||
import { PackageIcon, TruckIcon, TriangleAlertIcon, BotIcon } from "lucide-react"
|
||||
|
||||
export type FulfillmentStatus = "On Time" | "At Risk" | "Delayed" | "Blocked"
|
||||
export type AutomationLevel = "Autopilot" | "Copilot" | "Manual"
|
||||
|
||||
export interface TeamMember {
|
||||
name: string
|
||||
initials: string
|
||||
avatar: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface FulfillmentException {
|
||||
id: string
|
||||
reference: string
|
||||
customer: string
|
||||
email: string
|
||||
avatar: string
|
||||
initials: string
|
||||
lane: string
|
||||
facility: string
|
||||
stage: string
|
||||
promise: string
|
||||
slaMinutes: number
|
||||
automation: AutomationLevel
|
||||
owner: string
|
||||
units: number
|
||||
value: number
|
||||
risk: string
|
||||
status: FulfillmentStatus
|
||||
}
|
||||
|
||||
export const STATUS_ORDER: FulfillmentStatus[] = [
|
||||
"On Time",
|
||||
"At Risk",
|
||||
"Delayed",
|
||||
"Blocked",
|
||||
]
|
||||
|
||||
export const STATUS_BADGE_VARIANT: Record<
|
||||
FulfillmentStatus,
|
||||
BadgeProps["variant"]
|
||||
> = {
|
||||
"On Time": "success-outline",
|
||||
"At Risk": "warning-outline",
|
||||
Delayed: "info-outline",
|
||||
Blocked: "destructive-outline",
|
||||
}
|
||||
|
||||
export const AUTOMATION_BADGE_VARIANT: Record<
|
||||
AutomationLevel,
|
||||
BadgeProps["variant"]
|
||||
> = {
|
||||
Autopilot: "success-light",
|
||||
Copilot: "info-light",
|
||||
Manual: "warning-light",
|
||||
}
|
||||
|
||||
export const NAV_MEMBERS: TeamMember[] = [
|
||||
{
|
||||
name: "Maya Singh",
|
||||
initials: "MS",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
role: "Fulfillment lead",
|
||||
},
|
||||
{
|
||||
name: "Leo Martins",
|
||||
initials: "LM",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
role: "Automation owner",
|
||||
},
|
||||
{
|
||||
name: "Nora Albright",
|
||||
initials: "NA",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
||||
role: "Capacity planner",
|
||||
},
|
||||
]
|
||||
|
||||
export const TEAM_MEMBERS = NAV_MEMBERS.map((member) => ({
|
||||
src: member.avatar,
|
||||
initials: member.initials,
|
||||
name: member.name,
|
||||
}))
|
||||
|
||||
export const TEAM_EXTRA_COUNT = 11
|
||||
|
||||
export interface FulfillmentCardChange {
|
||||
positive: boolean
|
||||
percent: string
|
||||
amount: string
|
||||
}
|
||||
|
||||
export interface FulfillmentCard {
|
||||
typeLabel: string
|
||||
title: string
|
||||
metricLabel: string
|
||||
balance: string
|
||||
change: FulfillmentCardChange
|
||||
icon: ReactNode
|
||||
iconBg: string
|
||||
}
|
||||
|
||||
export const FULFILLMENT_CARDS: FulfillmentCard[] = [
|
||||
{
|
||||
typeLabel: "Outbound",
|
||||
title: "Orders Ready",
|
||||
metricLabel: "Ready Volume",
|
||||
balance: "18,420",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+11.8%",
|
||||
amount: "1,946",
|
||||
},
|
||||
iconBg: "bg-neutral-950",
|
||||
icon: (
|
||||
<PackageIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Promise",
|
||||
title: "Same-Day SLA",
|
||||
metricLabel: "Service Level",
|
||||
balance: "94.8%",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+1.2 pts",
|
||||
amount: "shift",
|
||||
},
|
||||
iconBg: "bg-indigo-600",
|
||||
icon: (
|
||||
<TruckIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Inventory",
|
||||
title: "Stock Risk",
|
||||
metricLabel: "Blocked SKUs",
|
||||
balance: "31",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "13 fewer",
|
||||
amount: "since 06:00",
|
||||
},
|
||||
iconBg: "bg-amber-400",
|
||||
icon: (
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Policy",
|
||||
title: "AI Autopilot",
|
||||
metricLabel: "Auto Resolved",
|
||||
balance: "71.6%",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+8.4 pts",
|
||||
amount: "policy",
|
||||
},
|
||||
iconBg: "bg-cyan-600",
|
||||
icon: (
|
||||
<BotIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export type AllocationPeriod = {
|
||||
value: "week" | "month" | "year"
|
||||
label: string
|
||||
allocation: string
|
||||
delta: string
|
||||
comparison: string
|
||||
exposure: string
|
||||
filledSegments: number
|
||||
}
|
||||
|
||||
export type AllocationMember = {
|
||||
name: string
|
||||
initials: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export const SEGMENT_COUNT = 56
|
||||
export const allocationMemberCount = 6
|
||||
|
||||
export const allocationPeriods: AllocationPeriod[] = [
|
||||
{
|
||||
value: "week",
|
||||
label: "Week",
|
||||
allocation: "86%",
|
||||
delta: "+5.8%",
|
||||
comparison: "vs labor plan",
|
||||
exposure: "3,840 orders",
|
||||
filledSegments: 48,
|
||||
},
|
||||
{
|
||||
value: "month",
|
||||
label: "Month",
|
||||
allocation: "79%",
|
||||
delta: "+2.4%",
|
||||
comparison: "vs prior month",
|
||||
exposure: "18 priority lanes",
|
||||
filledSegments: 44,
|
||||
},
|
||||
{
|
||||
value: "year",
|
||||
label: "Year",
|
||||
allocation: "74%",
|
||||
delta: "+9.2%",
|
||||
comparison: "automation lift",
|
||||
exposure: "6 facilities",
|
||||
filledSegments: 41,
|
||||
},
|
||||
]
|
||||
|
||||
export const allocationMembers: AllocationMember[] = TEAM_MEMBERS.map(
|
||||
(member) => ({
|
||||
name: member.name,
|
||||
initials: member.initials,
|
||||
avatar: member.src,
|
||||
})
|
||||
)
|
||||
|
||||
export type PerformanceTrend = "positive" | "negative"
|
||||
export type ActivityTone = "success" | "info" | "warning"
|
||||
|
||||
export interface PerformanceMetric {
|
||||
label: string
|
||||
value: string
|
||||
trend: PerformanceTrend
|
||||
delta: string
|
||||
}
|
||||
|
||||
export interface ShiftActivity {
|
||||
id: string
|
||||
title: string
|
||||
time: string
|
||||
status: string
|
||||
tone: ActivityTone
|
||||
}
|
||||
|
||||
export const PERFORMANCE_RANGE_OPTIONS = [
|
||||
{ label: "Today", value: "today" },
|
||||
{ label: "Week", value: "week" },
|
||||
{ label: "Month", value: "month" },
|
||||
]
|
||||
|
||||
export const SHIFT_PERFORMANCE: PerformanceMetric[] = [
|
||||
{
|
||||
label: "Orders Cleared",
|
||||
value: "18.4k",
|
||||
trend: "positive",
|
||||
delta: "+11.8%",
|
||||
},
|
||||
{
|
||||
label: "SLA Recovery",
|
||||
value: "94.8%",
|
||||
trend: "positive",
|
||||
delta: "+1.2 pts",
|
||||
},
|
||||
{
|
||||
label: "Risk Exposure",
|
||||
value: "$128k",
|
||||
trend: "negative",
|
||||
delta: "-9.4%",
|
||||
},
|
||||
]
|
||||
|
||||
export const SHIFT_PIPELINE_PROGRESS = 76
|
||||
|
||||
export const SHIFT_ACTIVITY: ShiftActivity[] = [
|
||||
{
|
||||
id: "wave-release",
|
||||
title: "Released priority wave to dock B",
|
||||
time: "4 min ago",
|
||||
status: "Cleared",
|
||||
tone: "success",
|
||||
},
|
||||
{
|
||||
id: "carrier-reprice",
|
||||
title: "Carrier mix repriced for zone 6",
|
||||
time: "12 min ago",
|
||||
status: "Review",
|
||||
tone: "info",
|
||||
},
|
||||
{
|
||||
id: "inventory-hold",
|
||||
title: "Inventory hold isolated to 3 SKUs",
|
||||
time: "23 min ago",
|
||||
status: "Watch",
|
||||
tone: "warning",
|
||||
},
|
||||
]
|
||||
|
||||
export type InflowFundKey = "autopilot" | "copilot" | "manual" | "reserve"
|
||||
|
||||
export interface InflowFund {
|
||||
key: Exclude<InflowFundKey, "reserve">
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
export interface InflowPeriod {
|
||||
value: "week" | "month" | "year"
|
||||
label: string
|
||||
total: string
|
||||
headline: string
|
||||
description: string
|
||||
delta: string
|
||||
funds: InflowFund[]
|
||||
}
|
||||
|
||||
const inflowAutopilotColor = "oklch(0.62 0.19 149)"
|
||||
const inflowCopilotColor = "oklch(0.58 0.18 257)"
|
||||
const inflowManualColor = "oklch(0.72 0.16 78)"
|
||||
|
||||
export const inflowChartConfig = {
|
||||
flow: {
|
||||
label: "Flow",
|
||||
},
|
||||
autopilot: {
|
||||
label: "Autopilot",
|
||||
color: inflowAutopilotColor,
|
||||
},
|
||||
copilot: {
|
||||
label: "Copilot",
|
||||
color: inflowCopilotColor,
|
||||
},
|
||||
manual: {
|
||||
label: "Manual",
|
||||
color: inflowManualColor,
|
||||
},
|
||||
reserve: {
|
||||
label: "Reserve",
|
||||
color: "oklch(0.7 0.04 260)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const inflowPeriods: InflowPeriod[] = [
|
||||
{
|
||||
value: "week",
|
||||
label: "Week",
|
||||
total: "18.4k",
|
||||
headline: "Exception Flow",
|
||||
description: "Orders entering decision lanes",
|
||||
delta: "+6.2%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "9.1k",
|
||||
share: 49.5,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "5.2k",
|
||||
share: 28.3,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "2.8k",
|
||||
share: 15.2,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "month",
|
||||
label: "Month",
|
||||
total: "76.8k",
|
||||
headline: "Resolved Flow",
|
||||
description: "Completed decisions this month",
|
||||
delta: "+14.8%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "41.6k",
|
||||
share: 54.2,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "20.3k",
|
||||
share: 26.4,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "9.8k",
|
||||
share: 12.8,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "year",
|
||||
label: "Year",
|
||||
total: "812k",
|
||||
headline: "Network Flow",
|
||||
description: "Decisions across six facilities",
|
||||
delta: "+21.5%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "428k",
|
||||
share: 52.7,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "224k",
|
||||
share: 27.6,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "103k",
|
||||
share: 12.7,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const FULFILLMENT_ROWS: FulfillmentException[] = [
|
||||
{
|
||||
id: "row-1001",
|
||||
reference: "NSC-84721",
|
||||
customer: "Avery Outdoor",
|
||||
email: "ops@averyoutdoor.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&dpr=2&q=80",
|
||||
initials: "AO",
|
||||
lane: "Chicago to Austin",
|
||||
facility: "ORD-2",
|
||||
stage: "Carrier tender",
|
||||
promise: "Today 18:00",
|
||||
slaMinutes: 42,
|
||||
automation: "Copilot",
|
||||
owner: "Maya Singh",
|
||||
units: 480,
|
||||
value: 38240,
|
||||
risk: "Carrier capacity is tight after midday cutoff",
|
||||
status: "At Risk",
|
||||
},
|
||||
{
|
||||
id: "row-1002",
|
||||
reference: "NSC-84734",
|
||||
customer: "Field & Frame",
|
||||
email: "priority@fieldframe.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
|
||||
initials: "FF",
|
||||
lane: "Dallas to Phoenix",
|
||||
facility: "DFW-1",
|
||||
stage: "Pick wave",
|
||||
promise: "Today 16:30",
|
||||
slaMinutes: 88,
|
||||
automation: "Autopilot",
|
||||
owner: "Leo Martins",
|
||||
units: 310,
|
||||
value: 21480,
|
||||
risk: "Wave optimized by carton density",
|
||||
status: "On Time",
|
||||
},
|
||||
{
|
||||
id: "row-1003",
|
||||
reference: "NSC-84755",
|
||||
customer: "MetroFit Labs",
|
||||
email: "ops@metrofit.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519345182560-3f2917c472ef?w=96&h=96&dpr=2&q=80",
|
||||
initials: "ML",
|
||||
lane: "Newark to Boston",
|
||||
facility: "EWR-3",
|
||||
stage: "Inventory hold",
|
||||
promise: "Today 15:15",
|
||||
slaMinutes: -24,
|
||||
automation: "Manual",
|
||||
owner: "Nora Albright",
|
||||
units: 126,
|
||||
value: 18760,
|
||||
risk: "Lot trace requires human release",
|
||||
status: "Blocked",
|
||||
},
|
||||
{
|
||||
id: "row-1004",
|
||||
reference: "NSC-84763",
|
||||
customer: "Northline Studio",
|
||||
email: "returns@northline.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
|
||||
initials: "NS",
|
||||
lane: "Los Angeles to Seattle",
|
||||
facility: "LAX-4",
|
||||
stage: "Packing",
|
||||
promise: "Today 19:45",
|
||||
slaMinutes: 114,
|
||||
automation: "Autopilot",
|
||||
owner: "Leo Martins",
|
||||
units: 840,
|
||||
value: 52210,
|
||||
risk: "Packing line is running above plan",
|
||||
status: "On Time",
|
||||
},
|
||||
{
|
||||
id: "row-1005",
|
||||
reference: "NSC-84801",
|
||||
customer: "Urban Pantry",
|
||||
email: "supply@urbanpantry.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?w=96&h=96&dpr=2&q=80",
|
||||
initials: "UP",
|
||||
lane: "Atlanta to Miami",
|
||||
facility: "ATL-2",
|
||||
stage: "Cold chain",
|
||||
promise: "Today 17:00",
|
||||
slaMinutes: 9,
|
||||
automation: "Copilot",
|
||||
owner: "Maya Singh",
|
||||
units: 212,
|
||||
value: 30440,
|
||||
risk: "Reefer handoff needs confirmation",
|
||||
status: "Delayed",
|
||||
},
|
||||
{
|
||||
id: "row-1006",
|
||||
reference: "NSC-84819",
|
||||
customer: "Glow Market",
|
||||
email: "vip@glowmarket.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1489424731084-a5d8b219a5bb?w=96&h=96&dpr=2&q=80",
|
||||
initials: "GM",
|
||||
lane: "Las Vegas to Denver",
|
||||
facility: "LAS-1",
|
||||
stage: "Labeling",
|
||||
promise: "Tomorrow 09:20",
|
||||
slaMinutes: 312,
|
||||
automation: "Autopilot",
|
||||
owner: "Nora Albright",
|
||||
units: 94,
|
||||
value: 10920,
|
||||
risk: "No current risk",
|
||||
status: "On Time",
|
||||
},
|
||||
{
|
||||
id: "row-1007",
|
||||
reference: "NSC-84827",
|
||||
customer: "Ridge Supply",
|
||||
email: "buyers@ridgesupply.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
||||
initials: "RS",
|
||||
lane: "Portland to San Jose",
|
||||
facility: "PDX-1",
|
||||
stage: "Split shipment",
|
||||
promise: "Today 20:00",
|
||||
slaMinutes: 36,
|
||||
automation: "Copilot",
|
||||
owner: "Maya Singh",
|
||||
units: 176,
|
||||
value: 14680,
|
||||
risk: "Two SKUs short at primary node",
|
||||
status: "At Risk",
|
||||
},
|
||||
{
|
||||
id: "row-1008",
|
||||
reference: "NSC-84842",
|
||||
customer: "Casa Verde",
|
||||
email: "storeops@casaverde.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544725176-7c40e5a71c5e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "CV",
|
||||
lane: "Nashville to Charlotte",
|
||||
facility: "BNA-2",
|
||||
stage: "Dock queue",
|
||||
promise: "Today 14:30",
|
||||
slaMinutes: -51,
|
||||
automation: "Manual",
|
||||
owner: "Nora Albright",
|
||||
units: 265,
|
||||
value: 22750,
|
||||
risk: "Outbound door is constrained",
|
||||
status: "Delayed",
|
||||
},
|
||||
{
|
||||
id: "row-1009",
|
||||
reference: "NSC-84864",
|
||||
customer: "Beacon Cycle",
|
||||
email: "logistics@beaconcycle.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1552058544-f2b08422138a?w=96&h=96&dpr=2&q=80",
|
||||
initials: "BC",
|
||||
lane: "Columbus to Pittsburgh",
|
||||
facility: "CMH-1",
|
||||
stage: "Fraud review",
|
||||
promise: "Tomorrow 11:45",
|
||||
slaMinutes: 510,
|
||||
automation: "Manual",
|
||||
owner: "Maya Singh",
|
||||
units: 58,
|
||||
value: 8920,
|
||||
risk: "Payment review blocks release",
|
||||
status: "Blocked",
|
||||
},
|
||||
{
|
||||
id: "row-1010",
|
||||
reference: "NSC-84888",
|
||||
customer: "Aster Goods",
|
||||
email: "ops@astergoods.example",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?w=96&h=96&dpr=2&q=80",
|
||||
initials: "AG",
|
||||
lane: "Reno to Salt Lake City",
|
||||
facility: "RNO-1",
|
||||
stage: "Manifest",
|
||||
promise: "Today 22:15",
|
||||
slaMinutes: 177,
|
||||
automation: "Autopilot",
|
||||
owner: "Leo Martins",
|
||||
units: 390,
|
||||
value: 19340,
|
||||
risk: "Manifest is ready for carrier scan",
|
||||
status: "On Time",
|
||||
},
|
||||
]
|
||||
|
||||
export function fulfillmentSearchBlob(row: FulfillmentException): string {
|
||||
return [
|
||||
row.reference,
|
||||
row.customer,
|
||||
row.email,
|
||||
row.lane,
|
||||
row.facility,
|
||||
row.stage,
|
||||
row.promise,
|
||||
row.automation,
|
||||
row.owner,
|
||||
row.risk,
|
||||
row.status,
|
||||
String(row.units),
|
||||
String(row.value),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
import { memo } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { type ColumnDef, type Row } from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { Item, ItemMedia } from "@evobgp/ui/components/item"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
AUTOMATION_BADGE_VARIANT,
|
||||
STATUS_BADGE_VARIANT,
|
||||
type AutomationLevel,
|
||||
type FulfillmentException,
|
||||
type FulfillmentStatus,
|
||||
} from "./data"
|
||||
import { PackageIcon, InfoIcon, MoreHorizontalIcon, EyeIcon, BellIcon, CopyIcon, TriangleAlertIcon } from "lucide-react"
|
||||
|
||||
const currencyCompact = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
|
||||
const numberCompact = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
|
||||
const availabilityColor: Record<FulfillmentStatus, string> = {
|
||||
"On Time": "bg-success",
|
||||
"At Risk": "bg-warning",
|
||||
Delayed: "bg-info",
|
||||
Blocked: "bg-destructive",
|
||||
}
|
||||
|
||||
const stageProgress: Record<string, number> = {
|
||||
"Carrier tender": 72,
|
||||
"Pick wave": 64,
|
||||
"Inventory hold": 28,
|
||||
Packing: 82,
|
||||
"Cold chain": 48,
|
||||
Labeling: 76,
|
||||
"Split shipment": 39,
|
||||
"Dock queue": 31,
|
||||
"Fraud review": 24,
|
||||
Manifest: 90,
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const StatusBadge = memo(function StatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: FulfillmentStatus
|
||||
}) {
|
||||
return (
|
||||
<Badge variant={STATUS_BADGE_VARIANT[status]} className="gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-1.5 rounded-full", availabilityColor[status])}
|
||||
/>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
|
||||
function AutomationBadge({ level }: { level: AutomationLevel }) {
|
||||
return <Badge variant={AUTOMATION_BADGE_VARIANT[level]}>{level}</Badge>
|
||||
}
|
||||
|
||||
const ReferenceCell = memo(function ReferenceCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<a
|
||||
href="#"
|
||||
className="text-primary truncate text-sm font-medium underline-offset-2 transition-colors hover:underline"
|
||||
aria-label={`View order ${order.reference}`}
|
||||
>
|
||||
{order.reference}
|
||||
</a>
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<span className="shrink-0">{order.facility}</span>
|
||||
<DotSeparator />
|
||||
<span className="truncate">{order.owner}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const CustomerCell = memo(function CustomerCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={order.avatar} alt={order.customer} />
|
||||
<AvatarFallback>{order.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
|
||||
availabilityColor[order.status]
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<a
|
||||
href="#"
|
||||
className="text-foreground hover:text-primary line-clamp-1 font-medium underline-offset-2 transition-colors hover:underline"
|
||||
aria-label={`View customer ${order.customer}`}
|
||||
>
|
||||
{order.customer}
|
||||
</a>
|
||||
<div
|
||||
className="text-muted-foreground line-clamp-1 text-xs"
|
||||
title={order.email}
|
||||
>
|
||||
{order.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const StageCell = memo(function StageCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
const progress = stageProgress[order.stage] ?? 50
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item render={<span />} className="w-auto shrink-0 border-0 p-0">
|
||||
<ItemMedia variant="icon" className="text-muted-foreground size-auto">
|
||||
<PackageIcon className="size-4" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-foreground min-w-0 truncate font-medium">
|
||||
{order.stage}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="bg-muted block h-1.5 min-w-16 flex-1 overflow-hidden rounded-full">
|
||||
<span
|
||||
className={cn(
|
||||
"block h-full rounded-full",
|
||||
availabilityColor[order.status]
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const LaneCell = memo(function LaneCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
|
||||
return (
|
||||
<div className="flex max-w-full min-w-0 flex-col gap-0.5">
|
||||
<span
|
||||
className="text-foreground block max-w-full min-w-0 truncate font-medium"
|
||||
title={order.lane}
|
||||
>
|
||||
{order.lane}
|
||||
</span>
|
||||
<span
|
||||
className="text-muted-foreground block max-w-full min-w-0 truncate text-xs"
|
||||
title={order.facility}
|
||||
>
|
||||
{order.facility}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const ValueCell = memo(function ValueCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const valueHint =
|
||||
row.original.value >= 30000 ? "Priority lane" : "Standard lane"
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{currencyCompact.format(row.original.value)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex size-5 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
aria-label={`Value hint for ${row.original.reference}: ${valueHint}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-48 p-2.5 text-xs leading-5">
|
||||
<div className="flex flex-col">
|
||||
<span>{valueHint}</span>
|
||||
<span className="text-background/80">
|
||||
{numberCompact.format(row.original.units)} units
|
||||
</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function StateCell({ row }: { row: Row<FulfillmentException> }) {
|
||||
const sla = row.original.slaMinutes
|
||||
const slaHint =
|
||||
sla < 0
|
||||
? `${Math.abs(sla)} min overdue`
|
||||
: sla <= 45
|
||||
? `${sla} min buffer`
|
||||
: `Due ${row.original.promise}`
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-start gap-1">
|
||||
<StatusBadge status={row.original.status} />
|
||||
<span className="text-muted-foreground max-w-full truncate text-xs">
|
||||
{slaHint}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskCell({ row }: { row: Row<FulfillmentException> }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex items-center gap-1.5 rounded-full focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
aria-label={`Risk note for ${row.original.reference}: ${row.original.risk}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
<span className="max-w-32 truncate text-xs">{row.original.risk}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs p-3 text-xs leading-5">
|
||||
{row.original.risk}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionsCell({ row }: { row: Row<FulfillmentException> }) {
|
||||
const copyReference = async () => {
|
||||
await navigator.clipboard?.writeText(row.original.reference)
|
||||
toast.success("Reference copied", {
|
||||
description: row.original.reference,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label={`Actions for ${row.original.reference}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-44">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Opening order", {
|
||||
description: row.original.reference,
|
||||
})
|
||||
}
|
||||
>
|
||||
<EyeIcon className="size-4" aria-hidden="true" />
|
||||
View order
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Owner notified", {
|
||||
description: row.original.owner,
|
||||
})
|
||||
}
|
||||
>
|
||||
<BellIcon className="size-4" aria-hidden="true" />
|
||||
Notify owner
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyReference}>
|
||||
<CopyIcon className="size-4" aria-hidden="true" />
|
||||
Copy reference
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
toast.warning("Escalation staged", {
|
||||
description: "Connect this action to your incident workflow.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<TriangleAlertIcon className="size-4" aria-hidden="true" />
|
||||
Escalate
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<FulfillmentException>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
id: "id",
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
enableSorting: false,
|
||||
size: 35,
|
||||
enableResizing: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
headerClassName: "ps-4!",
|
||||
cellClassName: "ps-4!",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "reference",
|
||||
id: "reference",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ReferenceCell row={row} />,
|
||||
size: 138,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Order",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "customer",
|
||||
id: "customer",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <CustomerCell row={row} />,
|
||||
size: 210,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: true,
|
||||
minSize: 190,
|
||||
meta: {
|
||||
headerTitle: "Customer",
|
||||
autoSize: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "lane",
|
||||
id: "lane",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <LaneCell row={row} />,
|
||||
size: 165,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Lane",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "stage",
|
||||
id: "stage",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <StageCell row={row} />,
|
||||
size: 160,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Stage",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "automation",
|
||||
id: "automation",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <AutomationBadge level={row.original.automation} />,
|
||||
size: 112,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Automation",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
id: "value",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ValueCell row={row} />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Value",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "risk",
|
||||
id: "risk",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <RiskCell row={row} />,
|
||||
size: 170,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Risk",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <StateCell row={row} />,
|
||||
size: 142,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "State",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => <ActionsCell row={row} />,
|
||||
size: 46,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
},
|
||||
]
|
||||
@@ -1,369 +0,0 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGrid as ReuiDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import { Label } from "@evobgp/ui/components/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import { TooltipProvider } from "@evobgp/ui/components/tooltip"
|
||||
import {
|
||||
FULFILLMENT_ROWS,
|
||||
fulfillmentSearchBlob,
|
||||
STATUS_ORDER,
|
||||
type FulfillmentStatus,
|
||||
} from "./data"
|
||||
import { columns, StatusBadge } from "./exception-columns"
|
||||
import { SearchIcon, XIcon, FilterIcon, MoreHorizontalIcon, FileDownIcon, RefreshCwIcon, SettingsIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
interface ToolbarProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (value: string) => void
|
||||
selectedStatuses: FulfillmentStatus[]
|
||||
onStatusChange: (checked: boolean, status: FulfillmentStatus) => void
|
||||
onClearFilters: () => void
|
||||
hasActiveFilters: boolean
|
||||
statusCounts: Record<string, number>
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
selectedStatuses,
|
||||
onStatusChange,
|
||||
onClearFilters,
|
||||
hasActiveFilters,
|
||||
statusCounts,
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<InputGroup className="w-full min-w-52 sm:w-60">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search orders..."
|
||||
aria-label="Search orders"
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
aria-label="Clear search"
|
||||
size="icon-xs"
|
||||
onClick={() => onSearchChange("")}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" aria-label="Filter by order status">
|
||||
<FilterIcon aria-hidden="true" />
|
||||
Status
|
||||
{selectedStatuses.length > 0 && (
|
||||
<Badge variant="info-outline">
|
||||
{selectedStatuses.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="flex w-48 flex-col gap-2.5 p-3"
|
||||
>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Filter by status
|
||||
</span>
|
||||
{STATUS_ORDER.map((status) => (
|
||||
<div key={status} className="flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
id={`status-${status.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
checked={selectedStatuses.includes(status)}
|
||||
onCheckedChange={(checked) =>
|
||||
onStatusChange(checked === true, status)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`status-${status.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-2 font-normal"
|
||||
>
|
||||
<StatusBadge status={status} />
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{statusCounts[status] ?? 0}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" aria-label="Exception queue actions">
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
Actions
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.success("Export ready", {
|
||||
description: "Exception queue export prepared.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<FileDownIcon aria-hidden="true" />
|
||||
Export CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.message("Queue refreshed", {
|
||||
description: "Live data would refresh through your API.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<RefreshCwIcon aria-hidden="true" />
|
||||
Refresh
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("View settings", {
|
||||
description: "Column and density controls are available.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
View settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExceptionGrid() {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "value", desc: true },
|
||||
])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<FulfillmentStatus[]>(
|
||||
[]
|
||||
)
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(
|
||||
columns.map((column) => column.id as string)
|
||||
)
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
risk: false,
|
||||
})
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
|
||||
const statusCounts = useMemo(
|
||||
() =>
|
||||
FULFILLMENT_ROWS.reduce(
|
||||
(acc, row) => {
|
||||
acc[row.status] = (acc[row.status] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return FULFILLMENT_ROWS.filter((row) => {
|
||||
const matchesStatus =
|
||||
!selectedStatuses.length || selectedStatuses.includes(row.status)
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
fulfillmentSearchBlob(row).includes(searchQuery.toLowerCase())
|
||||
|
||||
return matchesStatus && matchesSearch
|
||||
})
|
||||
}, [searchQuery, selectedStatuses])
|
||||
|
||||
const hasActiveFilters =
|
||||
searchQuery.trim().length > 0 || selectedStatuses.length > 0
|
||||
|
||||
const resetToFirstPage = () => {
|
||||
setPagination((current) =>
|
||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
|
||||
)
|
||||
}
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value)
|
||||
resetToFirstPage()
|
||||
}
|
||||
|
||||
const handleStatusChange = (checked: boolean, status: FulfillmentStatus) => {
|
||||
setSelectedStatuses((current) =>
|
||||
checked ? [...current, status] : current.filter((item) => item !== status)
|
||||
)
|
||||
resetToFirstPage()
|
||||
}
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSelectedStatuses([])
|
||||
setSearchQuery("")
|
||||
resetToFirstPage()
|
||||
}
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: filteredData,
|
||||
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
|
||||
getRowId: (row) => row.id,
|
||||
state: { pagination, sorting, columnOrder, columnVisibility, rowSelection },
|
||||
columnResizeMode: "onChange",
|
||||
enableRowSelection: true,
|
||||
autoResetPageIndex: false,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={200}>
|
||||
<ReuiDataGrid
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage={
|
||||
filteredData.length === 0
|
||||
? "No fulfillment exceptions match your filters."
|
||||
: undefined
|
||||
}
|
||||
tableLayout={{
|
||||
columnsPinnable: true,
|
||||
columnsResizable: true,
|
||||
columnsMovable: true,
|
||||
columnsVisibility: true,
|
||||
headerSticky: true,
|
||||
dense: true,
|
||||
}}
|
||||
tableClassNames={{
|
||||
bodyRow: "[&>td]:h-16",
|
||||
}}
|
||||
>
|
||||
<Frame variant="default" spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FrameTitle className="text-balance">Exception Queue</FrameTitle>
|
||||
<FrameDescription className="text-xs text-pretty">
|
||||
{filteredData.length} of {FULFILLMENT_ROWS.length} fulfillment
|
||||
records
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toast.info("Create exception", {
|
||||
description:
|
||||
"Connect this button to your incident intake flow.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add exception
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
<FramePanel className="bg-card p-0! shadow-none!">
|
||||
<div className="px-4 py-3">
|
||||
<Toolbar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
selectedStatuses={selectedStatuses}
|
||||
onStatusChange={handleStatusChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
statusCounts={statusCounts}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</FramePanel>
|
||||
<FrameFooter>
|
||||
<DataGridPagination />
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
</ReuiDataGrid>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { type DateRange } from "react-day-picker"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Calendar } from "@evobgp/ui/components/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { CalendarIcon, DownloadIcon } from "lucide-react"
|
||||
|
||||
type PeriodKey = "last30" | "prev30"
|
||||
|
||||
type ReportDateRange = {
|
||||
from: Date
|
||||
to: Date
|
||||
}
|
||||
|
||||
type DateRangePreset = {
|
||||
id: string
|
||||
label: string
|
||||
period: PeriodKey
|
||||
range: ReportDateRange
|
||||
}
|
||||
|
||||
const reportRange = (
|
||||
fromMonth: number,
|
||||
fromDay: number,
|
||||
toMonth: number,
|
||||
toDay: number,
|
||||
year = 2026
|
||||
): ReportDateRange => ({
|
||||
from: new Date(year, fromMonth, fromDay),
|
||||
to: new Date(year, toMonth, toDay),
|
||||
})
|
||||
|
||||
const preset = (
|
||||
id: string,
|
||||
label: string,
|
||||
period: PeriodKey,
|
||||
range: ReportDateRange
|
||||
): DateRangePreset => ({ id, label, period, range })
|
||||
|
||||
const LAST_30_RANGE = reportRange(4, 12, 5, 10)
|
||||
const PREVIOUS_30_RANGE = reportRange(3, 12, 4, 11)
|
||||
|
||||
const REPORT_RANGE_PRESETS: DateRangePreset[] = [
|
||||
preset("today", "Today", "last30", reportRange(5, 10, 5, 10)),
|
||||
preset("yesterday", "Yesterday", "last30", reportRange(5, 9, 5, 9)),
|
||||
preset("last7", "Last 7 days", "last30", reportRange(5, 4, 5, 10)),
|
||||
preset("last30", "Last 30 days", "last30", LAST_30_RANGE),
|
||||
preset("monthToDate", "Month to date", "last30", reportRange(5, 1, 5, 10)),
|
||||
preset("lastMonth", "Last month", "last30", reportRange(4, 1, 4, 31)),
|
||||
preset("yearToDate", "Year to date", "last30", reportRange(0, 1, 5, 10)),
|
||||
preset("lastYear", "Last year", "prev30", reportRange(0, 1, 11, 31, 2025)),
|
||||
]
|
||||
|
||||
const MAX_REPORT_DATE = LAST_30_RANGE.to
|
||||
|
||||
function isSameRange(first: ReportDateRange, second: DateRange) {
|
||||
const secondFrom = second.from
|
||||
const secondTo = second.to ?? second.from
|
||||
|
||||
return (
|
||||
Boolean(secondFrom && secondTo) &&
|
||||
first.from.getTime() === secondFrom?.getTime() &&
|
||||
first.to.getTime() === secondTo?.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRange(
|
||||
range: DateRange | undefined,
|
||||
fallback: ReportDateRange
|
||||
): ReportDateRange {
|
||||
if (!range?.from) return fallback
|
||||
|
||||
const from = range.from
|
||||
const to = range.to ?? range.from
|
||||
|
||||
return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from }
|
||||
}
|
||||
|
||||
function formatReportRange(range: ReportDateRange) {
|
||||
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`
|
||||
}
|
||||
|
||||
function getPeriodForRange(range: ReportDateRange) {
|
||||
const matchingPreset = getMatchingPreset(range)
|
||||
|
||||
if (matchingPreset) return matchingPreset.period
|
||||
return range.to.getTime() <= PREVIOUS_30_RANGE.to.getTime()
|
||||
? "prev30"
|
||||
: "last30"
|
||||
}
|
||||
|
||||
function getMatchingPreset(range: DateRange | undefined) {
|
||||
if (!range?.from || !range.to) return undefined
|
||||
const normalizedRange = normalizeRange(range, LAST_30_RANGE)
|
||||
|
||||
return REPORT_RANGE_PRESETS.find((preset) =>
|
||||
isSameRange(preset.range, normalizedRange)
|
||||
)
|
||||
}
|
||||
|
||||
function ReportDateRangePicker({
|
||||
period,
|
||||
onPeriodChange,
|
||||
}: {
|
||||
period: PeriodKey
|
||||
onPeriodChange: (value: PeriodKey) => void
|
||||
}) {
|
||||
const initialRange = period === "prev30" ? PREVIOUS_30_RANGE : LAST_30_RANGE
|
||||
const [open, setOpen] = useState(false)
|
||||
const [committedRange, setCommittedRange] =
|
||||
useState<ReportDateRange>(initialRange)
|
||||
const [draftRange, setDraftRange] = useState<DateRange | undefined>(
|
||||
initialRange
|
||||
)
|
||||
|
||||
const selectedPresetId = getMatchingPreset(draftRange ?? committedRange)?.id
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen) {
|
||||
setDraftRange(committedRange)
|
||||
}
|
||||
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const nextRange = normalizeRange(draftRange, committedRange)
|
||||
|
||||
setCommittedRange(nextRange)
|
||||
onPeriodChange(getPeriodForRange(nextRange))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="group/pick-date w-[250px] max-w-full justify-between leading-none font-normal tabular-nums"
|
||||
>
|
||||
<span className="truncate">
|
||||
{formatReportRange(committedRange)}
|
||||
</span>
|
||||
<CalendarIcon className="text-muted-foreground/80 group-hover/pick-date:text-foreground shrink-0 transition-colors" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-auto p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col sm:grid sm:grid-cols-[10rem_1fr]">
|
||||
<div className="border-border flex flex-wrap gap-1 border-b p-2 sm:flex-col sm:border-r sm:border-b-0">
|
||||
{REPORT_RANGE_PRESETS.map((preset) => {
|
||||
const selected = selectedPresetId === preset.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "secondary" : "ghost"}
|
||||
className={
|
||||
selected
|
||||
? "justify-start"
|
||||
: "text-muted-foreground justify-start"
|
||||
}
|
||||
onClick={() => setDraftRange(preset.range)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={draftRange}
|
||||
onSelect={setDraftRange}
|
||||
numberOfMonths={2}
|
||||
defaultMonth={draftRange?.from ?? committedRange.from}
|
||||
disabled={{
|
||||
after: MAX_REPORT_DATE,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border flex items-center justify-between gap-2 border-t px-3 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDraftRange(LAST_30_RANGE)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDraftRange(committedRange)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleApply}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
// Header action controls reused from the solution-agents-8 report toolbar.
|
||||
export function NavbarActions() {
|
||||
const [periodKey, setPeriodKey] = useState<PeriodKey>("last30")
|
||||
|
||||
function handleExport() {
|
||||
toast.success("Export queued", {
|
||||
description: "Fulfillment command report is being prepared.",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ReportDateRangePicker period={periodKey} onPeriodChange={setPeriodKey} />
|
||||
|
||||
<Button size="sm" type="button" onClick={handleExport}>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span className="hidden sm:block">Export</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@evobgp/ui/components/breadcrumb"
|
||||
|
||||
// Navbar breadcrumb
|
||||
|
||||
export function NavbarBreadcrumb() {
|
||||
return (
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Operations</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">Fulfillment</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { TEAM_EXTRA_COUNT, TEAM_MEMBERS } from "./data"
|
||||
import { UserPlusIcon } from "lucide-react"
|
||||
|
||||
// Header presence controls with team avatars and invite action.
|
||||
|
||||
export function NavbarPresence() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleInvite = () => {
|
||||
if (!email.trim()) return
|
||||
setEmail("")
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<AvatarGroup>
|
||||
{TEAM_MEMBERS.map((member, index) => (
|
||||
<Avatar key={index} size="sm">
|
||||
<AvatarImage src={member.src} alt={member.name} />
|
||||
<AvatarFallback className="text-[9px]! font-medium">
|
||||
{member.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
<AvatarGroupCount className="text-[10px]! font-medium">
|
||||
+{TEAM_EXTRA_COUNT}
|
||||
</AvatarGroupCount>
|
||||
</AvatarGroup>
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Invite team member"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent sideOffset={7} align="end" className="w-72">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="text-foreground text-sm">Invite team member</h4>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleInvite()}
|
||||
/>
|
||||
<Button onClick={handleInvite} disabled={!email.trim()}>
|
||||
Send invite
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { NavbarActions } from "./navbar-actions"
|
||||
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
|
||||
|
||||
// Navbar with breadcrumb and report range actions.
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<header
|
||||
className="flex min-h-9 w-full shrink-0 items-center justify-between gap-2 pb-1"
|
||||
aria-label="Fulfillment command header"
|
||||
>
|
||||
<NavbarBreadcrumb />
|
||||
|
||||
<NavbarActions />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Dashboard } from "./components/dashboard"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main className="bg-background min-h-svh w-full p-3 sm:p-4 lg:p-6">
|
||||
<Dashboard />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { Area, AreaChart, ResponsiveContainer, Tooltip } from "recharts"
|
||||
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
|
||||
import { activeUsersData, customersData, revenueData } from "./data"
|
||||
|
||||
// Business metric cards
|
||||
|
||||
const businessCards = [
|
||||
{
|
||||
title: "Revenue",
|
||||
period: "reui.io, 28 days",
|
||||
value: "$6.2K",
|
||||
timestamp: "",
|
||||
data: revenueData,
|
||||
color: "var(--color-emerald-500)",
|
||||
gradientId: "revenueGradient",
|
||||
formatValue: (value: number) => `$${(value / 1000).toFixed(1)}K`,
|
||||
},
|
||||
{
|
||||
title: "Signups",
|
||||
period: "Last 28 days",
|
||||
value: "4,238",
|
||||
timestamp: "3h ago",
|
||||
data: customersData,
|
||||
color: "var(--color-blue-500)",
|
||||
gradientId: "customersGradient",
|
||||
formatValue: (value: number) => `${(value / 1000).toFixed(1)}K`,
|
||||
},
|
||||
{
|
||||
title: "Active Licenses",
|
||||
period: "ReUI Cloud, 28 days",
|
||||
value: "4,238",
|
||||
timestamp: "1h ago",
|
||||
data: activeUsersData,
|
||||
color: "var(--color-violet-500)",
|
||||
gradientId: "usersGradient",
|
||||
formatValue: (value: number) => `${(value / 1000).toFixed(1)}K`,
|
||||
},
|
||||
]
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<div className="@container w-full max-w-6xl">
|
||||
<div className="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
|
||||
{businessCards.map((card) => (
|
||||
<Card key={card.title}>
|
||||
<CardContent className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="text-sm font-semibold">{card.title}</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="flex items-end justify-between gap-2.5">
|
||||
{/* Value */}
|
||||
<div className="flex flex-col gap-px pb-2">
|
||||
<div className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{card.period}
|
||||
</div>
|
||||
<div className="text-foreground text-xl font-semibold tracking-tight">
|
||||
{card.value}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-16 w-full max-w-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={card.data}
|
||||
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={card.gradientId}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={card.color}
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={card.color}
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: card.color,
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: "2 2",
|
||||
}}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const value = payload[0].value as number
|
||||
return (
|
||||
<Card className="bg-popover text-popover-foreground pointer-events-none p-0 shadow-md">
|
||||
<CardContent className="flex min-w-24 flex-col gap-1 px-2 py-1.5">
|
||||
<span className="text-muted-foreground text-[10px] leading-none font-medium">
|
||||
{card.title}
|
||||
</span>
|
||||
<span className="text-popover-foreground text-xs leading-none font-semibold tabular-nums">
|
||||
{card.formatValue(value)}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={card.color}
|
||||
fill={`url(#${card.gradientId})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 4,
|
||||
fill: card.color,
|
||||
stroke: "white",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
import { type ComponentProps } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import { type ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { type ModuleRecord, type ModuleStatus } from "./data"
|
||||
import { CalendarDaysIcon, FileTextIcon, StarIcon, MoreHorizontalIcon, CopyIcon, ArchiveIcon } from "lucide-react"
|
||||
|
||||
export type ModuleRowAction = "open" | "favorite" | "duplicate" | "archive"
|
||||
|
||||
const moduleStatusVariant: Record<
|
||||
ModuleStatus,
|
||||
ComponentProps<typeof Badge>["variant"]
|
||||
> = {
|
||||
Planned: "info-outline",
|
||||
Backlog: "outline",
|
||||
"In Progress": "warning-outline",
|
||||
}
|
||||
|
||||
const moduleStatusDotClass: Record<ModuleStatus, string> = {
|
||||
Planned: "bg-sky-500 dark:bg-sky-400",
|
||||
Backlog: "bg-muted-foreground/50",
|
||||
"In Progress": "bg-amber-500 dark:bg-amber-400",
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<span
|
||||
className="bg-muted-foreground/45 size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function getProgressToneClass(value: number) {
|
||||
if (value >= 75) return "text-emerald-500 dark:text-emerald-400"
|
||||
if (value >= 40) return "text-amber-500 dark:text-amber-400"
|
||||
if (value > 0) return "text-sky-500 dark:text-sky-400"
|
||||
|
||||
return "text-muted-foreground/35"
|
||||
}
|
||||
|
||||
function getWindowDurationLabel(module: ModuleRecord) {
|
||||
const start = new Date(module.dateStart).getTime()
|
||||
const end = new Date(module.dateEnd).getTime()
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
const days = Math.max(1, Math.round((end - start) / dayMs))
|
||||
|
||||
return `${days}-day window`
|
||||
}
|
||||
|
||||
function getCompactDateRange(module: ModuleRecord) {
|
||||
return module.dateRange.replace(/, 2026/g, "")
|
||||
}
|
||||
|
||||
function ModuleProgress({ module }: { module: ModuleRecord }) {
|
||||
const value = module.progress
|
||||
const radius = 18
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const dashOffset = circumference - (value / 100) * circumference
|
||||
const progressClassName = getProgressToneClass(value)
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="relative size-10 shrink-0">
|
||||
<svg
|
||||
viewBox="0 0 44 44"
|
||||
className="absolute inset-0 size-10 -rotate-90"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-muted-foreground/20"
|
||||
strokeWidth="3.25"
|
||||
/>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className={cn("stroke-current", progressClassName)}
|
||||
strokeWidth="3.25"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-muted-foreground absolute inset-0 flex items-center justify-center text-[9px] leading-none font-medium tabular-nums">
|
||||
{value}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground text-sm font-medium tabular-nums">
|
||||
{value}% ready
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs tabular-nums">
|
||||
{module.tasksCompleted}/{module.tasksTotal} tasks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleNameCell({ module }: { module: ModuleRecord }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-foreground truncate text-sm leading-5 font-medium">
|
||||
{module.name}
|
||||
</span>
|
||||
<div className="text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="shrink-0">{module.kind}</span>
|
||||
<DotSeparator />
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<Avatar className="size-4 shrink-0">
|
||||
{module.owner.avatar ? (
|
||||
<AvatarImage src={module.owner.avatar} alt={module.owner.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{module.owner.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">{module.owner.name}</span>
|
||||
</span>
|
||||
<DotSeparator />
|
||||
<span className="truncate font-mono tracking-wide">
|
||||
{module.domain}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleDateCell({ module }: { module: ModuleRecord }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate text-sm font-medium tabular-nums">
|
||||
{getCompactDateRange(module)}
|
||||
</span>
|
||||
<span className="text-muted-foreground inline-flex min-w-0 items-center gap-1.5 truncate text-xs">
|
||||
<CalendarDaysIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{getWindowDurationLabel(module)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleStatusCell({ module }: { module: ModuleRecord }) {
|
||||
return (
|
||||
<Badge variant={moduleStatusVariant[module.status]}>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full!",
|
||||
moduleStatusDotClass[module.status]
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{module.status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleActions({
|
||||
module,
|
||||
onAction,
|
||||
}: {
|
||||
module: ModuleRecord
|
||||
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Open ${module.name}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onAction("open", module)
|
||||
}}
|
||||
>
|
||||
<FileTextIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`${module.favorite ? "Unfavorite" : "Favorite"} ${
|
||||
module.name
|
||||
}`}
|
||||
className={cn(module.favorite && "text-amber-500")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onAction("favorite", module)
|
||||
}}
|
||||
>
|
||||
<StarIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`More actions for ${module.name}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onAction("duplicate", module)}>
|
||||
<CopyIcon aria-hidden="true" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onAction("archive", module)}
|
||||
>
|
||||
<ArchiveIcon aria-hidden="true" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function createModuleGridColumns({
|
||||
onAction,
|
||||
}: {
|
||||
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
|
||||
}): ColumnDef<ModuleRecord>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "progress",
|
||||
id: "progress",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ModuleProgress module={row.original} />,
|
||||
size: 210,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Progress",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ModuleNameCell module={row.original} />,
|
||||
minSize: 300,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
autoSize: true,
|
||||
headerTitle: "Module",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "dateStart",
|
||||
id: "dateStart",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ModuleDateCell module={row.original} />,
|
||||
sortingFn: (rowA, rowB) =>
|
||||
new Date(rowA.original.dateStart).getTime() -
|
||||
new Date(rowB.original.dateStart).getTime(),
|
||||
size: 180,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Window",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ModuleStatusCell module={row.original} />,
|
||||
size: 126,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Status",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ModuleActions module={row.original} onAction={onAction} />
|
||||
),
|
||||
size: 104,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
headerTitle: "Actions",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Chart } from "./chart"
|
||||
import { ModulesDataGridView } from "./data-grid-view"
|
||||
import { Navbar } from "./navbar"
|
||||
|
||||
/**
|
||||
* ReUI operations dashboard: navbar -> metric charts -> module grid.
|
||||
* The sections are copied from reviewed donor blocks.
|
||||
* Customize: swap the records and chart series in data.tsx first.
|
||||
*/
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<div className="bg-background text-foreground flex min-h-svh w-full flex-col">
|
||||
<Navbar />
|
||||
|
||||
<main
|
||||
className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 p-4 pt-6 sm:p-8"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
ReUI Operations Dashboard
|
||||
</h1>
|
||||
|
||||
{/* Metric Charts */}
|
||||
<Chart />
|
||||
|
||||
{/* Module Grid */}
|
||||
<ModulesDataGridView />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import {
|
||||
DataGridTable,
|
||||
DataGridTableHeader,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import { createModuleGridColumns, type ModuleRowAction } from "./columns"
|
||||
import {
|
||||
MODULE_RECORDS,
|
||||
MODULE_STATUS_OPTIONS,
|
||||
type ModuleRecord,
|
||||
type ModuleStatus,
|
||||
} from "./data"
|
||||
import { CircleCheckIcon, FlagIcon, ChevronRightIcon, PackageIcon, SearchIcon, XIcon, ArrowUpDownIcon, ChevronDownIcon, FilterIcon } from "lucide-react"
|
||||
|
||||
type ModuleSort = "name" | "dateStart" | "progress" | "status"
|
||||
|
||||
const sortLabels: Record<ModuleSort, string> = {
|
||||
name: "Name",
|
||||
dateStart: "Window",
|
||||
progress: "Progress",
|
||||
status: "Status",
|
||||
}
|
||||
|
||||
const EMPTY_MODULE_MESSAGE = "No ReUI modules match the selected filters."
|
||||
|
||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
||||
return [{ id: sortBy, desc: false }]
|
||||
}
|
||||
|
||||
function getModuleSearchBlob(module: ModuleRecord) {
|
||||
return [
|
||||
module.name,
|
||||
module.id,
|
||||
module.kind,
|
||||
module.domain,
|
||||
module.owner.name,
|
||||
module.owner.role,
|
||||
module.health,
|
||||
module.status,
|
||||
module.dateRange,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function ModulesDataGridView() {
|
||||
const [modules, setModules] = useState<ModuleRecord[]>(MODULE_RECORDS)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<ModuleStatus[]>([])
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [sortBy, setSortBy] = useState<ModuleSort>("name")
|
||||
const [sorting, setSorting] = useState<SortingState>(() =>
|
||||
buildSorting("name")
|
||||
)
|
||||
|
||||
const filteredModules = useMemo(() => {
|
||||
const normalizedSearchQuery = searchQuery.trim().toLowerCase()
|
||||
|
||||
return modules.filter((module) => {
|
||||
const matchesSearch =
|
||||
normalizedSearchQuery.length === 0 ||
|
||||
getModuleSearchBlob(module).includes(normalizedSearchQuery)
|
||||
const matchesStatus =
|
||||
selectedStatuses.length === 0 ||
|
||||
selectedStatuses.includes(module.status)
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
}, [modules, searchQuery, selectedStatuses])
|
||||
|
||||
const activeFilterCount = selectedStatuses.length
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
pageIndex: 0,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearchQuery(value)
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const handleStatusToggle = useCallback(
|
||||
(status: ModuleStatus, checked: boolean) => {
|
||||
setSelectedStatuses((current) => {
|
||||
if (checked) {
|
||||
return current.includes(status) ? current : [...current, status]
|
||||
}
|
||||
|
||||
return current.filter((item) => item !== status)
|
||||
})
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(value: string) => {
|
||||
const nextSort = value as ModuleSort
|
||||
setSortBy(nextSort)
|
||||
setSorting(buildSorting(nextSort))
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const handleModuleAction = useCallback(
|
||||
(action: ModuleRowAction, module: ModuleRecord) => {
|
||||
if (action === "favorite") {
|
||||
setModules((current) =>
|
||||
current.map((item) =>
|
||||
item.id === module.id
|
||||
? {
|
||||
...item,
|
||||
favorite: !item.favorite,
|
||||
}
|
||||
: item
|
||||
)
|
||||
)
|
||||
toast.success(module.favorite ? "Removed favorite" : "Module starred", {
|
||||
description: module.name,
|
||||
icon: (
|
||||
<CircleCheckIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "open") {
|
||||
toast.info("Open ReUI module", {
|
||||
description: `${module.name} (${module.kind})`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast.message(
|
||||
action === "duplicate" ? "Duplicate module" : "Archive module",
|
||||
{
|
||||
description: `Connect this action to your ${module.name} flow.`,
|
||||
}
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleAddModule = () => {
|
||||
toast.success("Add ReUI module", {
|
||||
description: "Open your module creation dialog.",
|
||||
icon: (
|
||||
<CircleCheckIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => createModuleGridColumns({ onAction: handleModuleAction }),
|
||||
[handleModuleAction]
|
||||
)
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: filteredModules,
|
||||
columns,
|
||||
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getRowId: (row) => row.id,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredModules.length}
|
||||
emptyMessage={EMPTY_MODULE_MESSAGE}
|
||||
tableLayout={{
|
||||
dense: true,
|
||||
rowBorder: true,
|
||||
headerSticky: false,
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
width: "fixed",
|
||||
}}
|
||||
tableClassNames={{
|
||||
bodyRow: "group/module-row [&>td]:h-16",
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full max-w-6xl flex-col">
|
||||
<div className="flex flex-col gap-3 border-b px-0 py-3 lg:min-h-14 lg:flex-row lg:items-center lg:gap-4 lg:py-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FlagIcon className="size-3.5 shrink-0 fill-amber-400 text-amber-400" aria-hidden="true" />
|
||||
<span className="text-muted-foreground truncate text-sm">ReUI</span>
|
||||
<ChevronRightIcon className="text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
|
||||
<PackageIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
||||
<h2 className="text-foreground truncate text-sm font-medium">
|
||||
Modules
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 lg:ml-auto lg:flex-nowrap">
|
||||
<InputGroup className="w-full min-w-40 sm:w-48">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchQuery}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
placeholder="Search..."
|
||||
aria-label="Search modules"
|
||||
/>
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label="Clear search"
|
||||
onClick={() => handleSearchChange("")}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline">
|
||||
<ArrowUpDownIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{sortLabels[sortBy]}
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
<DropdownMenuGroup>
|
||||
{(["name", "dateStart", "progress", "status"] as const).map(
|
||||
(value) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => handleSortChange(value)}
|
||||
>
|
||||
{sortLabels[value]}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline">
|
||||
<FilterIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Filters
|
||||
{activeFilterCount > 0 ? (
|
||||
<Badge variant="outline" radius="full">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
{MODULE_STATUS_OPTIONS.map((status) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={status}
|
||||
checked={selectedStatuses.includes(status)}
|
||||
closeOnClick={false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleStatusToggle(status, checked === true)
|
||||
}
|
||||
>
|
||||
{status}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{activeFilterCount > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
closeOnClick={false}
|
||||
onClick={() => {
|
||||
setSelectedStatuses([])
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
Reset filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button type="button" onClick={handleAddModule}>
|
||||
Add Module
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
) : (
|
||||
<>
|
||||
<DataGridScrollArea>
|
||||
<DataGridTableHeader />
|
||||
</DataGridScrollArea>
|
||||
<div className="text-muted-foreground flex min-h-48 w-full items-center justify-center px-4 text-center text-sm">
|
||||
{EMPTY_MODULE_MESSAGE}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t px-0 py-3">
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridPagination
|
||||
sizes={[10, 15, 20]}
|
||||
info="{from} - {to} of {count} modules"
|
||||
className="py-0"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
0 modules
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
export type ModuleStatus = "Planned" | "Backlog" | "In Progress"
|
||||
|
||||
export type ModuleKind = "System" | "Feature" | "Area"
|
||||
|
||||
export type ModuleHealth = "On Track" | "Watch" | "Blocked"
|
||||
|
||||
export interface ModuleOwner {
|
||||
name: string
|
||||
initials: string
|
||||
role: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface ModuleRecord {
|
||||
id: string
|
||||
name: string
|
||||
kind: ModuleKind
|
||||
owner: ModuleOwner
|
||||
domain: string
|
||||
progress: number
|
||||
tasksCompleted: number
|
||||
tasksTotal: number
|
||||
contributors: number
|
||||
blockers: number
|
||||
health: ModuleHealth
|
||||
dateStart: string
|
||||
dateEnd: string
|
||||
dateRange: string
|
||||
status: ModuleStatus
|
||||
favorite: boolean
|
||||
}
|
||||
|
||||
export const MODULE_STATUS_OPTIONS: ModuleStatus[] = [
|
||||
"Planned",
|
||||
"Backlog",
|
||||
"In Progress",
|
||||
]
|
||||
|
||||
const moduleOwners = {
|
||||
maya: {
|
||||
name: "Nora Vale",
|
||||
initials: "NV",
|
||||
role: "ReUI release lead",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
jonah: {
|
||||
name: "Jonah Lee",
|
||||
initials: "JL",
|
||||
role: "ReUI product ops",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
nina: {
|
||||
name: "Nina Santos",
|
||||
initials: "NS",
|
||||
role: "Docs owner",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
elijah: {
|
||||
name: "Elijah Morgan",
|
||||
initials: "EM",
|
||||
role: "License lead",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
priya: {
|
||||
name: "Priya Shah",
|
||||
initials: "PS",
|
||||
role: "Lifecycle PM",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
omar: {
|
||||
name: "Omar Haddad",
|
||||
initials: "OH",
|
||||
role: "Trust owner",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507591064344-4c6ce005b128?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
sofia: {
|
||||
name: "Sofia Romero",
|
||||
initials: "SR",
|
||||
role: "Content lead",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
kenji: {
|
||||
name: "Kenji Tan",
|
||||
initials: "KT",
|
||||
role: "Platform lead",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
lena: {
|
||||
name: "Lena Wade",
|
||||
initials: "LW",
|
||||
role: "Developer tools",
|
||||
},
|
||||
} satisfies Record<string, ModuleOwner>
|
||||
|
||||
export const MODULE_RECORDS: ModuleRecord[] = [
|
||||
{
|
||||
id: "core-workflow",
|
||||
name: "Registry Sync",
|
||||
kind: "System",
|
||||
owner: moduleOwners.maya,
|
||||
domain: "registry.reui.io",
|
||||
progress: 25,
|
||||
tasksCompleted: 8,
|
||||
tasksTotal: 32,
|
||||
contributors: 6,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-17",
|
||||
dateEnd: "2026-05-01",
|
||||
dateRange: "Apr 17 - May 01, 2026",
|
||||
status: "Planned",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "onboarding-flow",
|
||||
name: "Pro Onboarding",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.jonah,
|
||||
domain: "pro.reui.io",
|
||||
progress: 0,
|
||||
tasksCompleted: 0,
|
||||
tasksTotal: 18,
|
||||
contributors: 4,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-19",
|
||||
dateEnd: "2026-05-03",
|
||||
dateRange: "Apr 19 - May 03, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "workspace-setup",
|
||||
name: "Docs Portal",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.nina,
|
||||
domain: "docs.reui.io",
|
||||
progress: 0,
|
||||
tasksCompleted: 2,
|
||||
tasksTotal: 14,
|
||||
contributors: 3,
|
||||
blockers: 1,
|
||||
health: "Blocked",
|
||||
dateStart: "2026-04-21",
|
||||
dateEnd: "2026-05-05",
|
||||
dateRange: "Apr 21 - May 05, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "permission-matrix",
|
||||
name: "Access Matrix",
|
||||
kind: "System",
|
||||
owner: moduleOwners.maya,
|
||||
domain: "admin.reui.io",
|
||||
progress: 42,
|
||||
tasksCompleted: 11,
|
||||
tasksTotal: 26,
|
||||
contributors: 5,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-22",
|
||||
dateEnd: "2026-05-06",
|
||||
dateRange: "Apr 22 - May 06, 2026",
|
||||
status: "In Progress",
|
||||
favorite: true,
|
||||
},
|
||||
{
|
||||
id: "billing-rules",
|
||||
name: "License Billing",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.elijah,
|
||||
domain: "billing.reui.io",
|
||||
progress: 64,
|
||||
tasksCompleted: 21,
|
||||
tasksTotal: 33,
|
||||
contributors: 7,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-18",
|
||||
dateEnd: "2026-05-02",
|
||||
dateRange: "Apr 18 - May 02, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "notification-center",
|
||||
name: "Release Notes",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.priya,
|
||||
domain: "changelog.reui.io",
|
||||
progress: 18,
|
||||
tasksCompleted: 5,
|
||||
tasksTotal: 28,
|
||||
contributors: 4,
|
||||
blockers: 2,
|
||||
health: "Blocked",
|
||||
dateStart: "2026-04-23",
|
||||
dateEnd: "2026-05-09",
|
||||
dateRange: "Apr 23 - May 09, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "audit-trail",
|
||||
name: "Trust Audit",
|
||||
kind: "System",
|
||||
owner: moduleOwners.omar,
|
||||
domain: "trust.reui.io",
|
||||
progress: 76,
|
||||
tasksCompleted: 19,
|
||||
tasksTotal: 25,
|
||||
contributors: 5,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-15",
|
||||
dateEnd: "2026-04-30",
|
||||
dateRange: "Apr 15 - Apr 30, 2026",
|
||||
status: "In Progress",
|
||||
favorite: true,
|
||||
},
|
||||
{
|
||||
id: "template-library",
|
||||
name: "Block Library",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.sofia,
|
||||
domain: "blocks.reui.io",
|
||||
progress: 33,
|
||||
tasksCompleted: 10,
|
||||
tasksTotal: 30,
|
||||
contributors: 6,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-24",
|
||||
dateEnd: "2026-05-10",
|
||||
dateRange: "Apr 24 - May 10, 2026",
|
||||
status: "Planned",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "integration-hub",
|
||||
name: "Integration Hub",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.kenji,
|
||||
domain: "integrations.reui.io",
|
||||
progress: 58,
|
||||
tasksCompleted: 14,
|
||||
tasksTotal: 24,
|
||||
contributors: 8,
|
||||
blockers: 1,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-20",
|
||||
dateEnd: "2026-05-04",
|
||||
dateRange: "Apr 20 - May 04, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "api-console",
|
||||
name: "API Console",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.lena,
|
||||
domain: "api.reui.io",
|
||||
progress: 91,
|
||||
tasksCompleted: 29,
|
||||
tasksTotal: 32,
|
||||
contributors: 4,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-12",
|
||||
dateEnd: "2026-04-26",
|
||||
dateRange: "Apr 12 - Apr 26, 2026",
|
||||
status: "In Progress",
|
||||
favorite: true,
|
||||
},
|
||||
{
|
||||
id: "role-automation",
|
||||
name: "Role Automation",
|
||||
kind: "System",
|
||||
owner: moduleOwners.maya,
|
||||
domain: "admin.reui.io",
|
||||
progress: 12,
|
||||
tasksCompleted: 3,
|
||||
tasksTotal: 25,
|
||||
contributors: 3,
|
||||
blockers: 1,
|
||||
health: "Blocked",
|
||||
dateStart: "2026-04-25",
|
||||
dateEnd: "2026-05-12",
|
||||
dateRange: "Apr 25 - May 12, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "workspace-invites",
|
||||
name: "Team Invites",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.jonah,
|
||||
domain: "teams.reui.io",
|
||||
progress: 47,
|
||||
tasksCompleted: 15,
|
||||
tasksTotal: 32,
|
||||
contributors: 5,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-19",
|
||||
dateEnd: "2026-05-06",
|
||||
dateRange: "Apr 19 - May 06, 2026",
|
||||
status: "Planned",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "release-checklist",
|
||||
name: "Release Checklist",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.nina,
|
||||
domain: "release.reui.io",
|
||||
progress: 84,
|
||||
tasksCompleted: 26,
|
||||
tasksTotal: 31,
|
||||
contributors: 7,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-16",
|
||||
dateEnd: "2026-05-01",
|
||||
dateRange: "Apr 16 - May 01, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "reporting-digest",
|
||||
name: "Usage Digest",
|
||||
kind: "Feature",
|
||||
owner: moduleOwners.priya,
|
||||
domain: "reports.reui.io",
|
||||
progress: 5,
|
||||
tasksCompleted: 2,
|
||||
tasksTotal: 38,
|
||||
contributors: 3,
|
||||
blockers: 0,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-28",
|
||||
dateEnd: "2026-05-16",
|
||||
dateRange: "Apr 28 - May 16, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "security-review",
|
||||
name: "Security Review",
|
||||
kind: "System",
|
||||
owner: moduleOwners.omar,
|
||||
domain: "trust.reui.io",
|
||||
progress: 69,
|
||||
tasksCompleted: 18,
|
||||
tasksTotal: 26,
|
||||
contributors: 6,
|
||||
blockers: 2,
|
||||
health: "Blocked",
|
||||
dateStart: "2026-04-18",
|
||||
dateEnd: "2026-05-07",
|
||||
dateRange: "Apr 18 - May 07, 2026",
|
||||
status: "In Progress",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "help-center",
|
||||
name: "Help Center",
|
||||
kind: "Area",
|
||||
owner: moduleOwners.sofia,
|
||||
domain: "help.reui.io",
|
||||
progress: 39,
|
||||
tasksCompleted: 9,
|
||||
tasksTotal: 23,
|
||||
contributors: 4,
|
||||
blockers: 0,
|
||||
health: "On Track",
|
||||
dateStart: "2026-04-23",
|
||||
dateEnd: "2026-05-11",
|
||||
dateRange: "Apr 23 - May 11, 2026",
|
||||
status: "Planned",
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
id: "data-retention",
|
||||
name: "Data Retention",
|
||||
kind: "System",
|
||||
owner: moduleOwners.kenji,
|
||||
domain: "privacy.reui.io",
|
||||
progress: 22,
|
||||
tasksCompleted: 7,
|
||||
tasksTotal: 32,
|
||||
contributors: 5,
|
||||
blockers: 1,
|
||||
health: "Watch",
|
||||
dateStart: "2026-04-27",
|
||||
dateEnd: "2026-05-14",
|
||||
dateRange: "Apr 27 - May 14, 2026",
|
||||
status: "Backlog",
|
||||
favorite: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const revenueData = [
|
||||
{ value: 1000 },
|
||||
{ value: 4500 },
|
||||
{ value: 2000 },
|
||||
{ value: 5200 },
|
||||
{ value: 1500 },
|
||||
{ value: 6100 },
|
||||
{ value: 3000 },
|
||||
{ value: 6800 },
|
||||
{ value: 2000 },
|
||||
{ value: 1000 },
|
||||
{ value: 4000 },
|
||||
{ value: 2000 },
|
||||
{ value: 3000 },
|
||||
{ value: 2000 },
|
||||
{ value: 6238 },
|
||||
]
|
||||
|
||||
export const customersData = [
|
||||
{ value: 2000 },
|
||||
{ value: 4500 },
|
||||
{ value: 2000 },
|
||||
{ value: 5200 },
|
||||
{ value: 1500 },
|
||||
{ value: 5100 },
|
||||
{ value: 2500 },
|
||||
{ value: 6800 },
|
||||
{ value: 1800 },
|
||||
{ value: 1000 },
|
||||
{ value: 3000 },
|
||||
{ value: 2000 },
|
||||
{ value: 2700 },
|
||||
{ value: 2000 },
|
||||
{ value: 4238 },
|
||||
]
|
||||
|
||||
export const activeUsersData = [
|
||||
{ value: 2000 },
|
||||
{ value: 3500 },
|
||||
{ value: 2000 },
|
||||
{ value: 5200 },
|
||||
{ value: 1200 },
|
||||
{ value: 4100 },
|
||||
{ value: 3500 },
|
||||
{ value: 5800 },
|
||||
{ value: 2000 },
|
||||
{ value: 800 },
|
||||
{ value: 3000 },
|
||||
{ value: 1000 },
|
||||
{ value: 4000 },
|
||||
{ value: 2000 },
|
||||
{ value: 4238 },
|
||||
]
|
||||
|
||||
export type TeamMember = {
|
||||
src: string
|
||||
name: string
|
||||
initials: string
|
||||
}
|
||||
|
||||
export const TEAM_MEMBERS: TeamMember[] = [
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
name: "Mira Stone",
|
||||
initials: "MS",
|
||||
},
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
|
||||
name: "Alex Johnson",
|
||||
initials: "AJ",
|
||||
},
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
||||
name: "Sarah Chen",
|
||||
initials: "SC",
|
||||
},
|
||||
]
|
||||
|
||||
export const TEAM_EXTRA_COUNT = 8
|
||||
@@ -1,67 +0,0 @@
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evobgp/ui/components/dropdown-menu"
|
||||
import { PlusIcon, MoreHorizontalIcon, CopyIcon, Share2Icon, DownloadIcon, SettingsIcon } from "lucide-react"
|
||||
|
||||
// Navbar actions with a primary module action and overflow menu.
|
||||
|
||||
export function NavbarActions() {
|
||||
const handleAddModule = () => {
|
||||
toast.success("Add Module", {
|
||||
description: "Open your module creation dialog.",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button type="button" onClick={handleAddModule}>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
<span className="sr-only md:not-sr-only">Add Module</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" aria-label="More options" />
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent sideOffset={7} align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<CopyIcon aria-hidden="true" />
|
||||
Copy link
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<Share2Icon aria-hidden="true" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
Export
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@evobgp/ui/components/breadcrumb"
|
||||
import { HouseIcon, LayoutDashboardIcon } from "lucide-react"
|
||||
|
||||
// Navbar breadcrumb
|
||||
|
||||
export function NavbarBreadcrumb() {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
{/* List */}
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#" className="flex items-center md:gap-1.5">
|
||||
<HouseIcon className="text-muted-foreground size-3.5" aria-hidden="true" />
|
||||
<span className="hidden md:block">Home</span>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
||||
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
|
||||
<LayoutDashboardIcon className="text-muted-foreground size-3.5" aria-hidden="true" />
|
||||
<span className="hidden md:block">ReUI</span>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { Separator } from "@evobgp/ui/components/separator"
|
||||
import { TEAM_EXTRA_COUNT, TEAM_MEMBERS } from "./data"
|
||||
import { UserPlusIcon } from "lucide-react"
|
||||
|
||||
// Navbar presence with team avatars and invite
|
||||
|
||||
export function NavbarPresence() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleInvite = () => {
|
||||
if (!email.trim()) return
|
||||
setEmail("")
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* List */}
|
||||
<AvatarGroup>
|
||||
{TEAM_MEMBERS.map((member, index) => (
|
||||
<Avatar key={index} size="sm">
|
||||
<AvatarImage src={member.src} alt={member.name} />
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{member.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
<AvatarGroupCount>+{TEAM_EXTRA_COUNT}</AvatarGroupCount>
|
||||
</AvatarGroup>
|
||||
|
||||
<Separator orientation="vertical" className="my-auto h-4" />
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={<Button variant="outline" aria-label="Invite team member" />}
|
||||
>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
<span className="hidden md:block">Invite</span>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent sideOffset={7} align="end" className="w-72">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="text-foreground text-sm">Invite team member</h4>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="name@reui.io"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleInvite()}
|
||||
/>
|
||||
<Button onClick={handleInvite} disabled={!email.trim()}>
|
||||
Send invite
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NavbarActions } from "./navbar-actions"
|
||||
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
|
||||
import { NavbarPresence } from "./navbar-presence"
|
||||
|
||||
// Navbar with breadcrumb, team presence, and actions
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<header className="border-border bg-background sticky top-0 z-20 flex h-12 w-full shrink-0 items-center justify-between gap-2 border-b px-4">
|
||||
{/* Left - breadcrumb */}
|
||||
<NavbarBreadcrumb />
|
||||
|
||||
{/* Right - team presence + actions */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<NavbarPresence />
|
||||
<NavbarActions />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Dashboard } from "./components/dashboard"
|
||||
|
||||
export function Page() {
|
||||
return <Dashboard />
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
// Deterministic per-dot value so the field reads as noise, not a flat grid.
|
||||
function grain(i: number, j: number) {
|
||||
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
|
||||
return n - Math.floor(n)
|
||||
}
|
||||
|
||||
/**
|
||||
* Static dot field adapted from card-5's reviewed background effect.
|
||||
* The dots resolve from the current text color, so the surface stays
|
||||
* neutral and theme-aware without hard-coded colors.
|
||||
*/
|
||||
export function CardDotField({ className }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const GAP = 3
|
||||
const DOT = 1.5
|
||||
const BASE = 0.03
|
||||
const PEAK = 0.2
|
||||
|
||||
const draw = () => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
canvas.width = Math.round(rect.width * dpr)
|
||||
canvas.height = Math.round(rect.height * dpr)
|
||||
|
||||
ctx.fillStyle = getComputedStyle(canvas).color || "rgb(115,115,115)"
|
||||
ctx.fillRect(0, 0, 1, 1)
|
||||
const px = ctx.getImageData(0, 0, 1, 1).data
|
||||
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
ctx.fillStyle = color
|
||||
|
||||
const cols = Math.ceil(rect.width / GAP) + 1
|
||||
const rows = Math.ceil(rect.height / GAP) + 1
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const x = i * GAP
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const q = grain(i, j)
|
||||
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
|
||||
let a = (BASE + (PEAK - BASE) * q * q) * amp
|
||||
if (a > 1) a = 1
|
||||
ctx.globalAlpha = a
|
||||
ctx.fillRect(x, j * GAP, DOT, DOT)
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => draw())
|
||||
resizeObserver.observe(canvas)
|
||||
|
||||
const themeObserver = new MutationObserver(() => draw())
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style"],
|
||||
})
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
themeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
|
||||
type TooltipItem = {
|
||||
value?: unknown
|
||||
name?: unknown
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function ChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean
|
||||
payload?: readonly TooltipItem[]
|
||||
label?: string | number
|
||||
}) {
|
||||
if (!active || !payload?.length) return null
|
||||
|
||||
return (
|
||||
<Card className="bg-popover text-popover-foreground pointer-events-none p-0 shadow-md">
|
||||
<CardContent className="min-w-28 space-y-1.5 px-2 py-1.5">
|
||||
<div className="text-muted-foreground text-[10px] leading-none">
|
||||
{label}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{payload.map((item, index) => (
|
||||
<div
|
||||
key={`${String(item.name)}-${index}`}
|
||||
className="flex items-center justify-between gap-3 text-xs leading-none"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-1.5 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{String(item.name)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{Number(item.value ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import { type ComponentProps } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
export type MetricTone = "danger" | "success" | "warning" | "info"
|
||||
|
||||
export type MetricCard = {
|
||||
id: string
|
||||
title: string
|
||||
label: string
|
||||
value: string
|
||||
delta: string
|
||||
deltaVariant: ComponentProps<typeof Badge>["variant"]
|
||||
detail: string
|
||||
tone: MetricTone
|
||||
sparkline: number[]
|
||||
}
|
||||
|
||||
export const metricCards: MetricCard[] = [
|
||||
{
|
||||
id: "signal-risk",
|
||||
title: "Signal Risk",
|
||||
label: "Threat Index",
|
||||
value: "Elevated",
|
||||
delta: "+12.5%",
|
||||
deltaVariant: "destructive-light",
|
||||
detail: "5 cases",
|
||||
tone: "danger",
|
||||
sparkline: [18, 21, 15, 33, 29, 35, 28, 31, 19],
|
||||
},
|
||||
{
|
||||
id: "mesh-uptime",
|
||||
title: "Mesh Uptime",
|
||||
label: "Service Health",
|
||||
value: "99.98%",
|
||||
delta: "+0.2%",
|
||||
deltaVariant: "success-light",
|
||||
detail: "58 zones",
|
||||
tone: "success",
|
||||
sparkline: [42, 40, 42, 38, 39, 41, 40, 43, 46],
|
||||
},
|
||||
{
|
||||
id: "edge-traffic",
|
||||
title: "Edge Traffic",
|
||||
label: "Scrubbed Load",
|
||||
value: "4.8 GB/s",
|
||||
delta: "-3.1%",
|
||||
deltaVariant: "warning-light",
|
||||
detail: "clean flow",
|
||||
tone: "warning",
|
||||
sparkline: [21, 27, 28, 34, 32, 35, 33, 31, 34],
|
||||
},
|
||||
{
|
||||
id: "sensor-reach",
|
||||
title: "Sensor Reach",
|
||||
label: "Global Nodes",
|
||||
value: "18,420",
|
||||
delta: "+6.8%",
|
||||
deltaVariant: "success-light",
|
||||
detail: "93 regions",
|
||||
tone: "success",
|
||||
sparkline: [25, 23, 29, 28, 26, 31, 27, 30, 29],
|
||||
},
|
||||
]
|
||||
|
||||
export const threatVectors = [
|
||||
{ name: "Bot", blocked: 34, watched: 62 },
|
||||
{ name: "Phish", blocked: 30, watched: 74 },
|
||||
{ name: "DDoS", blocked: 52, watched: 33 },
|
||||
{ name: "Inject", blocked: 22, watched: 48 },
|
||||
{ name: "Auth", blocked: 43, watched: 68 },
|
||||
{ name: "Probe", blocked: 18, watched: 58 },
|
||||
{ name: "Exfil", blocked: 56, watched: 35 },
|
||||
{ name: "Beacon", blocked: 38, watched: 64 },
|
||||
{ name: "Day0", blocked: 14, watched: 28 },
|
||||
]
|
||||
|
||||
export const networkFlow = [
|
||||
{ month: "January", api: 1820, webhook: 1640 },
|
||||
{ month: "February", api: 2340, webhook: 2160 },
|
||||
{ month: "March", api: 1960, webhook: 1880 },
|
||||
{ month: "April", api: 2780, webhook: 2540 },
|
||||
{ month: "May", api: 2100, webhook: 1920 },
|
||||
{ month: "June", api: 3120, webhook: 2880 },
|
||||
{ month: "July", api: 2540, webhook: 2320 },
|
||||
{ month: "August", api: 3480, webhook: 3160 },
|
||||
{ month: "September", api: 2860, webhook: 2580 },
|
||||
{ month: "October", api: 2420, webhook: 2140 },
|
||||
{ month: "November", api: 3240, webhook: 2960 },
|
||||
{ month: "December", api: 2680, webhook: 2440 },
|
||||
]
|
||||
|
||||
export const networkFlowSummary = {
|
||||
change: "+12.8%",
|
||||
year: "2026",
|
||||
}
|
||||
|
||||
const loadSamples = [
|
||||
72, 68, 22, 18, 61, 71, 20, 26, 31, 70, 46, 88, 39, 25, 12, 33, 18, 28, 42,
|
||||
36, 61, 68, 74, 70, 91, 32, 82, 66, 52, 76, 48, 35, 70, 62, 57, 49, 37, 58,
|
||||
71, 7, 11, 69, 34, 28, 40, 61, 17, 55, 64, 19, 63, 67,
|
||||
] as const
|
||||
|
||||
export type LoadState = "nominal" | "warm" | "critical"
|
||||
|
||||
export type LoadPoint = {
|
||||
node: string
|
||||
load: number
|
||||
state: LoadState
|
||||
}
|
||||
|
||||
export const loadDistribution: LoadPoint[] = loadSamples.map((load, index) => ({
|
||||
node: `N${String(index).padStart(2, "0")}`,
|
||||
load,
|
||||
state: load >= 86 ? "critical" : load >= 76 ? "warm" : "nominal",
|
||||
}))
|
||||
|
||||
const loadStateTotals = loadDistribution.reduce<Record<LoadState, number>>(
|
||||
(totals, point) => ({
|
||||
...totals,
|
||||
[point.state]: totals[point.state] + 1,
|
||||
}),
|
||||
{
|
||||
nominal: 0,
|
||||
warm: 0,
|
||||
critical: 0,
|
||||
}
|
||||
)
|
||||
|
||||
export const clusterLoadSummary = {
|
||||
details: [
|
||||
{
|
||||
label: "Nodes",
|
||||
value: String(loadDistribution.length),
|
||||
variant: "secondary",
|
||||
},
|
||||
{
|
||||
label: "Warm",
|
||||
value: String(loadStateTotals.warm),
|
||||
variant: "warning-light",
|
||||
},
|
||||
{
|
||||
label: "Critical",
|
||||
value: String(loadStateTotals.critical),
|
||||
variant: "destructive-light",
|
||||
},
|
||||
],
|
||||
} satisfies {
|
||||
details: Array<{
|
||||
label: string
|
||||
value: string
|
||||
variant: ComponentProps<typeof Badge>["variant"]
|
||||
}>
|
||||
}
|
||||
|
||||
export const activeThreats = [
|
||||
{
|
||||
id: "00",
|
||||
label: "API Flood",
|
||||
source: "Edge WAF",
|
||||
state: "Mitigating",
|
||||
value: 4521,
|
||||
progress: 92,
|
||||
tone: "danger",
|
||||
},
|
||||
{
|
||||
id: "01",
|
||||
label: "Mail Spoof",
|
||||
source: "Mail Relay",
|
||||
state: "Reviewing",
|
||||
value: 3102,
|
||||
progress: 64,
|
||||
tone: "warning",
|
||||
},
|
||||
{
|
||||
id: "02",
|
||||
label: "Cloud Probe",
|
||||
source: "Cloud API",
|
||||
state: "Queued",
|
||||
value: 1250,
|
||||
progress: 26,
|
||||
tone: "info",
|
||||
},
|
||||
{
|
||||
id: "03",
|
||||
label: "Mesh Beacon",
|
||||
source: "Int Node",
|
||||
state: "Watching",
|
||||
value: 420,
|
||||
progress: 9,
|
||||
tone: "success",
|
||||
},
|
||||
] satisfies Array<{
|
||||
id: string
|
||||
label: string
|
||||
source: string
|
||||
state: string
|
||||
value: number
|
||||
progress: number
|
||||
tone: MetricTone
|
||||
}>
|
||||
@@ -1,182 +0,0 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
|
||||
import { ChartTooltip } from "./chart-tooltip"
|
||||
import { activeThreats, clusterLoadSummary, loadDistribution } from "./data"
|
||||
import { PanelCorners, PanelHeading } from "./panel-heading"
|
||||
import { loadStateColor, toneStyles } from "./tone-styles"
|
||||
|
||||
const chartGridProps = {
|
||||
vertical: false,
|
||||
stroke: "var(--border)",
|
||||
strokeDasharray: "3 3",
|
||||
strokeOpacity: 0.75,
|
||||
}
|
||||
|
||||
export function LoadPanel() {
|
||||
return (
|
||||
<Card className="relative h-full overflow-hidden p-0">
|
||||
<PanelCorners />
|
||||
<CardContent className="flex h-full flex-col p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-sm leading-4 font-semibold">Cluster Load</h2>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-3 text-xs leading-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-zinc-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Normal
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-amber-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Warm
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-red-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Critical
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
{clusterLoadSummary.details.map((detail) => (
|
||||
<Badge
|
||||
key={detail.label}
|
||||
variant={detail.variant}
|
||||
radius="full"
|
||||
className="h-6 gap-1.5 px-2 text-xs"
|
||||
>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{detail.value}
|
||||
</span>
|
||||
<span>{detail.label}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto h-60 pt-5">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={loadDistribution}
|
||||
margin={{ top: 10, right: 0, bottom: 0, left: 0 }}
|
||||
barCategoryGap={2}
|
||||
>
|
||||
<CartesianGrid {...chartGridProps} />
|
||||
<XAxis
|
||||
dataKey="node"
|
||||
axisLine={false}
|
||||
interval={25}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis hide domain={[0, 100]} />
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--muted)" }}
|
||||
content={({ active, payload, label }) => (
|
||||
<ChartTooltip
|
||||
active={active}
|
||||
payload={payload}
|
||||
label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="load"
|
||||
name="Load"
|
||||
radius={[5, 5, 5, 5]}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{loadDistribution.map((entry) => (
|
||||
<Cell
|
||||
key={entry.node}
|
||||
fill={loadStateColor[entry.state]}
|
||||
opacity={entry.state === "nominal" ? 0.62 : 1}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActiveThreatsPanel() {
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<PanelCorners />
|
||||
<CardContent className="space-y-5 p-4">
|
||||
<PanelHeading title="Active Lanes" description="Threat Queue" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{activeThreats.map((threat) => {
|
||||
const tone = toneStyles[threat.tone]
|
||||
|
||||
return (
|
||||
<div key={threat.id} className="space-y-2.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn("size-2 shrink-0 rounded-full", tone.dot)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">
|
||||
[{threat.id}] {threat.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span>{threat.source}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
||||
/>
|
||||
<span>{threat.state}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm font-semibold tabular-nums">
|
||||
{threat.value.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted h-1.5 min-w-0 flex-1 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn("h-full rounded-full", tone.bar)}
|
||||
style={{ width: `${threat.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground w-8 text-right text-[10px] leading-none tabular-nums">
|
||||
{threat.progress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
|
||||
import { CardDotField } from "@/components/dashboard/card-dot-field"
|
||||
import { type MetricCard } from "./data"
|
||||
import { PanelCorners } from "./panel-heading"
|
||||
import { toneStyles } from "./tone-styles"
|
||||
|
||||
function Sparkline({
|
||||
values,
|
||||
color,
|
||||
}: {
|
||||
values: readonly number[]
|
||||
color: string
|
||||
}) {
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
const spread = Math.max(1, max - min)
|
||||
const points = values
|
||||
.map((value, index) => {
|
||||
const x = (index / (values.length - 1)) * 72
|
||||
const y = 28 - ((value - min) / spread) * 22
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 72 32"
|
||||
className="h-9 w-24 shrink-0 opacity-95"
|
||||
role="img"
|
||||
aria-label="Metric trend"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="miter"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MetricTile({ metric }: { metric: MetricCard }) {
|
||||
const tone = toneStyles[metric.tone]
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
|
||||
<PanelCorners />
|
||||
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground truncate text-sm leading-4 font-semibold">
|
||||
{metric.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs leading-4">
|
||||
{metric.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="min-w-0 space-y-2.5">
|
||||
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight">
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={metric.deltaVariant} radius="full">
|
||||
{metric.delta}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{metric.detail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline values={metric.sparkline} color={tone.stroke} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { type DateRange } from "react-day-picker"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { Calendar } from "@evobgp/ui/components/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { CalendarIcon, ShieldCheckIcon, DownloadIcon } from "lucide-react"
|
||||
|
||||
type PeriodKey = "last30" | "prev30"
|
||||
|
||||
type ReportDateRange = {
|
||||
from: Date
|
||||
to: Date
|
||||
}
|
||||
|
||||
type DateRangePreset = {
|
||||
id: string
|
||||
label: string
|
||||
period: PeriodKey
|
||||
range: ReportDateRange
|
||||
}
|
||||
|
||||
const reportRange = (
|
||||
fromMonth: number,
|
||||
fromDay: number,
|
||||
toMonth: number,
|
||||
toDay: number,
|
||||
year = 2026
|
||||
): ReportDateRange => ({
|
||||
from: new Date(year, fromMonth, fromDay),
|
||||
to: new Date(year, toMonth, toDay),
|
||||
})
|
||||
|
||||
const preset = (
|
||||
id: string,
|
||||
label: string,
|
||||
period: PeriodKey,
|
||||
range: ReportDateRange
|
||||
): DateRangePreset => ({ id, label, period, range })
|
||||
|
||||
const LAST_30_RANGE = reportRange(4, 12, 5, 10)
|
||||
const PREVIOUS_30_RANGE = reportRange(3, 12, 4, 11)
|
||||
|
||||
const REPORT_RANGE_PRESETS: DateRangePreset[] = [
|
||||
preset("today", "Today", "last30", reportRange(5, 10, 5, 10)),
|
||||
preset("yesterday", "Yesterday", "last30", reportRange(5, 9, 5, 9)),
|
||||
preset("last7", "Last 7 days", "last30", reportRange(5, 4, 5, 10)),
|
||||
preset("last30", "Last 30 days", "last30", LAST_30_RANGE),
|
||||
preset("monthToDate", "Month to date", "last30", reportRange(5, 1, 5, 10)),
|
||||
preset("lastMonth", "Last month", "last30", reportRange(4, 1, 4, 31)),
|
||||
preset("yearToDate", "Year to date", "last30", reportRange(0, 1, 5, 10)),
|
||||
preset("lastYear", "Last year", "prev30", reportRange(0, 1, 11, 31, 2025)),
|
||||
]
|
||||
|
||||
const MAX_REPORT_DATE = LAST_30_RANGE.to
|
||||
|
||||
function isSameRange(first: ReportDateRange, second: DateRange) {
|
||||
const secondFrom = second.from
|
||||
const secondTo = second.to ?? second.from
|
||||
|
||||
return (
|
||||
Boolean(secondFrom && secondTo) &&
|
||||
first.from.getTime() === secondFrom?.getTime() &&
|
||||
first.to.getTime() === secondTo?.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRange(
|
||||
range: DateRange | undefined,
|
||||
fallback: ReportDateRange
|
||||
): ReportDateRange {
|
||||
if (!range?.from) return fallback
|
||||
|
||||
const from = range.from
|
||||
const to = range.to ?? range.from
|
||||
|
||||
return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from }
|
||||
}
|
||||
|
||||
function formatReportRange(range: ReportDateRange) {
|
||||
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`
|
||||
}
|
||||
|
||||
function getPeriodForRange(range: ReportDateRange) {
|
||||
const matchingPreset = getMatchingPreset(range)
|
||||
|
||||
if (matchingPreset) return matchingPreset.period
|
||||
return range.to.getTime() <= PREVIOUS_30_RANGE.to.getTime()
|
||||
? "prev30"
|
||||
: "last30"
|
||||
}
|
||||
|
||||
function getMatchingPreset(range: DateRange | undefined) {
|
||||
if (!range?.from || !range.to) return undefined
|
||||
const normalizedRange = normalizeRange(range, LAST_30_RANGE)
|
||||
|
||||
return REPORT_RANGE_PRESETS.find((preset) =>
|
||||
isSameRange(preset.range, normalizedRange)
|
||||
)
|
||||
}
|
||||
|
||||
function ReportDateRangePicker({
|
||||
period,
|
||||
onPeriodChange,
|
||||
}: {
|
||||
period: PeriodKey
|
||||
onPeriodChange: (value: PeriodKey) => void
|
||||
}) {
|
||||
const initialRange = period === "prev30" ? PREVIOUS_30_RANGE : LAST_30_RANGE
|
||||
const [open, setOpen] = useState(false)
|
||||
const [committedRange, setCommittedRange] =
|
||||
useState<ReportDateRange>(initialRange)
|
||||
const [draftRange, setDraftRange] = useState<DateRange | undefined>(
|
||||
initialRange
|
||||
)
|
||||
|
||||
const selectedPresetId = getMatchingPreset(draftRange ?? committedRange)?.id
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen) {
|
||||
setDraftRange(committedRange)
|
||||
}
|
||||
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const nextRange = normalizeRange(draftRange, committedRange)
|
||||
|
||||
setCommittedRange(nextRange)
|
||||
onPeriodChange(getPeriodForRange(nextRange))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="group/pick-date w-[250px] max-w-full justify-between leading-none font-normal tabular-nums"
|
||||
>
|
||||
<span className="truncate">
|
||||
{formatReportRange(committedRange)}
|
||||
</span>
|
||||
<CalendarIcon className="text-muted-foreground/80 group-hover/pick-date:text-foreground shrink-0 transition-colors" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-auto p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col sm:grid sm:grid-cols-[10rem_1fr]">
|
||||
<div className="border-border flex flex-wrap gap-1 border-b p-2 sm:flex-col sm:border-r sm:border-b-0">
|
||||
{REPORT_RANGE_PRESETS.map((preset) => {
|
||||
const selected = selectedPresetId === preset.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "secondary" : "ghost"}
|
||||
className={
|
||||
selected
|
||||
? "justify-start"
|
||||
: "text-muted-foreground justify-start"
|
||||
}
|
||||
onClick={() => setDraftRange(preset.range)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={draftRange}
|
||||
onSelect={setDraftRange}
|
||||
numberOfMonths={2}
|
||||
defaultMonth={draftRange?.from ?? committedRange.from}
|
||||
disabled={{
|
||||
after: MAX_REPORT_DATE,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border flex items-center justify-between gap-2 border-t px-3 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDraftRange(LAST_30_RANGE)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDraftRange(committedRange)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleApply}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavbarActions() {
|
||||
const [periodKey, setPeriodKey] = useState<PeriodKey>("last30")
|
||||
|
||||
function handleExport() {
|
||||
toast.success("Export queued", {
|
||||
description: "Security telemetry report is being prepared.",
|
||||
icon: (
|
||||
<ShieldCheckIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ReportDateRangePicker period={periodKey} onPeriodChange={setPeriodKey} />
|
||||
|
||||
<Button size="sm" type="button" onClick={handleExport}>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span className="hidden sm:block">Export</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@evobgp/ui/components/breadcrumb"
|
||||
|
||||
export function NavbarBreadcrumb() {
|
||||
return (
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Security</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">Telemetry</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { NavbarActions } from "./navbar-actions"
|
||||
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<header
|
||||
className="flex min-h-9 w-full shrink-0 items-center justify-between gap-2 pb-1"
|
||||
aria-label="Security telemetry header"
|
||||
>
|
||||
<NavbarBreadcrumb />
|
||||
|
||||
<NavbarActions />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@evobgp/ui/components/chart"
|
||||
|
||||
import { networkFlow, networkFlowSummary, threatVectors } from "./data"
|
||||
import { PanelCorners, PanelHeading } from "./panel-heading"
|
||||
|
||||
const threatChartConfig = {
|
||||
blocked: {
|
||||
label: "Blocked",
|
||||
color: "var(--color-blue-600)",
|
||||
},
|
||||
watched: {
|
||||
label: "Watched",
|
||||
color: "var(--color-sky-300)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
const flowChartConfig = {
|
||||
api: {
|
||||
label: "API Calls",
|
||||
color: "var(--color-yellow-500)",
|
||||
},
|
||||
webhook: {
|
||||
label: "Webhooks",
|
||||
color: "var(--color-emerald-500)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
const chartGridProps = {
|
||||
vertical: false,
|
||||
stroke: "var(--border)",
|
||||
strokeDasharray: "3 3",
|
||||
strokeOpacity: 0.75,
|
||||
}
|
||||
|
||||
function getChartColor(config: ChartConfig, name: string | number) {
|
||||
return config[String(name)]?.color
|
||||
}
|
||||
|
||||
function getChartLabel(config: ChartConfig, name: string | number) {
|
||||
return config[String(name)]?.label ?? String(name)
|
||||
}
|
||||
|
||||
function formatTooltipItem(
|
||||
config: ChartConfig,
|
||||
value: unknown,
|
||||
name: string | number
|
||||
) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: getChartColor(config, name) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{getChartLabel(config, name)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{Number(value ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CrosshatchPattern({
|
||||
config,
|
||||
idPrefix,
|
||||
}: {
|
||||
config: ChartConfig
|
||||
idPrefix: string
|
||||
}) {
|
||||
const entries = Object.entries(config).filter(([, value]) => value.color)
|
||||
|
||||
return (
|
||||
<>
|
||||
{entries.map(([key, { color }]) => (
|
||||
<pattern
|
||||
key={key}
|
||||
id={`${idPrefix}-${key}`}
|
||||
x="0"
|
||||
y="0"
|
||||
width="8"
|
||||
height="8"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M0,8 L8,0" stroke={color} strokeWidth="0.8" opacity="0.4" />
|
||||
<path d="M0,0 L8,8" stroke={color} strokeWidth="0.8" opacity="0.2" />
|
||||
</pattern>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThreatVectorsPanel() {
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<PanelCorners />
|
||||
<CardContent className="flex flex-col gap-5 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<PanelHeading title="Threat Vectors" description="Blocked Signals" />
|
||||
<div className="text-muted-foreground flex shrink-0 flex-wrap items-center gap-4 text-xs sm:justify-end">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-blue-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Blocked
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full bg-sky-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Watched
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChartContainer
|
||||
config={threatChartConfig}
|
||||
className="h-56 w-full min-w-0"
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={threatVectors}
|
||||
margin={{ top: 8, right: 4, bottom: 0, left: 4 }}
|
||||
barGap={3}
|
||||
barCategoryGap={8}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="dashboard5-threat-blocked"
|
||||
patternUnits="userSpaceOnUse"
|
||||
width="8"
|
||||
height="8"
|
||||
>
|
||||
<rect
|
||||
width="8"
|
||||
height="8"
|
||||
fill="var(--color-blocked)"
|
||||
opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M0,8 L8,0 M4,12 L12,4 M-4,4 L4,-4"
|
||||
stroke="var(--color-blocked)"
|
||||
strokeWidth="1.5"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<path
|
||||
d="M2,10 L10,2 M6,14 L14,6 M-2,6 L6,-2"
|
||||
stroke="var(--color-blocked)"
|
||||
strokeWidth="1"
|
||||
opacity="0.25"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<CartesianGrid {...chartGridProps} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis hide domain={[0, 78]} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="dot"
|
||||
className="min-w-36 gap-2"
|
||||
labelFormatter={(value) => (
|
||||
<div className="border-border/50 mb-0.5 border-b pb-2">
|
||||
<span className="text-xs font-medium">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
formatter={(value, name) =>
|
||||
formatTooltipItem(threatChartConfig, value, name)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="blocked"
|
||||
fill="url(#dashboard5-threat-blocked)"
|
||||
stroke="var(--color-blocked)"
|
||||
strokeWidth={1}
|
||||
radius={[5, 5, 5, 5]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="watched"
|
||||
fill="var(--color-watched)"
|
||||
fillOpacity={0.86}
|
||||
stroke="var(--color-watched)"
|
||||
strokeWidth={1}
|
||||
radius={[5, 5, 5, 5]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function NetworkFlowPanel() {
|
||||
return (
|
||||
<Card className="relative overflow-hidden p-0">
|
||||
<PanelCorners />
|
||||
<CardContent className="flex flex-col gap-5 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<PanelHeading title="Network Flow" description="API and webhooks" />
|
||||
<Badge variant="success-light" radius="full">
|
||||
{networkFlowSummary.change}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<ChartContainer
|
||||
config={flowChartConfig}
|
||||
className="h-56 w-full min-w-0"
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={networkFlow}
|
||||
margin={{ top: 20, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<CartesianGrid {...chartGridProps} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => String(value).slice(0, 3)}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="dot"
|
||||
className="min-w-40 gap-2.5"
|
||||
labelFormatter={(value) => (
|
||||
<div className="border-border/50 mb-0.5 border-b pb-2">
|
||||
<span className="text-xs font-medium">
|
||||
{value} {networkFlowSummary.year}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
formatter={(value, name) =>
|
||||
formatTooltipItem(flowChartConfig, value, name)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<defs>
|
||||
<CrosshatchPattern
|
||||
config={flowChartConfig}
|
||||
idPrefix="dashboard5-flow-crosshatch"
|
||||
/>
|
||||
</defs>
|
||||
<Area
|
||||
dataKey="webhook"
|
||||
type="natural"
|
||||
fill="url(#dashboard5-flow-crosshatch-webhook)"
|
||||
fillOpacity={0.5}
|
||||
stroke="var(--color-webhook)"
|
||||
stackId="a"
|
||||
strokeWidth={1.25}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
dataKey="api"
|
||||
type="natural"
|
||||
fill="url(#dashboard5-flow-crosshatch-api)"
|
||||
fillOpacity={0.5}
|
||||
stroke="var(--color-api)"
|
||||
stackId="a"
|
||||
strokeWidth={1.25}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
export function PanelCorners() {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="border-foreground/65 absolute top-0 left-0 size-2 border-t border-l"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="border-foreground/65 absolute right-0 bottom-0 size-2 border-r border-b"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PanelHeading({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-sm leading-4 font-semibold">{title}</h2>
|
||||
<p className="text-muted-foreground text-xs leading-4">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { metricCards } from "./data"
|
||||
import { ActiveThreatsPanel, LoadPanel } from "./load-panels"
|
||||
import { MetricTile } from "./metric-tile"
|
||||
import { Navbar } from "./navbar"
|
||||
import { NetworkFlowPanel, ThreatVectorsPanel } from "./network-panels"
|
||||
|
||||
/**
|
||||
* Dense edge security dashboard inspired by a telemetry wall.
|
||||
* The main entry only owns section order; records and panel details stay
|
||||
* in focused local files so the block remains easy to adapt.
|
||||
*/
|
||||
export function SecurityDashboard() {
|
||||
return (
|
||||
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Edge Security Telemetry
|
||||
</h1>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<section
|
||||
aria-label="Security Summary"
|
||||
className="grid grid-cols-1 gap-4 @3xl:grid-cols-2 @6xl:grid-cols-4"
|
||||
>
|
||||
{metricCards.map((metric) => (
|
||||
<MetricTile key={metric.id} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Security Flow"
|
||||
className="grid grid-cols-1 gap-4 @5xl:grid-cols-2"
|
||||
>
|
||||
<ThreatVectorsPanel />
|
||||
<NetworkFlowPanel />
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Security Load"
|
||||
className="grid grid-cols-1 gap-4 @5xl:grid-cols-[minmax(0,2fr)_minmax(300px,1fr)]"
|
||||
>
|
||||
<LoadPanel />
|
||||
<ActiveThreatsPanel />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { type LoadState, type MetricTone } from "./data"
|
||||
|
||||
export const toneStyles: Record<
|
||||
MetricTone,
|
||||
{
|
||||
dot: string
|
||||
stroke: string
|
||||
bar: string
|
||||
}
|
||||
> = {
|
||||
danger: {
|
||||
dot: "bg-red-500",
|
||||
stroke: "var(--color-red-500)",
|
||||
bar: "bg-red-500",
|
||||
},
|
||||
success: {
|
||||
dot: "bg-emerald-500",
|
||||
stroke: "var(--color-emerald-500)",
|
||||
bar: "bg-emerald-500",
|
||||
},
|
||||
warning: {
|
||||
dot: "bg-amber-500",
|
||||
stroke: "var(--color-amber-500)",
|
||||
bar: "bg-amber-500",
|
||||
},
|
||||
info: {
|
||||
dot: "bg-blue-500",
|
||||
stroke: "var(--color-blue-500)",
|
||||
bar: "bg-blue-500",
|
||||
},
|
||||
}
|
||||
|
||||
export const loadStateColor: Record<LoadState, string> = {
|
||||
nominal: "var(--color-zinc-300)",
|
||||
warm: "var(--color-amber-500)",
|
||||
critical: "var(--color-red-500)",
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { SecurityDashboard } from "./components/security-dashboard"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="bg-background min-h-svh w-full p-3 sm:p-4 lg:p-6"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<SecurityDashboard />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
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",
|
||||
},
|
||||
]
|
||||
@@ -1,15 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,512 +0,0 @@
|
||||
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!",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,691 +0,0 @@
|
||||
"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
@@ -1,212 +0,0 @@
|
||||
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
@@ -1,60 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
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)]",
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -1,597 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
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)",
|
||||
},
|
||||
]
|
||||
@@ -1,15 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
"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)]",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,660 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
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",
|
||||
}),
|
||||
]
|
||||
@@ -1,15 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,685 +0,0 @@
|
||||
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>[]
|
||||
}
|
||||
@@ -1,766 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,738 +0,0 @@
|
||||
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
@@ -1,108 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user