From 1f710bbe8b914835606b244ca0ea2ae6df9ccce3 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 17 Jul 2026 02:55:49 +0700 Subject: [PATCH] feat(api, web): integrate health status management for services and domains - Added health status and latency fields to service and domain schemas, enhancing monitoring capabilities. - Implemented health status aggregation for services in the API, allowing for improved health checks and reporting. - Updated web components to display health status using HealthCheckBadge, improving user visibility of service health. - Refactored service and domain management components to incorporate health status in various views, enhancing overall functionality. Co-authored-by: Cursor --- .../src/services/service-config-service.ts | 85 ++- apps/api/test/service-groups-health.test.ts | 94 +++ .../components/columns.tsx | 550 ++++++++++++++++ .../components/data-grid-view.tsx | 620 ++++++++++++++++++ .../data-grid-grouping-2/components/data.tsx | 459 +++++++++++++ .../blocks/data-grid-grouping-2/page.tsx | 15 + .../blocks/empty-state-12/components/data.ts | 48 ++ .../empty-state-12/components/empty-state.tsx | 247 +++++++ .../components/blocks/empty-state-12/page.tsx | 15 + .../components/kanban-board.tsx | 2 - .../blocks/settings-8/components/data.tsx | 243 +++++++ .../components/endpoint-actions-menu.tsx | 72 ++ .../components/endpoint-alert-indicator.tsx | 49 ++ .../settings-8/components/endpoint-row.tsx | 83 +++ .../settings-8/components/endpoint-toast.tsx | 46 ++ .../components/endpoint-url-copy.tsx | 54 ++ .../components/status-indicator.tsx | 11 + .../components/webhook-endpoints.tsx | 128 ++++ .../src/components/blocks/settings-8/page.tsx | 9 + .../components/columns/domains-columns.tsx | 42 +- .../domains/domain-availability-panel.tsx | 8 +- .../components/kanban/service-kanban-card.tsx | 24 +- .../src/components/reui-kit/kanban-board.tsx | 166 +++-- .../data-grid/data-grid-column-filter.tsx | 2 + .../data-grid/data-grid-column-header.tsx | 2 - .../data-grid/data-grid-column-visibility.tsx | 2 + .../reui/data-grid/data-grid-pagination.tsx | 2 - .../reui/data-grid/data-grid-scroll-area.tsx | 2 + .../data-grid/data-grid-table-dnd-rows.tsx | 2 - .../reui/data-grid/data-grid-table-dnd.tsx | 2 + .../data-grid/data-grid-table-virtual.tsx | 2 - .../reui/data-grid/data-grid-table.tsx | 2 + .../components/reui/data-grid/data-grid.tsx | 2 - apps/web/src/components/reui/icon-stack.tsx | 90 +++ .../services/services-grouped-catalog.tsx | 450 +++++++++++++ .../services/services-grouped-columns.tsx | 299 +++++++++ apps/web/src/lib/schemas.ts | 4 + .../routes/_auth/domains/$domainId/index.tsx | 27 +- apps/web/src/routes/_auth/index.tsx | 91 ++- apps/web/src/routes/_auth/services.tsx | 123 ++-- packages/db/dist/index.d.ts | 17 +- packages/db/dist/index.js | 106 +++ packages/db/src/repos.ts | 147 +++++ packages/shared/dist/index.d.ts | 42 ++ packages/shared/dist/index.js | 8 +- packages/shared/src/schemas.ts | 4 + packages/shared/src/types.ts | 4 + packages/ui/src/components/dropdown-menu.tsx | 2 - 48 files changed, 4258 insertions(+), 246 deletions(-) create mode 100644 apps/api/test/service-groups-health.test.ts create mode 100644 apps/web/src/components/blocks/data-grid-grouping-2/components/columns.tsx create mode 100644 apps/web/src/components/blocks/data-grid-grouping-2/components/data-grid-view.tsx create mode 100644 apps/web/src/components/blocks/data-grid-grouping-2/components/data.tsx create mode 100644 apps/web/src/components/blocks/data-grid-grouping-2/page.tsx create mode 100644 apps/web/src/components/blocks/empty-state-12/components/data.ts create mode 100644 apps/web/src/components/blocks/empty-state-12/components/empty-state.tsx create mode 100644 apps/web/src/components/blocks/empty-state-12/page.tsx create mode 100644 apps/web/src/components/blocks/settings-8/components/data.tsx create mode 100644 apps/web/src/components/blocks/settings-8/components/endpoint-actions-menu.tsx create mode 100644 apps/web/src/components/blocks/settings-8/components/endpoint-alert-indicator.tsx create mode 100644 apps/web/src/components/blocks/settings-8/components/endpoint-row.tsx create mode 100644 apps/web/src/components/blocks/settings-8/components/endpoint-toast.tsx create mode 100644 apps/web/src/components/blocks/settings-8/components/endpoint-url-copy.tsx create mode 100644 apps/web/src/components/blocks/settings-8/components/status-indicator.tsx create mode 100644 apps/web/src/components/blocks/settings-8/components/webhook-endpoints.tsx create mode 100644 apps/web/src/components/blocks/settings-8/page.tsx create mode 100644 apps/web/src/components/reui/icon-stack.tsx create mode 100644 apps/web/src/components/services/services-grouped-catalog.tsx create mode 100644 apps/web/src/components/services/services-grouped-columns.tsx diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index 7850876..9e55111 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -340,23 +340,45 @@ async function buildView(db: Db, serviceId: number): Promise { updated_at: service.updated_at, ips, domains: domainViews, + health_status: "unknown", + health_latency_ms: null, }; } +function attachServiceHealth( + db: Db, + views: ServiceView[], +): ServiceView[] { + const healthByService = repos.aggregateIpHealthByServiceIds( + db, + views.map((v) => v.id), + ); + return views.map((view) => { + const health = healthByService.get(view.id); + return { + ...view, + health_status: health?.health_status ?? "unknown", + health_latency_ms: health?.health_latency_ms ?? null, + }; + }); +} + export async function listViews(db: Db): Promise { - return Promise.all( + const views = await Promise.all( repos.listServices(db).map((s) => buildView(db, s.id)), ); + return attachServiceHealth(db, views); } export async function getView(db: Db, id: number): Promise { repos.getService(db, id); - return buildView(db, id); + const [view] = attachServiceHealth(db, [await buildView(db, id)]); + return view!; } export async function listGroupViews(db: Db): Promise { const groups = repos.listServiceGroups(db); - const groupViews = await Promise.all( + const groupViewsRaw = await Promise.all( groups.map(async (group) => { const services = repos.listServicesByGroup(db, group.id); const serviceViews = await Promise.all( @@ -367,10 +389,52 @@ export async function listGroupViews(db: Db): Promise { ); const ungroupedServices = repos.listUngroupedServices(db); - const ungrouped = await Promise.all( + const ungroupedRaw = await Promise.all( ungroupedServices.map((s) => buildView(db, s.id)), ); + const allServiceViews = [ + ...groupViewsRaw.flatMap((g) => g.services), + ...ungroupedRaw, + ]; + const withHealth = attachServiceHealth(db, allServiceViews); + const healthById = new Map(withHealth.map((v) => [v.id, v])); + + const groupHealthById = repos.aggregateIpHealthByRefs( + db, + "group", + groups.map((g) => g.id), + ); + + const groupViews = groupViewsRaw.map((group) => { + const services = group.services.map( + (s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null }, + ); + const groupScopeHealth = groupHealthById.get(group.id); + const merged = repos.mergeHealthAggregates([ + groupScopeHealth, + ...services.map((s) => ({ + health_status: s.health_status, + health_latency_ms: s.health_latency_ms, + })), + ]); + return { + ...group, + services, + health_status: merged.health_status, + health_latency_ms: merged.health_latency_ms, + }; + }); + + const ungrouped = ungroupedRaw.map( + (s) => + healthById.get(s.id) ?? { + ...s, + health_status: "unknown" as const, + health_latency_ms: null, + }, + ); + return { groups: groupViews, ungrouped }; } @@ -1224,7 +1288,8 @@ export async function updateConfig( void syncServiceToVpsTracker(db, id, removedBindingIds); - return buildView(db, id); + const [view] = attachServiceHealth(db, [await buildView(db, id)]); + return view!; } export async function createGroup( @@ -1318,12 +1383,18 @@ export async function toggleService( if (!enabled) { await cleanupServiceDnsOnly(db, cf, serviceId); await syncGroupDomainForService(db, cf, serviceId); - return buildView(db, serviceId); + const [disabledView] = attachServiceHealth(db, [ + await buildView(db, serviceId), + ]); + return disabledView!; } await syncServiceBindingsToDns(db, cf, serviceId); await syncGroupDomainForService(db, cf, serviceId); - return buildView(db, serviceId); + const [enabledView] = attachServiceHealth(db, [ + await buildView(db, serviceId), + ]); + return enabledView!; } export async function toggleGroup( diff --git a/apps/api/test/service-groups-health.test.ts b/apps/api/test/service-groups-health.test.ts new file mode 100644 index 0000000..0cf4e51 --- /dev/null +++ b/apps/api/test/service-groups-health.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import { repos } from "@cfdm/db"; + +async function authHeaders(app: Awaited>) { + const config = loadConfig(); + const res = await app.inject({ + method: "POST", + url: "/api/v1/auth/login", + payload: { username: config.adminUsername, password: "admin" }, + }); + expect(res.statusCode).toBe(200); + const { token } = res.json() as { token: string }; + return { authorization: `Bearer ${token}` }; +} + +describe("service groups health enrichment", () => { + it("GET /service-groups returns health_status from ip_health_status", async () => { + const app = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(app); + + const domain = repos.createDomain(app.db, null, "example.com", "zone-1"); + const group = repos.createServiceGroup( + app.db, + "VPN", + "vpn", + null, + "vpn.example.com", + ); + const service = repos.createService(app.db, "Panel", "panel"); + repos.setServiceGroup(app.db, service.id, group.id); + const binding = repos.insertBinding( + app.db, + domain.id, + service.id, + "panel", + null, + ); + repos.replaceBindingIpsWithMeta(app.db, binding.id, [ + { ip: "1.2.3.4", weight: 1, priority: 1 }, + ]); + repos.upsertIpHealthStatus( + app.db, + "binding", + binding.id, + "1.2.3.4", + "degraded", + 120, + 1, + null, + ); + repos.upsertIpHealthStatus( + app.db, + "group", + group.id, + "5.6.7.8", + "up", + 40, + 0, + null, + ); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/service-groups", + headers, + }); + expect(res.statusCode).toBe(200); + const body = res.json() as { + groups: Array<{ + id: number; + health_status: string; + health_latency_ms: number | null; + services: Array<{ + id: number; + health_status: string; + health_latency_ms: number | null; + }>; + }>; + }; + const groupView = body.groups.find((g) => g.id === group.id); + expect(groupView).toBeDefined(); + expect(groupView!.services[0]?.health_status).toBe("degraded"); + expect(groupView!.services[0]?.health_latency_ms).toBe(120); + // group worst = degraded (from service) over up (group scope) + expect(groupView!.health_status).toBe("degraded"); + + await app.close(); + }); +}); diff --git a/apps/web/src/components/blocks/data-grid-grouping-2/components/columns.tsx b/apps/web/src/components/blocks/data-grid-grouping-2/components/columns.tsx new file mode 100644 index 0000000..c8a5126 --- /dev/null +++ b/apps/web/src/components/blocks/data-grid-grouping-2/components/columns.tsx @@ -0,0 +1,550 @@ +"use client" + +import { memo, 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 "@cfdm/ui/lib/utils" +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@cfdm/ui/components/avatar" +import { Button } from "@cfdm/ui/components/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@cfdm/ui/components/dropdown-menu" +import { Item } from "@cfdm/ui/components/item" +import { + ACCOUNTS, + formatCurrency, + nextRenewal, + sumArr, + weightedNrr, + type Account, + type AccountHealth, + type AccountOwner, + type AccountRow, + type AccountTier, + type PortfolioRow, + type RegionGroupRow, +} from "./data" +import { TrendingUp, TrendingDown, ArrowRightIcon, CalendarDaysIcon, MoreHorizontalIcon, EyeIcon, CopyIcon, PencilIcon, ChevronRightIcon, GlobeIcon } from "lucide-react" + +export type AccountAction = "open" | "copy" | "plan" + +const tierVariant: Record< + AccountTier, + ComponentProps["variant"] +> = { + Enterprise: "outline", + Growth: "outline", + Startup: "outline", +} + +const healthVariant: Record< + AccountHealth, + ComponentProps["variant"] +> = { + Healthy: "success-light", + Watch: "warning-light", + "At Risk": "destructive-light", +} + +// Per-account brand mark: a colored monogram tile stands in for a real logo +// (accounts are fictional, so real brand art is off limits). Tint is stable per +// account id; the monogram is the initials of the first two words. +const BRAND_TINTS = [ + "border-blue-200 bg-blue-100 text-blue-700 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-300", + "border-violet-200 bg-violet-100 text-violet-700 dark:border-violet-900 dark:bg-violet-950 dark:text-violet-300", + "border-emerald-200 bg-emerald-100 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300", + "border-amber-200 bg-amber-100 text-amber-700 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-300", + "border-rose-200 bg-rose-100 text-rose-700 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-300", + "border-cyan-200 bg-cyan-100 text-cyan-700 dark:border-cyan-900 dark:bg-cyan-950 dark:text-cyan-300", +] + +// Cycle the tints by position within each region so a region's accounts get +// distinct colors (a region with more than six repeats from the first tint). +const BRAND_TINT_BY_ID = new Map() +const regionTintCursor = new Map() +for (const account of ACCOUNTS) { + const cursor = regionTintCursor.get(account.regionId) ?? 0 + BRAND_TINT_BY_ID.set(account.id, BRAND_TINTS[cursor % BRAND_TINTS.length]) + regionTintCursor.set(account.regionId, cursor + 1) +} + +function brandTint(id: string) { + return BRAND_TINT_BY_ID.get(id) ?? BRAND_TINTS[0] +} + +function accountMonogram(name: string) { + const words = name.trim().split(/\s+/) + if (words.length >= 2) { + return (words[0][0] + words[1][0]).toUpperCase() + } + return name.slice(0, 2).toUpperCase() +} + +function isAccountRow(row: PortfolioRow): row is AccountRow { + return row.kind === "account" +} + +function getRegionAccounts(row: RegionGroupRow): Account[] { + return row.subRows?.map((item) => item.account) ?? [] +} + +/** Color tone keyed on net revenue retention (100% = flat). */ +function nrrTone(value: number) { + if (value >= 100) return "text-emerald-600 dark:text-emerald-500" + return "text-rose-600 dark:text-rose-500" +} + +// ── Shared cells ── + +const OwnerAvatar = memo(function OwnerAvatar({ + owner, + className, +}: { + owner: AccountOwner + className?: string +}) { + return ( + + {owner.avatarSrc ? ( + + ) : null} + {owner.initials} + + ) +}) + +export function NrrValue({ value }: { value: number }) { + return ( + + {value >= 100 ? ( + + ) +} + +// ── Account (leaf) cells ── + +function AccountLogoTile({ account }: { account: Account }) { + return ( + } + aria-hidden="true" + className={cn( + "flex size-7 shrink-0 items-center justify-center border p-0 text-xs font-semibold tracking-tight", + brandTint(account.id) + )} + > + {accountMonogram(account.name)} + + ) +} + +function AccountNameAffordance({ name }: { name: string }) { + return ( + + + {name} + + + ) +} + +function AccountNameCell({ account }: { account: Account }) { + return ( +
+ +
+ +
+
+ ) +} + +function OwnerCell({ owner }: { owner: AccountOwner }) { + return ( +
+ + + {owner.name} + +
+ ) +} + +function RenewalCell({ account }: { account: Account }) { + return ( + + + ) +} + +function AccountActionsCell({ + account, + onAction, +}: { + account: Account + onAction: (action: AccountAction, account: Account) => void +}) { + return ( + + + } + > + + {/* Content */} + + + onAction("open", account)}> + + onAction("copy", account)}> + + + onAction("plan", account)}> + + + + + ) +} + +// ── Region (group) cells ── + +function RegionExpandButton({ + label, + expanded, + onToggle, +}: { + label: string + expanded: boolean + onToggle: () => void +}) { + return ( + + ) +} + +function RegionGroupCell({ + row, + expanded, + onToggle, +}: { + row: RegionGroupRow + expanded: boolean + onToggle: () => void +}) { + const count = row.subRows?.length ?? 0 + + return ( +
+ +
+ ) +} + +/** Right-aligned numeric slot shared by account and group rows. */ +function NumericSlot({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} + +export function createPortfolioColumns({ + onAction, +}: { + onAction: (action: AccountAction, account: Account) => void +}): ColumnDef[] { + return [ + { + accessorFn: (row) => + isAccountRow(row) ? row.account.name : row.region.name, + id: "account", + header: ({ column }) => ( + + ), + cell: ({ row }) => + isAccountRow(row.original) ? ( + + ) : ( + + ), + enableHiding: false, + enableSorting: false, + minSize: 240, + meta: { + headerTitle: "Account", + autoSize: true, + }, + }, + { + accessorFn: (row) => + isAccountRow(row) ? row.account.owner.name : row.region.summary, + id: "owner", + header: ({ column }) => ( + + ), + cell: ({ row }) => + isAccountRow(row.original) ? ( + + ) : ( + + {row.original.region.summary} + + ), + size: 190, + enableSorting: false, + meta: { + headerTitle: "Owner", + }, + }, + { + accessorFn: (row) => (isAccountRow(row) ? row.account.tier : ""), + id: "tier", + header: ({ column }) => ( + + ), + cell: ({ row }) => + isAccountRow(row.original) ? ( + + {row.original.account.tier} + + ) : null, + size: 120, + enableSorting: false, + meta: { + headerTitle: "Tier", + }, + }, + { + accessorFn: (row) => (isAccountRow(row) ? row.account.health : ""), + id: "health", + header: ({ column }) => ( + + ), + cell: ({ row }) => + isAccountRow(row.original) ? ( + + {row.original.account.health} + + ) : null, + size: 120, + enableSorting: false, + meta: { + headerTitle: "Health", + }, + }, + { + accessorFn: (row) => + isAccountRow(row) ? row.account.arr : sumArr(getRegionAccounts(row)), + id: "arr", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = isAccountRow(row.original) + ? row.original.account.arr + : sumArr(getRegionAccounts(row.original)) + return ( + + + {formatCurrency(value)} + + + ) + }, + size: 150, + enableSorting: false, + meta: { + headerTitle: "ARR", + headerClassName: "text-right!", + cellClassName: "text-right!", + }, + }, + { + accessorFn: (row) => + isAccountRow(row) + ? row.account.nrr + : weightedNrr(getRegionAccounts(row)), + id: "nrr", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = isAccountRow(row.original) + ? row.original.account.nrr + : weightedNrr(getRegionAccounts(row.original)) + return ( + + + + ) + }, + size: 120, + enableSorting: false, + meta: { + headerTitle: "NRR", + headerClassName: "text-right!", + cellClassName: "text-right!", + }, + }, + { + accessorFn: (row) => + isAccountRow(row) + ? row.account.renewalAt + : (nextRenewal(getRegionAccounts(row))?.renewalAt ?? ""), + id: "renewal", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + if (isAccountRow(row.original)) { + return ( +
+ +
+ ) + } + const upcoming = nextRenewal(getRegionAccounts(row.original)) + return ( +
+ {upcoming ? ( + + Next + + {upcoming.renewalLabel} + + + ) : ( + -- + )} +
+ ) + }, + size: 184, + minSize: 150, + enableSorting: false, + meta: { + headerTitle: "Renewal", + headerClassName: "text-right!", + }, + }, + { + id: "actions", + header: "", + cell: ({ row }) => + isAccountRow(row.original) ? ( +
+ +
+ ) : null, + size: 56, + enableHiding: false, + enableSorting: false, + }, + ] +} \ No newline at end of file diff --git a/apps/web/src/components/blocks/data-grid-grouping-2/components/data-grid-view.tsx b/apps/web/src/components/blocks/data-grid-grouping-2/components/data-grid-view.tsx new file mode 100644 index 0000000..53b7e88 --- /dev/null +++ b/apps/web/src/components/blocks/data-grid-grouping-2/components/data-grid-view.tsx @@ -0,0 +1,620 @@ +import { useCallback, useMemo, useState, type ComponentProps } from "react" +import { Badge } from "@/components/reui/badge" +import { + DataGrid, + DataGridContainer, +} from "@/components/reui/data-grid/data-grid" +import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area" +import { + DataGridTable, + DataGridTableFootRow, + DataGridTableFootRowCell, +} from "@/components/reui/data-grid/data-grid-table" +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from "@/components/reui/frame" +import { + getCoreRowModel, + getExpandedRowModel, + useReactTable, + type ExpandedState, + type VisibilityState, +} from "@tanstack/react-table" +import { toast } from "sonner" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@cfdm/ui/components/dropdown-menu" +import { + Field, + FieldGroup, + FieldLabel, + FieldSeparator, +} from "@cfdm/ui/components/field" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@cfdm/ui/components/input-group" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@cfdm/ui/components/popover" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@cfdm/ui/components/select" +import { createPortfolioColumns, NrrValue, type AccountAction } from "./columns" +import { + ACCOUNT_HEALTH_OPTIONS, + ACCOUNTS, + formatCompactCurrency, + formatCurrency, + REGIONS, + sumArr, + weightedNrr, + type Account, + type AccountHealth, + type AccountRow, + type PortfolioRow, + type RegionGroupRow, +} from "./data" +import { DownloadIcon, SearchIcon, XIcon, ActivityIcon, Settings2Icon, CheckIcon } from "lucide-react" + +type TableDensity = "compact" | "comfortable" + +type PortfolioColumnKey = "owner" | "tier" | "health" | "nrr" | "renewal" + +const TABLE_DENSITY_OPTIONS: { value: TableDensity; label: string }[] = [ + { value: "compact", label: "Compact" }, + { value: "comfortable", label: "Comfortable" }, +] + +const DISPLAY_COLUMN_OPTIONS: { key: PortfolioColumnKey; label: string }[] = [ + { key: "owner", label: "Owner" }, + { key: "tier", label: "Tier" }, + { key: "health", label: "Health" }, + { key: "nrr", label: "NRR" }, + { key: "renewal", label: "Renewal" }, +] + +function getAccountSearchBlob(account: Account) { + return [ + account.name, + account.industry, + account.owner.name, + account.owner.role, + account.tier, + account.health, + ] + .filter(Boolean) + .join(" ") + .toLowerCase() +} + +function buildPortfolioRows(accounts: Account[]): RegionGroupRow[] { + return REGIONS.map((region) => { + const subRows: AccountRow[] = accounts + .filter((account) => account.regionId === region.id) + .map((account) => ({ + kind: "account", + id: account.id, + region, + account, + })) + + const row: RegionGroupRow = { + kind: "region", + id: region.id, + region, + subRows, + } + + return row + }).filter((row) => (row.subRows?.length ?? 0) > 0) +} + +function getExpandedRegionState(rows: RegionGroupRow[]): ExpandedState { + return rows.reduce>((expanded, row) => { + expanded[row.id] = true + return expanded + }, {}) +} + +function isExpanded(expanded: ExpandedState, rowId: string) { + if (expanded === true) return true + return expanded[rowId] === true +} + +function PortfolioMetric({ + label, + value, + variant = "secondary", +}: { + label: string + value: string + variant?: ComponentProps["variant"] +}) { + return ( +
+ + {label} + + + {value} + +
+ ) +} + +export function GroupedRevenueDataGridView() { + const [searchQuery, setSearchQuery] = useState("") + const [selectedHealth, setSelectedHealth] = useState([]) + const [tableDensity, setTableDensity] = useState("comfortable") + const [visibleColumns, setVisibleColumns] = useState< + Record + >({ + owner: true, + tier: false, + health: true, + nrr: true, + renewal: true, + }) + const [expandedRows, setExpandedRows] = useState(() => { + // Start collapsed; open only the first region as a preview. + const [firstRegion] = buildPortfolioRows(ACCOUNTS) + return firstRegion ? { [firstRegion.id]: true } : {} + }) + + const filteredAccounts = useMemo(() => { + const normalizedQuery = searchQuery.trim().toLowerCase() + + return ACCOUNTS.filter((account) => { + if ( + normalizedQuery.length > 0 && + !getAccountSearchBlob(account).includes(normalizedQuery) + ) { + return false + } + + if ( + selectedHealth.length > 0 && + !selectedHealth.includes(account.health) + ) { + return false + } + + return true + }) + }, [searchQuery, selectedHealth]) + + const groupedRows = useMemo( + () => buildPortfolioRows(filteredAccounts), + [filteredAccounts] + ) + + const allGroupsExpanded = + groupedRows.length > 0 && + groupedRows.every((row) => isExpanded(expandedRows, row.id)) + + const activeFilterCount = selectedHealth.length + const totalArr = sumArr(filteredAccounts) + const portfolioNrr = weightedNrr(filteredAccounts) + const atRiskArr = sumArr( + filteredAccounts.filter((account) => account.health === "At Risk") + ) + + const columnVisibility = useMemo( + () => ({ + owner: visibleColumns.owner, + tier: visibleColumns.tier, + health: visibleColumns.health, + nrr: visibleColumns.nrr, + renewal: visibleColumns.renewal, + }), + [visibleColumns] + ) + + const handleHealthToggle = useCallback( + (health: AccountHealth, checked: boolean) => { + setSelectedHealth((current) => { + if (checked) { + return current.includes(health) ? current : [...current, health] + } + + return current.filter((item) => item !== health) + }) + }, + [] + ) + + const handleToggleGroups = useCallback(() => { + setExpandedRows( + allGroupsExpanded ? {} : getExpandedRegionState(groupedRows) + ) + }, [allGroupsExpanded, groupedRows]) + + const handleAccountAction = useCallback( + (action: AccountAction, account: Account) => { + if (action === "open") { + toast.info("View account", { + description: `${account.name} / ${account.industry}`, + }) + return + } + + if (action === "copy") { + if (typeof navigator !== "undefined" && navigator.clipboard) { + void navigator.clipboard.writeText(account.name) + } + + toast.success("Account name copied", { + description: account.name, + }) + return + } + + toast.message("Adjust plan", { + description: `Connect ${account.name} to your renewal or pricing flow.`, + }) + }, + [] + ) + + const handleExport = useCallback(() => { + toast.success("Export portfolio", { + description: "Connect this action to your CSV or warehouse export.", + }) + }, []) + + const columns = useMemo( + () => + createPortfolioColumns({ + onAction: handleAccountAction, + }), + [handleAccountAction] + ) + + const table = useReactTable({ + data: groupedRows, + columns, + getRowId: (row) => row.id, + getSubRows: (row) => + row.kind === "region" + ? (row.subRows as PortfolioRow[] | undefined) + : undefined, + getRowCanExpand: (row) => + row.original.kind === "region" && Boolean(row.original.subRows?.length), + state: { + columnVisibility, + expanded: expandedRows, + }, + onExpandedChange: setExpandedRows, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + }) + + function toggleColumn(key: PortfolioColumnKey, checked: boolean) { + setVisibleColumns((current) => ({ + ...current, + [key]: checked, + })) + } + + function clearFilters() { + setSearchQuery("") + setSelectedHealth([]) + } + + // Grand-total footer: one cell per visible column so it tracks column toggles. + const footerContent = + filteredAccounts.length > 0 ? ( + + {table.getVisibleLeafColumns().map((column) => { + if (column.id === "account") { + return ( + +
+ + All Regions + + + {filteredAccounts.length} + +
+
+ ) + } + + if (column.id === "arr") { + return ( + + + {formatCurrency(totalArr)} + + + ) + } + + if (column.id === "nrr") { + return ( + +
+ +
+
+ ) + } + + return + })} +
+ ) : undefined + + return ( + tr:has([data-portfolio-row=region])+tr:has(>td:only-child:empty)>td]:!border-b-0", + bodyRow: + "group/portfolio-row [&>td]:h-11 [&:has([data-portfolio-row=region])>td]:h-11", + edgeCell: "first:ps-3 last:pe-3 lg:first:ps-4 lg:last:pe-4", + }} + > +
+ + {/* Header */} + +
+ Revenue By Region + + Net retention and renewals across the book. + +
+ +
+ + = 100 ? "success-light" : "warning-light" + } + /> + 0 ? "destructive-light" : "secondary"} + /> + +
+
+ + + {/* Toolbar */} +
+ + + + setSearchQuery(event.target.value)} + placeholder="Search accounts..." + aria-label="Search accounts" + /> + {searchQuery.length > 0 ? ( + + setSearchQuery("")} + > + + + ) : null} + + +
+ + + + + + + + + + + {searchQuery.length > 0 || selectedHealth.length > 0 ? ( + + ) : null} +
+
+ + {/* Grouped grid */} + + + + + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/blocks/data-grid-grouping-2/components/data.tsx b/apps/web/src/components/blocks/data-grid-grouping-2/components/data.tsx new file mode 100644 index 0000000..6f880c4 --- /dev/null +++ b/apps/web/src/components/blocks/data-grid-grouping-2/components/data.tsx @@ -0,0 +1,459 @@ +export type RegionId = "na" | "emea" | "apac" | "latam" + +export type AccountTier = "Enterprise" | "Growth" | "Startup" + +export type AccountHealth = "Healthy" | "Watch" | "At Risk" + +export interface AccountOwner { + id: string + name: string + initials: string + avatarSrc?: string + role: string +} + +export interface Region { + id: RegionId + name: string + summary: string + order: number +} + +export interface Account { + id: string + name: string + industry: string + regionId: RegionId + owner: AccountOwner + tier: AccountTier + health: AccountHealth + arr: number + nrr: number + renewalAt: string + renewalLabel: string +} + +// ── Row union (TanStack nested rows: region group → account leaf) ── + +export interface RegionGroupRow { + kind: "region" + id: string + region: Region + subRows?: AccountRow[] +} + +export interface AccountRow { + kind: "account" + id: string + region: Region + account: Account +} + +export type PortfolioRow = RegionGroupRow | AccountRow + +export const REGION_ORDER: RegionId[] = ["na", "emea", "apac", "latam"] + +export const ACCOUNT_TIERS: AccountTier[] = ["Enterprise", "Growth", "Startup"] + +export const ACCOUNT_HEALTH_OPTIONS: AccountHealth[] = [ + "Healthy", + "Watch", + "At Risk", +] + +export const REGIONS: Region[] = [ + { + id: "na", + name: "North America", + summary: "US and Canada strategic accounts", + order: 1, + }, + { + id: "emea", + name: "EMEA", + summary: "Europe, Middle East, and Africa book", + order: 2, + }, + { + id: "apac", + name: "APAC", + summary: "Asia Pacific growth territory", + order: 3, + }, + { + id: "latam", + name: "LATAM", + summary: "Latin America emerging accounts", + order: 4, + }, +] + +export const ACCOUNT_OWNERS: AccountOwner[] = [ + { + id: "rina", + name: "Rina Holt", + initials: "RH", + avatarSrc: + "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80", + role: "Enterprise AE", + }, + { + id: "vale", + name: "Vale Aksoy", + initials: "VA", + avatarSrc: + "https://images.unsplash.com/photo-1519345182560-3f2917c472ef?w=96&h=96&dpr=2&q=80", + role: "Strategic AE", + }, + { + id: "noor", + name: "Noor Albright", + initials: "NA", + avatarSrc: + "https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=96&h=96&dpr=2&q=80", + role: "Account Manager", + }, + { + id: "sora", + name: "Sora Min", + initials: "SM", + avatarSrc: + "https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80", + role: "Growth AE", + }, + { + id: "evren", + name: "Evren Blake", + initials: "EB", + avatarSrc: + "https://images.unsplash.com/photo-1547425260-76bcadfb4f2c?w=96&h=96&dpr=2&q=80", + role: "Enterprise AE", + }, + { + id: "mina", + name: "Mina Rowe", + initials: "MR", + avatarSrc: + "https://images.unsplash.com/photo-1552058544-f2b08422138a?w=96&h=96&dpr=2&q=80", + role: "Account Manager", + }, +] + +function owner(id: AccountOwner["id"]) { + const match = ACCOUNT_OWNERS.find((item) => item.id === id) + + if (!match) { + throw new Error(`Unknown account owner: ${id}`) + } + + return match +} + +export const ACCOUNTS: Account[] = [ + { + id: "acc-northwind", + name: "Northwind Trading", + industry: "Logistics", + regionId: "na", + owner: owner("rina"), + tier: "Enterprise", + health: "Healthy", + arr: 920000, + nrr: 124, + renewalAt: "2026-09-14", + renewalLabel: "Sep 14, 2026", + }, + { + id: "acc-cedar", + name: "Cedar Health Systems", + industry: "Healthcare", + regionId: "na", + owner: owner("evren"), + tier: "Enterprise", + health: "Watch", + arr: 760000, + nrr: 103, + renewalAt: "2026-07-02", + renewalLabel: "Jul 2, 2026", + }, + { + id: "acc-vantage", + name: "Vantage Robotics", + industry: "Manufacturing", + regionId: "na", + owner: owner("rina"), + tier: "Growth", + health: "Healthy", + arr: 410000, + nrr: 118, + renewalAt: "2026-11-21", + renewalLabel: "Nov 21, 2026", + }, + { + id: "acc-brightline", + name: "Brightline Media", + industry: "Media", + regionId: "na", + owner: owner("mina"), + tier: "Growth", + health: "At Risk", + arr: 285000, + nrr: 92, + renewalAt: "2026-06-30", + renewalLabel: "Jun 30, 2026", + }, + { + id: "acc-summit", + name: "Summit Analytics", + industry: "Software", + regionId: "na", + owner: owner("evren"), + tier: "Startup", + health: "Healthy", + arr: 138000, + nrr: 129, + renewalAt: "2026-10-09", + renewalLabel: "Oct 9, 2026", + }, + { + id: "acc-helvetia", + name: "Helvetia Pay", + industry: "Fintech", + regionId: "emea", + owner: owner("vale"), + tier: "Enterprise", + health: "Healthy", + arr: 845000, + nrr: 121, + renewalAt: "2026-08-18", + renewalLabel: "Aug 18, 2026", + }, + { + id: "acc-nordwind", + name: "Nordwind Energy", + industry: "Energy", + regionId: "emea", + owner: owner("noor"), + tier: "Enterprise", + health: "Watch", + arr: 690000, + nrr: 99, + renewalAt: "2026-07-27", + renewalLabel: "Jul 27, 2026", + }, + { + id: "acc-albion", + name: "Albion Retail Group", + industry: "Retail", + regionId: "emea", + owner: owner("vale"), + tier: "Growth", + health: "At Risk", + arr: 320000, + nrr: 88, + renewalAt: "2026-06-24", + renewalLabel: "Jun 24, 2026", + }, + { + id: "acc-lumen", + name: "Lumen Telecom", + industry: "Telecom", + regionId: "emea", + owner: owner("noor"), + tier: "Growth", + health: "Healthy", + arr: 455000, + nrr: 115, + renewalAt: "2026-12-03", + renewalLabel: "Dec 3, 2026", + }, + { + id: "acc-castellan", + name: "Castellan Bank", + industry: "Banking", + regionId: "emea", + owner: owner("vale"), + tier: "Enterprise", + health: "Healthy", + arr: 980000, + nrr: 109, + renewalAt: "2026-09-30", + renewalLabel: "Sep 30, 2026", + }, + { + id: "acc-sakura", + name: "Sakura Mobility", + industry: "Mobility", + regionId: "apac", + owner: owner("sora"), + tier: "Growth", + health: "Healthy", + arr: 372000, + nrr: 126, + renewalAt: "2026-10-22", + renewalLabel: "Oct 22, 2026", + }, + { + id: "acc-pacific", + name: "Pacific Cloud", + industry: "Software", + regionId: "apac", + owner: owner("mina"), + tier: "Enterprise", + health: "Watch", + arr: 615000, + nrr: 101, + renewalAt: "2026-08-05", + renewalLabel: "Aug 5, 2026", + }, + { + id: "acc-banyan", + name: "Banyan AgriTech", + industry: "Agriculture", + regionId: "na", + owner: owner("sora"), + tier: "Startup", + health: "Healthy", + arr: 124000, + nrr: 132, + renewalAt: "2026-11-12", + renewalLabel: "Nov 12, 2026", + }, + { + id: "acc-meridian", + name: "Meridian Shipping", + industry: "Logistics", + regionId: "apac", + owner: owner("mina"), + tier: "Growth", + health: "At Risk", + arr: 298000, + nrr: 90, + renewalAt: "2026-06-27", + renewalLabel: "Jun 27, 2026", + }, + { + id: "acc-hanwoo", + name: "Hanwoo Foods", + industry: "Food and Beverage", + regionId: "apac", + owner: owner("sora"), + tier: "Growth", + health: "Healthy", + arr: 340000, + nrr: 112, + renewalAt: "2026-09-08", + renewalLabel: "Sep 8, 2026", + }, + { + id: "acc-andes", + name: "Andes Fintech", + industry: "Fintech", + regionId: "na", + owner: owner("noor"), + tier: "Growth", + health: "Healthy", + arr: 268000, + nrr: 122, + renewalAt: "2026-10-30", + renewalLabel: "Oct 30, 2026", + }, + { + id: "acc-costera", + name: "Costera Travel", + industry: "Travel", + regionId: "latam", + owner: owner("rina"), + tier: "Startup", + health: "Watch", + arr: 96000, + nrr: 104, + renewalAt: "2026-07-15", + renewalLabel: "Jul 15, 2026", + }, + { + id: "acc-verde", + name: "Verde Logistics", + industry: "Logistics", + regionId: "latam", + owner: owner("evren"), + tier: "Growth", + health: "Healthy", + arr: 312000, + nrr: 117, + renewalAt: "2026-12-09", + renewalLabel: "Dec 9, 2026", + }, + { + id: "acc-pampa", + name: "Pampa Retail", + industry: "Retail", + regionId: "latam", + owner: owner("sora"), + tier: "Startup", + health: "At Risk", + arr: 142000, + nrr: 89, + renewalAt: "2026-06-22", + renewalLabel: "Jun 22, 2026", + }, + { + id: "acc-tucan", + name: "Tucan Media", + industry: "Media", + regionId: "emea", + owner: owner("noor"), + tier: "Growth", + health: "Healthy", + arr: 205000, + nrr: 113, + renewalAt: "2026-09-19", + renewalLabel: "Sep 19, 2026", + }, +] + +// ── Formatting + aggregate helpers (raw values stay in data, formatting here) ── + +const CURRENCY_FORMATTER = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, +}) + +export function formatCurrency(value: number) { + return CURRENCY_FORMATTER.format(value) +} + +/** Compact money for dense KPI/subtotal slots: 2_513_000 -> "$2.51M". */ +export function formatCompactCurrency(value: number) { + if (Math.abs(value) >= 1_000_000) { + return `$${(value / 1_000_000).toFixed(2)}M` + } + if (Math.abs(value) >= 1_000) { + return `$${Math.round(value / 1_000)}K` + } + return formatCurrency(value) +} + +export function sumArr(accounts: Account[]) { + return accounts.reduce((total, account) => total + account.arr, 0) +} + +/** ARR-weighted NRR so big accounts move the group average correctly. */ +export function weightedNrr(accounts: Account[]) { + const totalArr = sumArr(accounts) + if (totalArr === 0) return 0 + const weighted = accounts.reduce( + (total, account) => total + account.arr * account.nrr, + 0 + ) + return Math.round(weighted / totalArr) +} + +/** Earliest renewal date in the group (ISO strings sort lexicographically). */ +export function nextRenewal(accounts: Account[]) { + return accounts.reduce((earliest, account) => { + if (!earliest || account.renewalAt < earliest.renewalAt) return account + return earliest + }, null) +} \ No newline at end of file diff --git a/apps/web/src/components/blocks/data-grid-grouping-2/page.tsx b/apps/web/src/components/blocks/data-grid-grouping-2/page.tsx new file mode 100644 index 0000000..757b175 --- /dev/null +++ b/apps/web/src/components/blocks/data-grid-grouping-2/page.tsx @@ -0,0 +1,15 @@ +import { GroupedRevenueDataGridView } from "./components/data-grid-view" + +export function Page() { + return ( +
+

