feat(api, web): integrate health status management for services and domains
Build, Test, and Push CFDM Docker Image / test (push) Successful in 10m17s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m48s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Successful in 10m17s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m48s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
- 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 <[email protected]>
This commit is contained in:
@@ -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<typeof Badge>["variant"]
|
||||
> = {
|
||||
Enterprise: "outline",
|
||||
Growth: "outline",
|
||||
Startup: "outline",
|
||||
}
|
||||
|
||||
const healthVariant: Record<
|
||||
AccountHealth,
|
||||
ComponentProps<typeof Badge>["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<string, string>()
|
||||
const regionTintCursor = new Map<Account["regionId"], number>()
|
||||
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 (
|
||||
<Avatar className={cn("size-6 shrink-0", className)}>
|
||||
{owner.avatarSrc ? (
|
||||
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="text-[10px]">{owner.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
})
|
||||
|
||||
export function NrrValue({ value }: { value: number }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-sm tabular-nums",
|
||||
nrrTone(value)
|
||||
)}
|
||||
>
|
||||
{value >= 100 ? (
|
||||
<TrendingUp className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<TrendingDown className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{value}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Account (leaf) cells ──
|
||||
|
||||
function AccountLogoTile({ account }: { account: Account }) {
|
||||
return (
|
||||
<Item
|
||||
render={<span />}
|
||||
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)}
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountNameAffordance({ name }: { name: string }) {
|
||||
return (
|
||||
<span className="group/account-name inline-flex min-w-0 items-center gap-1 truncate">
|
||||
<span
|
||||
data-slot="portfolio-account-name"
|
||||
className="hover:text-primary text-foreground max-w-full cursor-pointer truncate py-0.25 transition-colors"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<ArrowRightIcon className="size-3 shrink-0 -translate-x-1 opacity-0 transition group-hover/account-name:translate-x-0 group-hover/account-name:opacity-100" aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountNameCell({ account }: { account: Account }) {
|
||||
return (
|
||||
<div
|
||||
data-portfolio-row="account"
|
||||
className="flex min-w-0 items-center gap-3 ps-8"
|
||||
>
|
||||
<AccountLogoTile account={account} />
|
||||
<div className="min-w-0 flex-1 text-sm leading-5 font-medium">
|
||||
<AccountNameAffordance name={account.name} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OwnerCell({ owner }: { owner: AccountOwner }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<OwnerAvatar owner={owner} />
|
||||
<span className="text-foreground min-w-0 truncate text-sm">
|
||||
{owner.name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RenewalCell({ account }: { account: Account }) {
|
||||
return (
|
||||
<Badge variant="outline" className="bg-background gap-1.5">
|
||||
<CalendarDaysIcon className="text-muted-foreground size-3.5" aria-hidden="true" />
|
||||
<span className="tabular-nums">{account.renewalLabel}</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountActionsCell({
|
||||
account,
|
||||
onAction,
|
||||
}: {
|
||||
account: Account
|
||||
onAction: (action: AccountAction, account: Account) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Actions for ${account.name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
{/* Content */}
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onAction("open", account)}>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
View account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAction("copy", account)}>
|
||||
<CopyIcon aria-hidden="true" />
|
||||
Copy name
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onAction("plan", account)}>
|
||||
<PencilIcon aria-hidden="true" />
|
||||
Adjust plan
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Region (group) cells ──
|
||||
|
||||
function RegionExpandButton({
|
||||
label,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={expanded ? `Collapse ${label}` : `Expand ${label}`}
|
||||
aria-expanded={expanded}
|
||||
className="text-muted-foreground hover:text-foreground size-6 shrink-0 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>
|
||||
)
|
||||
}
|
||||
|
||||
function RegionGroupCell({
|
||||
row,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
row: RegionGroupRow
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const count = row.subRows?.length ?? 0
|
||||
|
||||
return (
|
||||
<div
|
||||
data-portfolio-row="region"
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<RegionExpandButton
|
||||
label={row.region.name}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
<GlobeIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="text-foreground min-w-0 truncate text-sm font-semibold">
|
||||
{row.region.name}
|
||||
</span>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Right-aligned numeric slot shared by account and group rows. */
|
||||
function NumericSlot({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex w-full items-center justify-end", className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function createPortfolioColumns({
|
||||
onAction,
|
||||
}: {
|
||||
onAction: (action: AccountAction, account: Account) => void
|
||||
}): ColumnDef<PortfolioRow>[] {
|
||||
return [
|
||||
{
|
||||
accessorFn: (row) =>
|
||||
isAccountRow(row) ? row.account.name : row.region.name,
|
||||
id: "account",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Account" column={column} />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
isAccountRow(row.original) ? (
|
||||
<AccountNameCell account={row.original.account} />
|
||||
) : (
|
||||
<RegionGroupCell
|
||||
row={row.original}
|
||||
expanded={row.getIsExpanded()}
|
||||
onToggle={row.getToggleExpandedHandler()}
|
||||
/>
|
||||
),
|
||||
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 }) => (
|
||||
<DataGridColumnHeader title="Owner" column={column} />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
isAccountRow(row.original) ? (
|
||||
<OwnerCell owner={row.original.account.owner} />
|
||||
) : (
|
||||
<span className="text-muted-foreground truncate text-sm">
|
||||
{row.original.region.summary}
|
||||
</span>
|
||||
),
|
||||
size: 190,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerTitle: "Owner",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => (isAccountRow(row) ? row.account.tier : ""),
|
||||
id: "tier",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Tier" column={column} />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
isAccountRow(row.original) ? (
|
||||
<Badge variant={tierVariant[row.original.account.tier]}>
|
||||
{row.original.account.tier}
|
||||
</Badge>
|
||||
) : null,
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerTitle: "Tier",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => (isAccountRow(row) ? row.account.health : ""),
|
||||
id: "health",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Health" column={column} />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
isAccountRow(row.original) ? (
|
||||
<Badge variant={healthVariant[row.original.account.health]}>
|
||||
{row.original.account.health}
|
||||
</Badge>
|
||||
) : null,
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerTitle: "Health",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) =>
|
||||
isAccountRow(row) ? row.account.arr : sumArr(getRegionAccounts(row)),
|
||||
id: "arr",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="ARR"
|
||||
column={column}
|
||||
className="w-full justify-end text-right"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const value = isAccountRow(row.original)
|
||||
? row.original.account.arr
|
||||
: sumArr(getRegionAccounts(row.original))
|
||||
return (
|
||||
<NumericSlot>
|
||||
<span
|
||||
className={cn(
|
||||
"text-foreground text-sm tabular-nums",
|
||||
isAccountRow(row.original) ? "font-medium" : "font-semibold"
|
||||
)}
|
||||
>
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
</NumericSlot>
|
||||
)
|
||||
},
|
||||
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 }) => (
|
||||
<DataGridColumnHeader
|
||||
title="NRR"
|
||||
column={column}
|
||||
className="w-full justify-end text-right"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const value = isAccountRow(row.original)
|
||||
? row.original.account.nrr
|
||||
: weightedNrr(getRegionAccounts(row.original))
|
||||
return (
|
||||
<NumericSlot>
|
||||
<NrrValue value={value} />
|
||||
</NumericSlot>
|
||||
)
|
||||
},
|
||||
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 }) => (
|
||||
<DataGridColumnHeader
|
||||
title="Renewal"
|
||||
column={column}
|
||||
className="w-full justify-end text-right"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
if (isAccountRow(row.original)) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<RenewalCell account={row.original.account} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const upcoming = nextRenewal(getRegionAccounts(row.original))
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
{upcoming ? (
|
||||
<span className="inline-flex items-baseline gap-1.5 text-sm whitespace-nowrap">
|
||||
<span className="text-muted-foreground">Next</span>
|
||||
<span className="text-foreground tabular-nums">
|
||||
{upcoming.renewalLabel}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">--</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 184,
|
||||
minSize: 150,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerTitle: "Renewal",
|
||||
headerClassName: "text-right!",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) =>
|
||||
isAccountRow(row.original) ? (
|
||||
<div className="flex justify-end">
|
||||
<AccountActionsCell
|
||||
account={row.original.account}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
size: 56,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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<Record<string, boolean>>((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<typeof Badge>["variant"]
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 sm:border-l sm:pl-3 sm:first:border-l-0 sm:first:pl-0">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium">
|
||||
{label}
|
||||
</span>
|
||||
<Badge variant={variant} className="tabular-nums">
|
||||
{value}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GroupedRevenueDataGridView() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedHealth, setSelectedHealth] = useState<AccountHealth[]>([])
|
||||
const [tableDensity, setTableDensity] = useState<TableDensity>("comfortable")
|
||||
const [visibleColumns, setVisibleColumns] = useState<
|
||||
Record<PortfolioColumnKey, boolean>
|
||||
>({
|
||||
owner: true,
|
||||
tier: false,
|
||||
health: true,
|
||||
nrr: true,
|
||||
renewal: true,
|
||||
})
|
||||
const [expandedRows, setExpandedRows] = useState<ExpandedState>(() => {
|
||||
// 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<VisibilityState>(
|
||||
() => ({
|
||||
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 ? (
|
||||
<DataGridTableFootRow>
|
||||
{table.getVisibleLeafColumns().map((column) => {
|
||||
if (column.id === "account") {
|
||||
return (
|
||||
<DataGridTableFootRowCell key={column.id}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="text-foreground text-sm font-semibold">
|
||||
All Regions
|
||||
</span>
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{filteredAccounts.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</DataGridTableFootRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
if (column.id === "arr") {
|
||||
return (
|
||||
<DataGridTableFootRowCell key={column.id} className="text-right!">
|
||||
<span className="text-foreground text-sm font-semibold tabular-nums">
|
||||
{formatCurrency(totalArr)}
|
||||
</span>
|
||||
</DataGridTableFootRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
if (column.id === "nrr") {
|
||||
return (
|
||||
<DataGridTableFootRowCell key={column.id} className="text-right!">
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<NrrValue value={portfolioNrr} />
|
||||
</div>
|
||||
</DataGridTableFootRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
return <DataGridTableFootRowCell key={column.id} />
|
||||
})}
|
||||
</DataGridTableFootRow>
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredAccounts.length}
|
||||
emptyMessage="No accounts match this view."
|
||||
tableLayout={{
|
||||
dense: tableDensity === "compact",
|
||||
rowBorder: true,
|
||||
footerBackground: true,
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
width: "fixed",
|
||||
}}
|
||||
tableClassNames={{
|
||||
body: "[&>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",
|
||||
}}
|
||||
>
|
||||
<section className="flex w-full max-w-7xl flex-col px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Frame>
|
||||
{/* Header */}
|
||||
<FrameHeader className="flex-col items-start gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<FrameTitle>Revenue By Region</FrameTitle>
|
||||
<FrameDescription>
|
||||
Net retention and renewals across the book.
|
||||
</FrameDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<PortfolioMetric
|
||||
label="Total ARR"
|
||||
value={formatCompactCurrency(totalArr)}
|
||||
variant="outline"
|
||||
/>
|
||||
<PortfolioMetric
|
||||
label="Portfolio NRR"
|
||||
value={`${portfolioNrr}%`}
|
||||
variant={
|
||||
portfolioNrr >= 100 ? "success-light" : "warning-light"
|
||||
}
|
||||
/>
|
||||
<PortfolioMetric
|
||||
label="At-Risk ARR"
|
||||
value={formatCompactCurrency(atRiskArr)}
|
||||
variant={atRiskArr > 0 ? "destructive-light" : "secondary"}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={handleExport}>
|
||||
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="bg-card p-0! shadow-none!">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 border-b px-3 py-3 lg:flex-row lg:items-center lg:justify-between lg:px-4">
|
||||
<InputGroup className="w-full min-w-0 lg:max-w-xs">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search accounts..."
|
||||
aria-label="Search accounts"
|
||||
/>
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label="Clear search"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5 lg:justify-end">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline">
|
||||
<ActivityIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Health
|
||||
{activeFilterCount > 0 ? (
|
||||
<Badge variant="secondary">{activeFilterCount}</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Account health</DropdownMenuLabel>
|
||||
{ACCOUNT_HEALTH_OPTIONS.map((health) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={health}
|
||||
checked={selectedHealth.includes(health)}
|
||||
closeOnClick={false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleHealthToggle(health, checked === true)
|
||||
}
|
||||
>
|
||||
{health}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{activeFilterCount > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
closeOnClick={false}
|
||||
onClick={() => setSelectedHealth([])}
|
||||
>
|
||||
Reset health
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline">
|
||||
<Settings2Icon data-icon="inline-start" aria-hidden="true" />
|
||||
Display
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-[300px] p-0">
|
||||
<FieldGroup className="gap-3 px-3.5 py-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
Table
|
||||
</div>
|
||||
<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>
|
||||
{
|
||||
TABLE_DENSITY_OPTIONS.find(
|
||||
(option) => option.value === tableDensity
|
||||
)?.label
|
||||
}
|
||||
</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 className="-mx-3.5" />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
Display properties
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DISPLAY_COLUMN_OPTIONS.map((option) => {
|
||||
const active = visibleColumns[option.key]
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={option.key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={active ? "secondary" : "outline"}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
active && "border-foreground/10"
|
||||
)}
|
||||
aria-pressed={active}
|
||||
onClick={() =>
|
||||
toggleColumn(option.key, !active)
|
||||
}
|
||||
>
|
||||
{active ? (
|
||||
<CheckIcon className="size-4" aria-hidden="true" />
|
||||
) : null}
|
||||
{option.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleToggleGroups}
|
||||
>
|
||||
{allGroupsExpanded ? "Collapse all" : "Expand all"}
|
||||
</Button>
|
||||
|
||||
{searchQuery.length > 0 || selectedHealth.length > 0 ? (
|
||||
<Button type="button" variant="ghost" onClick={clearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grouped grid */}
|
||||
<DataGridContainer>
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable footerContent={footerContent} />
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</section>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -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<Account | null>((earliest, account) => {
|
||||
if (!earliest || account.renewalAt < earliest.renewalAt) return account
|
||||
return earliest
|
||||
}, null)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GroupedRevenueDataGridView } from "./components/data-grid-view"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-svh w-full items-start justify-center p-8 pt-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Revenue by region grouped data grid
|
||||
</h1>
|
||||
<GroupedRevenueDataGridView />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type ToolbarOption<T extends string> = {
|
||||
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<string>[]
|
||||
|
||||
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<string>[]
|
||||
|
||||
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<string>[]
|
||||
|
||||
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"]
|
||||
@@ -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<T extends string> = {
|
||||
label: string
|
||||
value: T
|
||||
options: readonly ToolbarOption<T>[]
|
||||
onValueChange: (value: T) => void
|
||||
}
|
||||
|
||||
function getOptionLabel<T extends string>(
|
||||
options: readonly ToolbarOption<T>[],
|
||||
value: T
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
function ToolbarFilter<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onValueChange,
|
||||
}: ToolbarFilterProps<T>) {
|
||||
const selectedLabel = getOptionLabel(options, value)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`${label}: ${selectedLabel}`}
|
||||
>
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={(nextValue) => {
|
||||
if (nextValue !== null) {
|
||||
onValueChange(nextValue as T)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
closeOnClick
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState() {
|
||||
const [audience, setAudience] = useState<ExportAudience>(
|
||||
EXPORT_AUDIENCE_OPTIONS[0].value
|
||||
)
|
||||
const [scope, setScope] = useState<ExportScope>(EXPORT_SCOPE_OPTIONS[0].value)
|
||||
const [range, setRange] = useState<ExportRange>(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 (
|
||||
<section
|
||||
className="flex min-h-[430px] w-full max-w-4xl flex-col"
|
||||
aria-labelledby="export-ledger-heading"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 pb-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
{/* Heading */}
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
{/* Title and Description */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* Title */}
|
||||
<h2
|
||||
id="export-ledger-heading"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
Activity Exports
|
||||
</h2>
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Download scoped activity packets for billing review, staffing
|
||||
audits, and client handoffs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ToolbarFilter
|
||||
label="Audience"
|
||||
value={audience}
|
||||
options={EXPORT_AUDIENCE_OPTIONS}
|
||||
onValueChange={setAudience}
|
||||
/>
|
||||
<ToolbarFilter
|
||||
label="Workspace"
|
||||
value={scope}
|
||||
options={EXPORT_SCOPE_OPTIONS}
|
||||
onValueChange={setScope}
|
||||
/>
|
||||
<ToolbarFilter
|
||||
label="Date range"
|
||||
value={range}
|
||||
options={EXPORT_RANGE_OPTIONS}
|
||||
onValueChange={setRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Action */}
|
||||
<ButtonGroup className="w-full **:data-[slot=button]:border-r-0 sm:w-fit">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1 sm:flex-none"
|
||||
onClick={() => showExportToast()}
|
||||
>
|
||||
<PlayIcon className="fill-current" data-icon="inline-start" aria-hidden="true" />
|
||||
<span>Execute</span>
|
||||
</Button>
|
||||
|
||||
<ButtonGroupSeparator className="bg-primary/72" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="border-primary-foreground/20 rounded-l-none border-l"
|
||||
aria-label="Open download options"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChevronDownIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent sideOffset={8} align="end" className="w-52">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => showExportToast("csv")}>
|
||||
<FileDownIcon aria-hidden="true" />
|
||||
CSV bundle
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => showExportToast("pdf")}>
|
||||
<BookOpenIcon aria-hidden="true" />
|
||||
PDF brief
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => showExportToast("schedule")}>
|
||||
<CalendarClockIcon aria-hidden="true" />
|
||||
Schedule delivery
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Empty State */}
|
||||
<div className="flex flex-1 items-center justify-center py-14 sm:py-16">
|
||||
<Empty className="max-w-md flex-none bg-transparent p-0">
|
||||
<EmptyHeader className="gap-5 text-center">
|
||||
<EmptyMedia className="mb-0">
|
||||
<IconStack aria-hidden="true">
|
||||
<ArchiveIcon strokeWidth="1.9" aria-hidden="true" />
|
||||
</IconStack>
|
||||
</EmptyMedia>
|
||||
|
||||
{/* Empty State Content */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">
|
||||
No exportable activity yet
|
||||
</EmptyTitle>
|
||||
<EmptyDescription className="max-w-sm text-sm/relaxed">
|
||||
Capture approved activity and this view will assemble your next
|
||||
review packet.
|
||||
</EmptyDescription>
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { EmptyState } from "./components/empty-state"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh w-full items-center justify-center p-4 sm:p-8 md:p-10"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Activity exports empty state
|
||||
</h1>
|
||||
<EmptyState />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { useState, type ComponentProps, type ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
|
||||
@@ -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: (
|
||||
<CreditCardIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "endpoint-customers",
|
||||
name: "Customer Ledger",
|
||||
url: "https://ingest.acme.dev/webhooks/customers",
|
||||
description: "Customer lifecycle events.",
|
||||
events: [
|
||||
{ id: "customer.created", label: "customer.created" },
|
||||
{ id: "customer.updated", label: "customer.updated" },
|
||||
],
|
||||
status: "active",
|
||||
secret: "whsec_8m1p6q4r2v7x3k9n",
|
||||
lastDelivery: "14m ago",
|
||||
secretRotation: "28d ago",
|
||||
owner: "Data",
|
||||
icon: (
|
||||
<UsersIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "endpoint-subscriptions",
|
||||
name: "Subscription Orchestrator",
|
||||
url: "https://ops.acme.dev/webhooks/subscriptions",
|
||||
description: "Retries and plan changes.",
|
||||
events: [
|
||||
{ id: "subscription.activated", label: "subscription.activated" },
|
||||
{ id: "subscription.cancelled", label: "subscription.cancelled" },
|
||||
{ id: "invoice.failed", label: "invoice.failed" },
|
||||
],
|
||||
status: "failing",
|
||||
secret: "whsec_4x8m1r6q3p9k2v7n",
|
||||
lastDelivery: "9m ago",
|
||||
secretRotation: "5d left",
|
||||
owner: "Lifecycle",
|
||||
icon: (
|
||||
<RepeatIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "endpoint-finance",
|
||||
name: "Finance Reconciliation",
|
||||
url: "https://ledger.acme.dev/webhooks/payouts",
|
||||
description: "Payout sync to ledger.",
|
||||
events: [
|
||||
{ id: "payout.completed", label: "payout.completed" },
|
||||
{ id: "balance.updated", label: "balance.updated" },
|
||||
],
|
||||
status: "disabled",
|
||||
secret: "whsec_7v3q9m2k6r1p4x8n",
|
||||
lastDelivery: null,
|
||||
secretRotation: "Paused",
|
||||
owner: "Finance",
|
||||
icon: (
|
||||
<BanknoteIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "endpoint-risk",
|
||||
name: "Risk Intake",
|
||||
url: "https://risk.acme.dev/webhooks/disputes",
|
||||
description: "Disputes and refund review.",
|
||||
events: [
|
||||
{ id: "dispute.opened", label: "dispute.opened" },
|
||||
{ id: "charge.refunded", label: "charge.refunded" },
|
||||
],
|
||||
status: "active",
|
||||
secret: "whsec_5q9r2m7x1k4v8p3n",
|
||||
lastDelivery: "47m ago",
|
||||
secretRotation: "9d ago",
|
||||
owner: "Risk",
|
||||
icon: (
|
||||
<ShieldAlertIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
export function parseMinutesAgo(label: string | null) {
|
||||
const match = label?.match(/^(\d+)m ago$/)
|
||||
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
export function parseRotationDaysAgo(label: string) {
|
||||
const match = label.match(/^(\d+)d ago$/)
|
||||
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
export function getEndpointAlerts(endpoint: WebhookEndpoint): EndpointAlert[] {
|
||||
const alerts: EndpointAlert[] = []
|
||||
const deliveryMinutes = parseMinutesAgo(endpoint.lastDelivery)
|
||||
const rotationDaysAgo = parseRotationDaysAgo(endpoint.secretRotation)
|
||||
|
||||
if (endpoint.status === "failing") {
|
||||
alerts.push({
|
||||
id: "delivery-failure",
|
||||
tone: "critical",
|
||||
badgeLabel: "Critical",
|
||||
detail: `${endpoint.name} has deliveries waiting for retry review.`,
|
||||
})
|
||||
} else if (endpoint.status === "disabled") {
|
||||
alerts.push({
|
||||
id: "delivery-paused",
|
||||
tone: "warning",
|
||||
badgeLabel: "Warning",
|
||||
detail: `${endpoint.name} is paused and not receiving new events.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (deliveryMinutes !== null && deliveryMinutes >= 30) {
|
||||
alerts.push({
|
||||
id: "delivery-lag",
|
||||
tone: "warning",
|
||||
badgeLabel: "Warning",
|
||||
detail: `Last delivery landed ${endpoint.lastDelivery}. Check if that delay is expected.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (endpoint.secretRotation === "5d left") {
|
||||
alerts.push({
|
||||
id: "rotation-due",
|
||||
tone: "warning",
|
||||
badgeLabel: "Warning",
|
||||
detail: "Signing secret rotation is due within 5 days.",
|
||||
})
|
||||
} else if (rotationDaysAgo !== null && rotationDaysAgo >= 21) {
|
||||
alerts.push({
|
||||
id: "rotation-stale",
|
||||
tone: "warning",
|
||||
badgeLabel: "Warning",
|
||||
detail: `Signing secret was rotated ${endpoint.secretRotation}. Consider refreshing it soon.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (alerts.length === 0) {
|
||||
alerts.push({
|
||||
id: "healthy",
|
||||
tone: "success",
|
||||
badgeLabel: "Healthy",
|
||||
detail: `${endpoint.name} is delivering subscribed events normally.`,
|
||||
})
|
||||
}
|
||||
|
||||
return alerts
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@cfdm/ui/components/dropdown-menu"
|
||||
import type { WebhookEndpoint, WebhookEndpointActionHandlers } from "./data"
|
||||
import { EllipsisVerticalIcon, Settings2Icon, HistoryIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
type EndpointActionsMenuProps = WebhookEndpointActionHandlers & {
|
||||
endpoint: WebhookEndpoint
|
||||
}
|
||||
|
||||
export function EndpointActionsMenu({
|
||||
endpoint,
|
||||
onManage,
|
||||
onViewDeliveries,
|
||||
onRotateSecret,
|
||||
onRemove,
|
||||
}: EndpointActionsMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Open actions for ${endpoint.name}`}
|
||||
>
|
||||
<EllipsisVerticalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onManage(endpoint)}>
|
||||
<Settings2Icon aria-hidden="true" />
|
||||
{endpoint.status === "failing"
|
||||
? "Review endpoint"
|
||||
: "Manage endpoint"}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => onViewDeliveries(endpoint)}>
|
||||
<HistoryIcon aria-hidden="true" />
|
||||
View deliveries
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => onRotateSecret(endpoint)}>
|
||||
<RefreshCwIcon aria-hidden="true" />
|
||||
Rotate secret
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onRemove(endpoint)}
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
Remove endpoint
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@cfdm/ui/components/tooltip"
|
||||
import { ALERT_CONFIG, type EndpointAlert } from "./data"
|
||||
import { CircleCheckIcon, CircleXIcon, TriangleAlertIcon } from "lucide-react"
|
||||
|
||||
// ── Endpoint Alert Indicator ──
|
||||
|
||||
export function EndpointAlertIndicator({ alert }: { alert: EndpointAlert }) {
|
||||
const config = ALERT_CONFIG[alert.tone]
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"focus-visible:ring-ring focus-visible:ring-offset-background inline-flex rounded-sm p-0.5 focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
config.toneClassName
|
||||
)}
|
||||
aria-label={`${alert.badgeLabel}. ${alert.detail}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{alert.tone === "success" ? (
|
||||
<CircleCheckIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
) : alert.tone === "critical" ? (
|
||||
<CircleXIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<TriangleAlertIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
|
||||
{/* Content */}
|
||||
<TooltipContent side="top" className="max-w-xs p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={config.badgeVariant}>{alert.badgeLabel}</Badge>
|
||||
<p>{alert.detail}</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@cfdm/ui/components/item"
|
||||
import { Switch } from "@cfdm/ui/components/switch"
|
||||
|
||||
import {
|
||||
getEndpointAlerts,
|
||||
type WebhookEndpoint,
|
||||
type WebhookEndpointActionHandlers,
|
||||
} from "./data"
|
||||
import { EndpointActionsMenu } from "./endpoint-actions-menu"
|
||||
import { EndpointAlertIndicator } from "./endpoint-alert-indicator"
|
||||
import { EndpointUrlCopy } from "./endpoint-url-copy"
|
||||
import { StatusIndicator } from "./status-indicator"
|
||||
|
||||
type EndpointRowProps = WebhookEndpointActionHandlers & {
|
||||
endpoint: WebhookEndpoint
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
}
|
||||
|
||||
// ── Endpoint Row ──
|
||||
|
||||
export function EndpointRow({
|
||||
endpoint,
|
||||
onToggle,
|
||||
onManage,
|
||||
onViewDeliveries,
|
||||
onRotateSecret,
|
||||
onRemove,
|
||||
}: EndpointRowProps) {
|
||||
const isEnabled = endpoint.status !== "disabled"
|
||||
const alerts = getEndpointAlerts(endpoint)
|
||||
|
||||
return (
|
||||
<Item variant="outline" className="border-x-0 border-t-0 last:border-b-0">
|
||||
{/* Media */}
|
||||
<ItemMedia variant="icon" className="translate-y-0! self-center!">
|
||||
<Item className="border-border flex size-10 items-center justify-center border p-0 [&_svg]:opacity-60">
|
||||
{endpoint.icon}
|
||||
</Item>
|
||||
</ItemMedia>
|
||||
|
||||
{/* Content */}
|
||||
<ItemContent className="min-w-0 gap-1">
|
||||
<ItemTitle className="min-w-0 gap-2">
|
||||
<span className="min-w-0 truncate">{endpoint.name}</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{alerts.map((alert) => (
|
||||
<EndpointAlertIndicator key={alert.id} alert={alert} />
|
||||
))}
|
||||
</span>
|
||||
<StatusIndicator status={endpoint.status} />
|
||||
</ItemTitle>
|
||||
|
||||
<ItemDescription className="min-w-0">
|
||||
<EndpointUrlCopy endpointName={endpoint.name} url={endpoint.url} />
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
|
||||
{/* Actions */}
|
||||
<ItemActions className="gap-2 self-start sm:self-center">
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => onToggle(endpoint.id, checked)}
|
||||
aria-label={`Toggle ${endpoint.url}`}
|
||||
/>
|
||||
|
||||
<EndpointActionsMenu
|
||||
endpoint={endpoint}
|
||||
onManage={onManage}
|
||||
onViewDeliveries={onViewDeliveries}
|
||||
onRotateSecret={onRotateSecret}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
// ── Show Endpoint Toast ──
|
||||
|
||||
export function showEndpointToast({
|
||||
title,
|
||||
description,
|
||||
variant = "info",
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
variant?: "info" | "success"
|
||||
}) {
|
||||
toast.custom((id) => (
|
||||
<div className="bg-invert text-invert-foreground flex w-[356px] items-start gap-3 rounded-md border border-transparent p-4 shadow-lg">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 shrink-0 items-center",
|
||||
variant === "success" ? "text-green-500" : "text-info"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-invert-foreground/70 text-sm">{description}</p>
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="bg-background/10 border-border/10 text-invert-foreground"
|
||||
onClick={() => toast.dismiss(id)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@cfdm/ui/components/tooltip"
|
||||
import { CopyIcon } from "lucide-react"
|
||||
|
||||
// ── Endpoint URL Copy ──
|
||||
|
||||
export function EndpointUrlCopy({
|
||||
endpointName,
|
||||
url,
|
||||
}: {
|
||||
endpointName: string
|
||||
url: string
|
||||
}) {
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard()
|
||||
|
||||
return (
|
||||
<span className="group/url inline-flex max-w-full items-center gap-1 align-top">
|
||||
<code className="text-foreground/90 max-w-full min-w-0 truncate text-xs">
|
||||
{url}
|
||||
</code>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground shrink-0 opacity-100 transition-opacity duration-200 focus-visible:opacity-100 sm:opacity-0 sm:group-focus-within/url:opacity-100 sm:group-hover/url:opacity-100"
|
||||
aria-label={
|
||||
isCopied
|
||||
? `Copied endpoint URL for ${endpointName}`
|
||||
: `Copy endpoint URL for ${endpointName}`
|
||||
}
|
||||
onClick={() => copyToClipboard(url)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CopyIcon aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>{isCopied ? "Copied" : "Copy URL"}</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { STATUS_CONFIG, type EndpointStatus } from "./data"
|
||||
|
||||
// ── Status Indicator ──
|
||||
|
||||
export function StatusIndicator({ status }: { status: EndpointStatus }) {
|
||||
const config = STATUS_CONFIG[status]
|
||||
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import { ENDPOINTS, type WebhookEndpoint } from "./data"
|
||||
import { EndpointRow } from "./endpoint-row"
|
||||
import { showEndpointToast } from "./endpoint-toast"
|
||||
import { PlusIcon } from "lucide-react"
|
||||
|
||||
export function WebhookEndpoints() {
|
||||
const [endpoints, setEndpoints] = useState(ENDPOINTS)
|
||||
|
||||
const handleToggle = (id: string, enabled: boolean) => {
|
||||
const endpoint = endpoints.find((item) => item.id === id)
|
||||
|
||||
setEndpoints((current) =>
|
||||
current.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
status: enabled ? ("active" as const) : ("disabled" as const),
|
||||
}
|
||||
: item
|
||||
)
|
||||
)
|
||||
|
||||
if (!endpoint) return
|
||||
|
||||
showEndpointToast({
|
||||
title: enabled ? "Endpoint enabled" : "Endpoint paused",
|
||||
description: enabled
|
||||
? `${endpoint.name} is live again.`
|
||||
: `${endpoint.name} is no longer receiving events.`,
|
||||
variant: enabled ? "success" : "info",
|
||||
})
|
||||
}
|
||||
|
||||
const handleManage = (endpoint: WebhookEndpoint) => {
|
||||
showEndpointToast({
|
||||
title:
|
||||
endpoint.status === "failing" ? "Review delivery" : "Endpoint settings",
|
||||
description:
|
||||
endpoint.status === "failing"
|
||||
? `Check retries for ${endpoint.name}.`
|
||||
: `Open rules for ${endpoint.name}.`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleViewDeliveries = (endpoint: WebhookEndpoint) => {
|
||||
showEndpointToast({
|
||||
title: "Delivery history",
|
||||
description: endpoint.lastDelivery
|
||||
? `${endpoint.name} delivered ${endpoint.lastDelivery}.`
|
||||
: `${endpoint.name} has no recent deliveries.`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleRotateSecret = (endpoint: WebhookEndpoint) => {
|
||||
setEndpoints((current) =>
|
||||
current.map((item) =>
|
||||
item.id === endpoint.id ? { ...item, secretRotation: "Just now" } : item
|
||||
)
|
||||
)
|
||||
|
||||
showEndpointToast({
|
||||
title: "Secret rotated",
|
||||
description: `${endpoint.name} received a new signing secret.`,
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemove = (endpoint: WebhookEndpoint) => {
|
||||
setEndpoints((current) => current.filter((item) => item.id !== endpoint.id))
|
||||
|
||||
showEndpointToast({
|
||||
title: "Endpoint removed",
|
||||
description: `${endpoint.name} was removed from delivery routes.`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className="w-full max-w-3xl">
|
||||
{/* Header */}
|
||||
<FrameHeader className="flex-row items-center justify-between gap-4 px-2! py-2.5!">
|
||||
<div className="space-y-px">
|
||||
<FrameTitle>Webhook Endpoints</FrameTitle>
|
||||
<FrameDescription>Routes and status</FrameDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
showEndpointToast({
|
||||
title: "Add endpoint",
|
||||
description: "Add a destination URL and subscribed events.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add Endpoint
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
|
||||
{/* Content */}
|
||||
<FramePanel className="p-0!">
|
||||
{endpoints.map((endpoint, index) => (
|
||||
<div key={endpoint.id}>
|
||||
{index > 0 ? <Separator /> : null}
|
||||
<EndpointRow
|
||||
endpoint={endpoint}
|
||||
onToggle={handleToggle}
|
||||
onManage={handleManage}
|
||||
onViewDeliveries={handleViewDeliveries}
|
||||
onRotateSecret={handleRotateSecret}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { WebhookEndpoints } from "./components/webhook-endpoints"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12">
|
||||
<WebhookEndpoints />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
DataGridTableRowSelectAll,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DomainListItem } from '@/lib/schemas'
|
||||
import { formatRelative, sqliteUtcToIso } from '@/lib/format'
|
||||
@@ -25,18 +24,10 @@ import {
|
||||
|
||||
export const DOMAIN_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'ok', label: 'OK' },
|
||||
{ id: 'slow', label: 'Slow' },
|
||||
{ id: 'down', label: 'Down' },
|
||||
{ id: 'unknown', label: '—' },
|
||||
{ id: 'without_group', label: 'Без группы' },
|
||||
] as const
|
||||
|
||||
export function domainTabFilter(item: DomainListItem, tabId: string) {
|
||||
if (tabId === 'ok') return item.health_status === 'up'
|
||||
if (tabId === 'slow') return item.health_status === 'degraded'
|
||||
if (tabId === 'down') return item.health_status === 'down'
|
||||
if (tabId === 'unknown') return item.health_status === 'unknown'
|
||||
if (tabId === 'without_group') return item.group_id == null
|
||||
return true
|
||||
}
|
||||
@@ -68,18 +59,6 @@ export function useDomainFilterFields(
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, groupOptions),
|
||||
},
|
||||
{
|
||||
key: 'health_status',
|
||||
label: 'Доступность',
|
||||
type: 'select',
|
||||
className: 'w-[140px]',
|
||||
options: [
|
||||
{ label: 'OK', value: 'up' },
|
||||
{ label: 'Slow', value: 'degraded' },
|
||||
{ label: 'Down', value: 'down' },
|
||||
{ label: '—', value: 'unknown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'environment',
|
||||
label: 'Env',
|
||||
@@ -102,8 +81,6 @@ export function domainFilterFieldValue(item: DomainListItem, field: string) {
|
||||
return `${item.zone_name} ${item.group_name ?? ''} ${(item.tags ?? []).join(' ')}`.toLowerCase()
|
||||
case 'group_id':
|
||||
return item.group_id != null ? String(item.group_id) : 'none'
|
||||
case 'health_status':
|
||||
return item.health_status ?? 'unknown'
|
||||
case 'environment':
|
||||
return item.environment ?? ''
|
||||
default:
|
||||
@@ -179,20 +156,6 @@ export function useDomainColumns({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
accessorKey: 'health_status',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Доступность" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<HealthCheckBadge
|
||||
status={row.original.health_status ?? 'unknown'}
|
||||
latencyMs={row.original.health_latency_ms}
|
||||
showLatency
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
header: ({ column }) => (
|
||||
@@ -232,7 +195,10 @@ export function useDomainColumns({
|
||||
className="h-auto p-0 tabular-nums"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/services" search={{ domainId: row.original.id }} />
|
||||
<Link
|
||||
to="/services"
|
||||
search={{ domainId: row.original.id }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{row.original.service_count}
|
||||
|
||||
@@ -146,8 +146,9 @@ export function DomainAvailabilityPanel({
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Мониторы доступности hostname в зоне (HTTP, Ping, DNS)
|
||||
<p className="text-muted-foreground max-w-2xl text-sm">
|
||||
Зонные мониторы (HTTP/Ping/DNS). LB и IP-доступность сервисов — в разделе
|
||||
Сервисы.
|
||||
</p>
|
||||
<LoadingButton
|
||||
type="button"
|
||||
@@ -231,7 +232,8 @@ export function DomainAvailabilityPanel({
|
||||
<FrameHeader>
|
||||
<FrameTitle>Мониторы</FrameTitle>
|
||||
<FrameDescription>
|
||||
Последний статус каждой проверки
|
||||
Зонные HTTP/Ping/DNS проверки. LB и IP-доступность сервисов — в
|
||||
разделе Сервисы.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { serviceDisplayFqdn } from '@/lib/service-utils'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
@@ -63,7 +64,12 @@ export function ServiceKanbanCard({
|
||||
{service.name}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions className="shrink-0">
|
||||
<ItemActions className="flex shrink-0 items-center gap-1.5">
|
||||
<HealthCheckBadge
|
||||
status={service.health_status ?? 'unknown'}
|
||||
latencyMs={service.health_latency_ms}
|
||||
size="xs"
|
||||
/>
|
||||
<Switch
|
||||
checked={service.enabled ?? false}
|
||||
disabled={isToggling || dragDisabled}
|
||||
@@ -74,15 +80,20 @@ export function ServiceKanbanCard({
|
||||
</ItemHeader>
|
||||
|
||||
<ItemContent className="min-w-0 gap-2">
|
||||
{fqdn ? (
|
||||
<span className="text-muted-foreground truncate font-mono text-xs">{fqdn}</span>
|
||||
{fqdn && fqdn !== '—' ? (
|
||||
<span className="text-muted-foreground truncate font-mono text-xs">
|
||||
{fqdn}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">FQDN не задан</span>
|
||||
)}
|
||||
</ItemContent>
|
||||
|
||||
<ItemFooter className="min-w-0 justify-between gap-2">
|
||||
<StatusBadge status={service.enabled ? 'active' : 'unknown'} label={service.enabled ? 'Вкл' : 'Выкл'} />
|
||||
<StatusBadge
|
||||
status={service.enabled ? 'active' : 'unknown'}
|
||||
label={service.enabled ? 'Вкл' : 'Выкл'}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline" size="sm">
|
||||
{service.slug}
|
||||
@@ -106,7 +117,10 @@ export function ServiceKanbanCard({
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDelete(service)}>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(service)}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, type ComponentProps, type ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
@@ -15,19 +15,37 @@ import {
|
||||
KanbanOverlay,
|
||||
} from '@/components/reui/kanban'
|
||||
import { ScrollArea as ScrollAreaPrimitive } from '@base-ui/react/scroll-area'
|
||||
import { GripVerticalIcon, PlusIcon } from 'lucide-react'
|
||||
import {
|
||||
GripVerticalIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, type ComponentProps, type ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { IpHealthStatus } from '@/lib/schemas'
|
||||
|
||||
export interface KanbanColumnConfig {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
healthStatus?: IpHealthStatus['status']
|
||||
healthLatencyMs?: number | null
|
||||
dotClassName?: string
|
||||
addLabel?: string
|
||||
onAdd?: () => void
|
||||
onEdit?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export interface KanbanBoardProps<T extends { id: string | number }> {
|
||||
@@ -103,53 +121,115 @@ function KanbanColumnView<T extends { id: string | number }>({
|
||||
<Frame
|
||||
spacing="sm"
|
||||
className={cn('group/column', isOverlay && 'shadow-lg')}
|
||||
aria-label={column.description ?? column.title}
|
||||
aria-label={
|
||||
column.description
|
||||
? `${column.title}: ${column.description}`
|
||||
: column.title
|
||||
}
|
||||
>
|
||||
<FrameHeader className="flex min-h-10 flex-row items-center gap-2 px-2 py-1.5">
|
||||
{column.dotClassName ? (
|
||||
<span
|
||||
className={cn('size-2.5 shrink-0 rounded-full', column.dotClassName)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<FrameTitle className="truncate text-sm leading-5" title={column.title}>
|
||||
{column.title}
|
||||
</FrameTitle>
|
||||
<span className="text-muted-foreground shrink-0 text-sm font-medium tabular-nums">
|
||||
{items.length}
|
||||
</span>
|
||||
{!isOverlay ? (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within/column:opacity-100 group-hover/column:opacity-100">
|
||||
{column.onAdd ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={COLUMN_HEADER_ACTION_BUTTON_CLASSNAME}
|
||||
aria-label={column.addLabel ?? 'Добавить'}
|
||||
title={column.addLabel ?? 'Добавить'}
|
||||
onClick={column.onAdd}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
<KanbanColumnHandle
|
||||
className="group-focus-within/column:opacity-100"
|
||||
render={({ className, ...handleProps }) => (
|
||||
<FrameHeader className="flex min-h-10 flex-col gap-1 px-2 py-1.5">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{column.dotClassName ? (
|
||||
<span
|
||||
className={cn(
|
||||
'size-2.5 shrink-0 rounded-full',
|
||||
column.dotClassName,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<FrameTitle
|
||||
className="truncate text-sm leading-5"
|
||||
title={column.title}
|
||||
>
|
||||
{column.title}
|
||||
</FrameTitle>
|
||||
<span className="text-muted-foreground shrink-0 text-sm font-medium tabular-nums">
|
||||
{items.length}
|
||||
</span>
|
||||
{column.healthStatus ? (
|
||||
<HealthCheckBadge
|
||||
status={column.healthStatus}
|
||||
latencyMs={column.healthLatencyMs}
|
||||
size="xs"
|
||||
/>
|
||||
) : null}
|
||||
{!isOverlay ? (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within/column:opacity-100 group-hover/column:opacity-100">
|
||||
{column.onAdd ? (
|
||||
<Button
|
||||
{...handleProps}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Переместить колонку ${column.title}`}
|
||||
title={`Переместить колонку ${column.title}`}
|
||||
className={cn(COLUMN_HEADER_ACTION_BUTTON_CLASSNAME, className)}
|
||||
className={COLUMN_HEADER_ACTION_BUTTON_CLASSNAME}
|
||||
aria-label={column.addLabel ?? 'Добавить'}
|
||||
title={column.addLabel ?? 'Добавить'}
|
||||
onClick={column.onAdd}
|
||||
>
|
||||
<GripVerticalIcon aria-hidden="true" />
|
||||
<PlusIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{column.onEdit || column.onDelete ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={COLUMN_HEADER_ACTION_BUTTON_CLASSNAME}
|
||||
aria-label={`Действия колонки ${column.title}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{column.onEdit ? (
|
||||
<DropdownMenuItem onClick={column.onEdit}>
|
||||
Изменить группу
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{column.onDelete ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={column.onDelete}
|
||||
>
|
||||
Удалить группу
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<KanbanColumnHandle
|
||||
className="group-focus-within/column:opacity-100"
|
||||
render={({ className, ...handleProps }) => (
|
||||
<Button
|
||||
{...handleProps}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Переместить колонку ${column.title}`}
|
||||
title={`Переместить колонку ${column.title}`}
|
||||
className={cn(
|
||||
COLUMN_HEADER_ACTION_BUTTON_CLASSNAME,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<GripVerticalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{column.description ? (
|
||||
<FrameDescription className="text-muted-foreground truncate pl-4 font-mono text-xs">
|
||||
{column.description}
|
||||
</FrameDescription>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
|
||||
import {
|
||||
getColumnHeaderLabel,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { ReactElement } from "react"
|
||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
||||
import { Table } from "@tanstack/react-table"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import React, { ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
PointerEvent,
|
||||
ReactNode,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
CSSProperties,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
Fragment,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
memo,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
Fragment,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, ReactNode, useContext, useMemo } from "react"
|
||||
import {
|
||||
Column,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
type IconStackProps = React.ComponentProps<"div">
|
||||
|
||||
function IconStack({ className, children, style, ...props }: IconStackProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="icon-stack"
|
||||
className={cn(
|
||||
"text-foreground **:data-[slot=icon-stack-layer]:fill-background relative h-20 w-18",
|
||||
className
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--icon-stack-content-x": "71%",
|
||||
"--icon-stack-content-y": "58%",
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 72 81"
|
||||
fill="none"
|
||||
className="h-full w-full overflow-visible"
|
||||
>
|
||||
<ellipse
|
||||
cx="36"
|
||||
cy="76"
|
||||
rx="30"
|
||||
ry="7"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.055"
|
||||
className="blur-[4px]"
|
||||
/>
|
||||
|
||||
<IconStackLayer opacity="0.4" />
|
||||
<IconStackLayer opacity="0.6" x={13.65} y={6.04} />
|
||||
<IconStackLayer opacity="0.8" x={27.32} y={12.08} active />
|
||||
</svg>
|
||||
|
||||
{children ? (
|
||||
<div
|
||||
data-slot="icon-stack-content"
|
||||
className="text-muted-foreground pointer-events-none absolute top-[var(--icon-stack-content-y)] left-[var(--icon-stack-content-x)] flex -translate-x-1/2 -translate-y-1/2 scale-x-90 -skew-y-26 items-center justify-center"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IconStackLayer({
|
||||
active = false,
|
||||
opacity,
|
||||
x = 0,
|
||||
y = 0,
|
||||
}: {
|
||||
active?: boolean
|
||||
opacity: string
|
||||
x?: number
|
||||
y?: number
|
||||
}) {
|
||||
return (
|
||||
<g opacity={opacity} transform={`translate(${x} ${y})`}>
|
||||
<path
|
||||
data-slot="icon-stack-layer"
|
||||
d="M42.2538 2.046C41.4408 1.6325 40.3965 1.6677 39.2612 2.2424L7.9616 18.1934C5.3895 19.5039 3.301 23.1064 3.301 26.2322V64.3226C3.301 66.0677 3.9458 67.2943 4.962 67.8199L1.8363 66.229C0.8201 65.7104 0.1753 64.4771 0.1753 62.732V24.6412C0.1753 21.5085 2.2638 17.913 4.8359 16.6024L36.1355 0.6515C37.2778 0.0698 38.322 0.0416 39.128 0.4551L42.2538 2.046Z"
|
||||
stroke="currentColor"
|
||||
strokeOpacity={active ? "0.3" : "0.2"}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
data-slot="icon-stack-layer"
|
||||
d="M42.2545 2.0456C43.2707 2.5643 43.9155 3.7979 43.9155 5.543V43.6337C43.9155 46.7665 41.827 50.3616 39.2549 51.6722L7.9554 67.6235C6.813 68.2052 5.7687 68.2331 4.9628 67.8196C3.9465 67.301 3.3018 66.0673 3.3018 64.3222V26.2318C3.3018 23.0991 5.3903 19.5036 7.9624 18.193L39.2619 2.2421C40.4043 1.6604 41.4486 1.6321 42.2545 2.0456Z"
|
||||
stroke="currentColor"
|
||||
strokeOpacity={active ? "0.3" : "0.2"}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export { IconStack, type IconStackProps }
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
useReactTable,
|
||||
type ExpandedState,
|
||||
} from '@tanstack/react-table'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
FilterIcon,
|
||||
FolderPlusIcon,
|
||||
FunnelXIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Filters, type Filter } from '@/components/reui/filters'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { applyFiltersToData } from '@/components/reui-kit/filter-utils'
|
||||
import { createServicesGroupedColumns } from '@/components/services/services-grouped-columns'
|
||||
import type { ServiceCatalogTreeRow } from '@/components/services/services-grouped-columns'
|
||||
import {
|
||||
SERVICE_TABS,
|
||||
createDefaultServiceFilters,
|
||||
serviceFilterFieldValue,
|
||||
serviceTabFilter,
|
||||
useServiceFilterFields,
|
||||
type ServiceCatalogRow,
|
||||
} from '@/components/columns/services-columns'
|
||||
import type {
|
||||
ServiceGroupView,
|
||||
ServiceGroupsResponse,
|
||||
ServiceView,
|
||||
} from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
|
||||
const HEALTH_TABS = [
|
||||
{ id: 'health-ok', label: 'OK' },
|
||||
{ id: 'health-slow', label: 'Slow' },
|
||||
{ id: 'health-down', label: 'Down' },
|
||||
{ id: 'health-unknown', label: '—' },
|
||||
] as const
|
||||
|
||||
const ALL_TABS = [...SERVICE_TABS, ...HEALTH_TABS] as const
|
||||
|
||||
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
|
||||
if (domainId == null) return true
|
||||
return service.domains.some((d) => d.domain_id === domainId)
|
||||
}
|
||||
|
||||
function toCatalogRow(
|
||||
service: ServiceView,
|
||||
groupId: number | null,
|
||||
groupName: string | null,
|
||||
): ServiceCatalogRow {
|
||||
return {
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
slug: service.slug,
|
||||
enabled: service.enabled,
|
||||
groupId,
|
||||
groupName,
|
||||
domainIds: service.domains.map((d) => d.domain_id),
|
||||
service,
|
||||
}
|
||||
}
|
||||
|
||||
function catalogTabFilter(row: ServiceCatalogRow, tabId: string) {
|
||||
if (tabId.startsWith('health-')) {
|
||||
const status = row.service.health_status ?? 'unknown'
|
||||
if (tabId === 'health-ok') return status === 'up'
|
||||
if (tabId === 'health-slow') return status === 'degraded'
|
||||
if (tabId === 'health-down') return status === 'down'
|
||||
if (tabId === 'health-unknown') return status === 'unknown'
|
||||
return true
|
||||
}
|
||||
return serviceTabFilter(row, tabId)
|
||||
}
|
||||
|
||||
function buildTreeRows(
|
||||
data: ServiceGroupsResponse,
|
||||
filteredServiceIds: Set<number>,
|
||||
domainId?: number,
|
||||
): ServiceCatalogTreeRow[] {
|
||||
const rows: ServiceCatalogTreeRow[] = []
|
||||
|
||||
for (const group of data.groups) {
|
||||
const services = group.services
|
||||
.filter((s) => serviceMatchesDomain(s, domainId))
|
||||
.filter((s) => filteredServiceIds.has(s.id))
|
||||
if (services.length === 0) continue
|
||||
|
||||
rows.push({
|
||||
kind: 'group',
|
||||
id: `group-${group.id}`,
|
||||
group,
|
||||
subRows: services.map((service) => ({
|
||||
kind: 'service' as const,
|
||||
id: `service-${service.id}`,
|
||||
service,
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
const ungrouped = data.ungrouped
|
||||
.filter((s) => serviceMatchesDomain(s, domainId))
|
||||
.filter((s) => filteredServiceIds.has(s.id))
|
||||
|
||||
if (ungrouped.length > 0) {
|
||||
rows.push({
|
||||
kind: 'group',
|
||||
id: 'group-ungrouped',
|
||||
group: null,
|
||||
subRows: ungrouped.map((service) => ({
|
||||
kind: 'service' as const,
|
||||
id: `service-${service.id}`,
|
||||
service,
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function defaultExpanded(rows: ServiceCatalogTreeRow[]): ExpandedState {
|
||||
return rows.reduce<Record<string, boolean>>((acc, row) => {
|
||||
acc[row.id] = true
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function ServicesAddMenu({
|
||||
onAddService,
|
||||
onAddGroup,
|
||||
}: {
|
||||
onAddService: () => void
|
||||
onAddGroup: () => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden />
|
||||
Добавить
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
<DropdownMenuItem onClick={onAddService}>
|
||||
<ServerIcon aria-hidden />
|
||||
Сервис
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onAddGroup}>
|
||||
<FolderPlusIcon aria-hidden />
|
||||
Группу
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServicesGroupedCatalogProps {
|
||||
data: ServiceGroupsResponse
|
||||
domainId?: number
|
||||
domainLabel?: string
|
||||
isLoading?: boolean
|
||||
primaryAction?: ReactNode
|
||||
togglingId: number | null
|
||||
onEditService: (service: ServiceView) => void
|
||||
onDeleteService: (service: ServiceView) => void
|
||||
onToggleService: (serviceId: number, enabled: boolean) => void
|
||||
onEditGroup: (group: ServiceGroupView) => void
|
||||
onDeleteGroup: (group: ServiceGroupView) => void
|
||||
onAddServiceToGroup: (groupId: number | null) => void
|
||||
emptyAction?: ReactNode
|
||||
}
|
||||
|
||||
export function ServicesGroupedCatalog({
|
||||
data,
|
||||
domainId,
|
||||
domainLabel,
|
||||
isLoading = false,
|
||||
primaryAction,
|
||||
togglingId,
|
||||
onEditService,
|
||||
onDeleteService,
|
||||
onToggleService,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onAddServiceToGroup,
|
||||
emptyAction,
|
||||
}: ServicesGroupedCatalogProps) {
|
||||
const [tab, setTab] = useState('all')
|
||||
const [filters, setFilters] = useState<Filter[]>(() =>
|
||||
createDefaultServiceFilters(),
|
||||
)
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||
const filterFields = useServiceFilterFields()
|
||||
|
||||
const flatRows = useMemo(() => {
|
||||
const rows: ServiceCatalogRow[] = []
|
||||
for (const group of data.groups) {
|
||||
for (const service of group.services) {
|
||||
if (!serviceMatchesDomain(service, domainId)) continue
|
||||
rows.push(toCatalogRow(service, group.id, group.name))
|
||||
}
|
||||
}
|
||||
for (const service of data.ungrouped) {
|
||||
if (!serviceMatchesDomain(service, domainId)) continue
|
||||
rows.push(toCatalogRow(service, null, null))
|
||||
}
|
||||
return rows
|
||||
}, [data, domainId])
|
||||
|
||||
const tabCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const t of ALL_TABS) {
|
||||
counts[t.id] = flatRows.filter((row) => catalogTabFilter(row, t.id)).length
|
||||
}
|
||||
return counts
|
||||
}, [flatRows])
|
||||
|
||||
const filteredIds = useMemo(() => {
|
||||
const afterTab = flatRows.filter((row) => catalogTabFilter(row, tab))
|
||||
const afterFilters = applyFiltersToData(afterTab, filters, (item, field) =>
|
||||
serviceFilterFieldValue(item, field),
|
||||
)
|
||||
return new Set(afterFilters.map((r) => r.id))
|
||||
}, [flatRows, tab, filters])
|
||||
|
||||
const treeData = useMemo(
|
||||
() => buildTreeRows(data, filteredIds, domainId),
|
||||
[data, filteredIds, domainId],
|
||||
)
|
||||
|
||||
const expandedKey = treeData.map((r) => r.id).join(',')
|
||||
useEffect(() => {
|
||||
setExpanded(defaultExpanded(treeData))
|
||||
}, [expandedKey, treeData])
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createServicesGroupedColumns({
|
||||
onEditService,
|
||||
onDeleteService,
|
||||
onToggleService,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onAddServiceToGroup,
|
||||
togglingId,
|
||||
}),
|
||||
[
|
||||
onEditService,
|
||||
onDeleteService,
|
||||
onToggleService,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onAddServiceToGroup,
|
||||
togglingId,
|
||||
],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: treeData,
|
||||
columns,
|
||||
state: { expanded },
|
||||
onExpandedChange: setExpanded,
|
||||
getSubRows: (row) => (row.kind === 'group' ? row.subRows : undefined),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="mt-1 h-4 w-72" />
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3 p-4">
|
||||
<Skeleton className="h-9 w-full max-w-md" />
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
const hasAnyServices = flatRows.length > 0
|
||||
const hasAnyGroups = data.groups.length > 0
|
||||
|
||||
if (!hasAnyServices && !hasAnyGroups) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={ServerIcon}
|
||||
title="Сервисы не найдены"
|
||||
description={
|
||||
domainLabel
|
||||
? `Нет сервисов с привязками к ${domainLabel}`
|
||||
: 'Сначала создайте группу, затем добавьте сервисы.'
|
||||
}
|
||||
action={emptyAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredIds.size}
|
||||
emptyMessage="Нет записей по выбранным фильтрам."
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Сервисы</FrameTitle>
|
||||
<FrameDescription>
|
||||
{domainLabel
|
||||
? `Каталог сервисов с привязками к ${domainLabel}`
|
||||
: 'Группы, FQDN и доступность сервисов'}
|
||||
</FrameDescription>
|
||||
</div>
|
||||
{primaryAction ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{primaryAction}
|
||||
</div>
|
||||
) : null}
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList variant="line" className="gap-5 overflow-x-auto">
|
||||
{ALL_TABS.map((t) => (
|
||||
<TabsTrigger
|
||||
key={t.id}
|
||||
value={t.id}
|
||||
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
|
||||
>
|
||||
<span>{t.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[t.id] ?? 0}
|
||||
</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={setFilters}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTab('all')
|
||||
setFilters(createDefaultServiceFilters())
|
||||
}}
|
||||
>
|
||||
<FunnelXIcon className="size-4" aria-hidden />
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{treeData.length === 0 ? (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
title="Нет совпадений"
|
||||
description="Измените фильтры или вкладку."
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTab('all')
|
||||
setFilters(createDefaultServiceFilters())
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
<Separator />
|
||||
<FrameFooter>
|
||||
<DataGridPagination
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from} - {to} of {count}"
|
||||
previousPageLabel="Предыдущая"
|
||||
nextPageLabel="Следующая"
|
||||
/>
|
||||
</FrameFooter>
|
||||
</>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
FolderIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { serviceDisplayFqdn } from '@/lib/service-utils'
|
||||
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export type ServiceTreeServiceRow = {
|
||||
kind: 'service'
|
||||
id: string
|
||||
service: ServiceView
|
||||
groupId: number | null
|
||||
groupName: string | null
|
||||
}
|
||||
|
||||
export type ServiceTreeGroupRow = {
|
||||
kind: 'group'
|
||||
id: string
|
||||
group: ServiceGroupView | null
|
||||
subRows: ServiceTreeServiceRow[]
|
||||
}
|
||||
|
||||
export type ServiceCatalogTreeRow = ServiceTreeGroupRow | ServiceTreeServiceRow
|
||||
|
||||
function isServiceRow(row: ServiceCatalogTreeRow): row is ServiceTreeServiceRow {
|
||||
return row.kind === 'service'
|
||||
}
|
||||
|
||||
export function createServicesGroupedColumns({
|
||||
onEditService,
|
||||
onDeleteService,
|
||||
onToggleService,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onAddServiceToGroup,
|
||||
togglingId,
|
||||
}: {
|
||||
onEditService: (service: ServiceView) => void
|
||||
onDeleteService: (service: ServiceView) => void
|
||||
onToggleService: (serviceId: number, enabled: boolean) => void
|
||||
onEditGroup: (group: ServiceGroupView) => void
|
||||
onDeleteGroup: (group: ServiceGroupView) => void
|
||||
onAddServiceToGroup: (groupId: number | null) => void
|
||||
togglingId: number | null
|
||||
}): ColumnDef<ServiceCatalogTreeRow>[] {
|
||||
return [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) =>
|
||||
isServiceRow(row) ? row.service.name : (row.group?.name ?? 'Без группы'),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Группа / сервис" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original
|
||||
if (!isServiceRow(original)) {
|
||||
const title = original.group?.name ?? 'Без группы'
|
||||
const domain = original.group?.domain
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={
|
||||
row.getIsExpanded() ? `Свернуть ${title}` : `Развернуть ${title}`
|
||||
}
|
||||
aria-expanded={row.getIsExpanded()}
|
||||
className="text-muted-foreground hover:text-foreground size-6 shrink-0 p-0 shadow-none"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
row.getToggleExpandedHandler()()
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 transition-transform duration-150',
|
||||
row.getIsExpanded() && 'rotate-90',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
<FolderIcon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">{title}</span>
|
||||
<Badge variant="outline" size="xs" className="shrink-0">
|
||||
{original.subRows.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{domain ? (
|
||||
<span className="text-muted-foreground truncate font-mono text-xs">
|
||||
{domain}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5 pl-8">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{original.service.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate font-mono text-xs">
|
||||
{serviceDisplayFqdn(original.service)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
minSize: 260,
|
||||
meta: { headerTitle: 'Группа / сервис', autoSize: true },
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Доступность" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original
|
||||
if (!isServiceRow(original)) {
|
||||
return (
|
||||
<HealthCheckBadge
|
||||
status={original.group?.health_status ?? 'unknown'}
|
||||
latencyMs={original.group?.health_latency_ms}
|
||||
size="xs"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<HealthCheckBadge
|
||||
status={original.service.health_status ?? 'unknown'}
|
||||
latencyMs={original.service.health_latency_ms}
|
||||
size="xs"
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original
|
||||
if (!isServiceRow(original)) return null
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={original.service.enabled}
|
||||
disabled={togglingId === original.service.id}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleService(original.service.id, Boolean(checked))
|
||||
}
|
||||
aria-label={
|
||||
original.service.enabled
|
||||
? 'Выключить сервис'
|
||||
: 'Включить сервис'
|
||||
}
|
||||
/>
|
||||
<StatusBadge
|
||||
status={original.service.enabled ? 'active' : 'disabled'}
|
||||
label={original.service.enabled ? 'Вкл' : 'Выкл'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const original = row.original
|
||||
if (!isServiceRow(original)) {
|
||||
if (!original.group) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Добавить сервис без группы"
|
||||
onClick={() => onAddServiceToGroup(null)}
|
||||
>
|
||||
<PlusIcon aria-hidden />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Действия группы ${original.group.name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onAddServiceToGroup(original.group!.id)}
|
||||
>
|
||||
<PlusIcon aria-hidden />
|
||||
Добавить сервис
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEditGroup(original.group!)}>
|
||||
Изменить группу
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDeleteGroup(original.group!)}
|
||||
>
|
||||
Удалить группу
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Действия ${original.service.name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEditService(original.service)}>
|
||||
Изменить
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDeleteService(original.service)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
},
|
||||
size: 56,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function useServicesGroupedColumns(
|
||||
args: Parameters<typeof createServicesGroupedColumns>[0],
|
||||
) {
|
||||
return useMemo(() => createServicesGroupedColumns(args), [
|
||||
args.onEditService,
|
||||
args.onDeleteService,
|
||||
args.onToggleService,
|
||||
args.onEditGroup,
|
||||
args.onDeleteGroup,
|
||||
args.onAddServiceToGroup,
|
||||
args.togglingId,
|
||||
])
|
||||
}
|
||||
Reference in New Issue
Block a user