+ Revenue by region grouped data grid +

+ +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/blocks/empty-state-12/components/data.ts b/apps/web/src/components/blocks/empty-state-12/components/data.ts new file mode 100644 index 0000000..7bcb740 --- /dev/null +++ b/apps/web/src/components/blocks/empty-state-12/components/data.ts @@ -0,0 +1,48 @@ +export type ToolbarOption = { + value: T + label: string +} + +export const EXPORT_AUDIENCE_OPTIONS = [ + { value: "everyone", label: "Everyone" }, + { value: "ops-leads", label: "Ops leads" }, + { value: "finance-reviewers", label: "Finance reviewers" }, + { value: "client-owners", label: "Client owners" }, +] as const satisfies readonly ToolbarOption[] + +export const EXPORT_SCOPE_OPTIONS = [ + { value: "all-workspaces", label: "All workspaces" }, + { value: "harbor-field", label: "Harbor field" }, + { value: "market-lab", label: "Market lab" }, + { value: "support-desk", label: "Support desk" }, +] as const satisfies readonly ToolbarOption[] + +export const EXPORT_RANGE_OPTIONS = [ + { value: "last-30-days", label: "Last 30 days" }, + { value: "this-quarter", label: "This quarter" }, + { value: "previous-cycle", label: "Previous cycle" }, + { value: "custom-window", label: "Custom window" }, +] as const satisfies readonly ToolbarOption[] + +export const EXPORT_FORMAT_OPTIONS = [ + { + value: "csv", + label: "CSV bundle", + description: "Spreadsheet-ready activity rows", + }, + { + value: "pdf", + label: "PDF brief", + description: "A concise review packet for stakeholders", + }, + { + value: "schedule", + label: "Schedule delivery", + description: "Send this export every Friday morning", + }, +] as const + +export type ExportAudience = (typeof EXPORT_AUDIENCE_OPTIONS)[number]["value"] +export type ExportScope = (typeof EXPORT_SCOPE_OPTIONS)[number]["value"] +export type ExportRange = (typeof EXPORT_RANGE_OPTIONS)[number]["value"] +export type ExportFormat = (typeof EXPORT_FORMAT_OPTIONS)[number]["value"] \ No newline at end of file diff --git a/apps/web/src/components/blocks/empty-state-12/components/empty-state.tsx b/apps/web/src/components/blocks/empty-state-12/components/empty-state.tsx new file mode 100644 index 0000000..88c47a4 --- /dev/null +++ b/apps/web/src/components/blocks/empty-state-12/components/empty-state.tsx @@ -0,0 +1,247 @@ +import { useState } from "react" +import { IconStack } from "@/components/reui/icon-stack" +import { toast } from "sonner" + +import { Button } from "@cfdm/ui/components/button" +import { + ButtonGroup, + ButtonGroupSeparator, +} from "@cfdm/ui/components/button-group" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@cfdm/ui/components/dropdown-menu" +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@cfdm/ui/components/empty" +import { Separator } from "@cfdm/ui/components/separator" +import { + EXPORT_AUDIENCE_OPTIONS, + EXPORT_FORMAT_OPTIONS, + EXPORT_RANGE_OPTIONS, + EXPORT_SCOPE_OPTIONS, + type ExportAudience, + type ExportFormat, + type ExportRange, + type ExportScope, + type ToolbarOption, +} from "./data" +import { ChevronDownIcon, PlayIcon, FileDownIcon, BookOpenIcon, CalendarClockIcon, ArchiveIcon } from "lucide-react" + +type ToolbarFilterProps = { + label: string + value: T + options: readonly ToolbarOption[] + onValueChange: (value: T) => void +} + +function getOptionLabel( + options: readonly ToolbarOption[], + value: T +) { + return options.find((option) => option.value === value)?.label ?? value +} + +function ToolbarFilter({ + label, + value, + options, + onValueChange, +}: ToolbarFilterProps) { + const selectedLabel = getOptionLabel(options, value) + + return ( + + + {selectedLabel} + + ) +} + +export function EmptyState() { + const [audience, setAudience] = useState( + EXPORT_AUDIENCE_OPTIONS[0].value + ) + const [scope, setScope] = useState(EXPORT_SCOPE_OPTIONS[0].value) + const [range, setRange] = useState(EXPORT_RANGE_OPTIONS[0].value) + + const audienceLabel = getOptionLabel(EXPORT_AUDIENCE_OPTIONS, audience) + const scopeLabel = getOptionLabel(EXPORT_SCOPE_OPTIONS, scope) + const rangeLabel = getOptionLabel(EXPORT_RANGE_OPTIONS, range) + + const showExportToast = (format: ExportFormat = "csv") => { + const formatOption = + EXPORT_FORMAT_OPTIONS.find((option) => option.value === format) ?? + EXPORT_FORMAT_OPTIONS[0] + + toast.message(`${formatOption.label} is ready to wire`, { + description: `${formatOption.description}. ${audienceLabel} · ${scopeLabel} · ${rangeLabel}. Connect this action to your export job when activity records exist.`, + }) + } + + return ( +
+ {/* Header */} +
+ {/* Heading */} +
+ {/* Title and Description */} +
+ {/* Title */} +

+ Activity Exports +

+ {/* Description */} +

+ Download scoped activity packets for billing review, staffing + audits, and client handoffs. +

+
+ + {/* Filters */} +
+ + + +
+
+ + {/* Download Action */} + + + + + + + + } + > + + + + + showExportToast("csv")}> + + showExportToast("pdf")}> + + + showExportToast("schedule")}> + + + + + +
+ + + + {/* Empty State */} +
+ + + + + + + {/* Empty State Content */} +
+ + No exportable activity yet + + + Capture approved activity and this view will assemble your next + review packet. + +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/blocks/empty-state-12/page.tsx b/apps/web/src/components/blocks/empty-state-12/page.tsx new file mode 100644 index 0000000..04ab719 --- /dev/null +++ b/apps/web/src/components/blocks/empty-state-12/page.tsx @@ -0,0 +1,15 @@ +import { EmptyState } from "./components/empty-state" + +export function Page() { + return ( +
+

+ Activity exports empty state +

+ +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/blocks/kanban-board-8/components/kanban-board.tsx b/apps/web/src/components/blocks/kanban-board-8/components/kanban-board.tsx index 1b51e0a..58b76d9 100644 --- a/apps/web/src/components/blocks/kanban-board-8/components/kanban-board.tsx +++ b/apps/web/src/components/blocks/kanban-board-8/components/kanban-board.tsx @@ -1,5 +1,3 @@ -"use client" - import { useState, type ComponentProps, type ReactNode } from "react" import { Badge } from "@/components/reui/badge" import { diff --git a/apps/web/src/components/blocks/settings-8/components/data.tsx b/apps/web/src/components/blocks/settings-8/components/data.tsx new file mode 100644 index 0000000..54cbe76 --- /dev/null +++ b/apps/web/src/components/blocks/settings-8/components/data.tsx @@ -0,0 +1,243 @@ +import { type ReactNode } from "react" +import type { BadgeProps } from "@/components/reui/badge" +import { CreditCardIcon, UsersIcon, RepeatIcon, BanknoteIcon, ShieldAlertIcon } from "lucide-react" + +// ── Types ── + +export type EndpointStatus = "active" | "failing" | "disabled" + +export type EndpointAlertTone = "success" | "warning" | "critical" + +export interface EndpointAlert { + id: string + tone: EndpointAlertTone + badgeLabel: string + detail: string +} + +export interface WebhookEvent { + id: string + label: string +} + +export interface WebhookEndpoint { + id: string + name: string + url: string + description: string + events: WebhookEvent[] + status: EndpointStatus + secret: string + lastDelivery: string | null + secretRotation: string + owner: string + icon: ReactNode +} + +export type WebhookEndpointActionHandlers = { + onManage: (endpoint: WebhookEndpoint) => void + onViewDeliveries: (endpoint: WebhookEndpoint) => void + onRotateSecret: (endpoint: WebhookEndpoint) => void + onRemove: (endpoint: WebhookEndpoint) => void +} + +// ── Config ── + +export const STATUS_CONFIG: Record< + EndpointStatus, + { label: string; variant: BadgeProps["variant"] } +> = { + active: { label: "Delivering", variant: "success-light" }, + failing: { label: "Retrying", variant: "warning-light" }, + disabled: { label: "Paused", variant: "outline" }, +} + +export const ALERT_CONFIG: Record< + EndpointAlertTone, + { + toneClassName: string + badgeVariant: BadgeProps["variant"] + } +> = { + success: { + toneClassName: "text-green-600", + badgeVariant: "success", + }, + warning: { + toneClassName: "text-warning", + badgeVariant: "warning", + }, + critical: { + toneClassName: "text-destructive", + badgeVariant: "destructive", + }, +} + +// ── Data ── + +export const ENDPOINTS: WebhookEndpoint[] = [ + { + id: "endpoint-billing", + name: "Billing Pipeline", + url: "https://events.acme.dev/webhooks/billing", + description: "Invoices and disputes.", + events: [ + { id: "invoice.paid", label: "invoice.paid" }, + { id: "invoice.failed", label: "invoice.failed" }, + { id: "charge.refunded", label: "charge.refunded" }, + ], + status: "active", + secret: "whsec_1h7qv4r9m2x6k8p3", + lastDelivery: "2m ago", + secretRotation: "12d ago", + owner: "Revenue", + icon: ( +