feat: integrate ReUI components and update MCP configuration
Build, Test, and Push CFDM Docker Image / test (push) Failing after 49s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Failing after 49s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
- Added ReUI configuration to .mcp.json and .cursor/mcp.json for component integration. - Updated pnpm-lock.yaml with new dependencies including react-phone-number-input and adjustments to existing packages. - Enhanced SKILL.md documentation for ReUI to clarify usage and features. - Removed unused components (ChartCard, DataGridCard, etc.) to streamline the codebase. - Adjusted domain-related components and filters for improved functionality and UI consistency. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { type CSSProperties } from "react"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { SidebarInset, SidebarProvider } from "@cfdm/ui/components/sidebar"
|
||||
|
||||
import { AppSidebar } from "./app-sidebar"
|
||||
import { PageToolbar } from "./page-toolbar"
|
||||
import { SiteFooter } from "./site-footer"
|
||||
import { SiteHeader } from "./site-header"
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<SidebarProvider
|
||||
className={cn(
|
||||
"[--sidebar:color-mix(in_oklab,var(--color-sidebar)_60%,transparent)]",
|
||||
"[--sidebar-border:transparent]",
|
||||
"[--sidebar-accent:color-mix(in_oklab,var(--color-primary)_5%,transparent)]",
|
||||
"[--sidebar-accent-foreground:var(--color-primary)]"
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "240px",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Sidebar */}
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
{/* Header */}
|
||||
<SiteHeader />
|
||||
{/* Content */}
|
||||
<main className="flex flex-1 flex-col gap-5 px-4 py-4 md:px-6 md:py-5">
|
||||
<PageToolbar />
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="border-border/70 bg-muted/20 aspect-video rounded-lg border border-dashed" />
|
||||
<div className="border-border/70 bg-muted/20 aspect-video rounded-lg border border-dashed" />
|
||||
<div className="border-border/70 bg-muted/20 aspect-video rounded-lg border border-dashed" />
|
||||
</div>
|
||||
<div className="border-border/70 bg-muted/20 min-h-80 flex-1 rounded-lg border border-dashed" />
|
||||
</main>
|
||||
{/* Footer */}
|
||||
<SiteFooter />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import {
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
Sidebar as SidebarRoot,
|
||||
useSidebar,
|
||||
} from "@cfdm/ui/components/sidebar"
|
||||
|
||||
import { Brand } from "./brand"
|
||||
import { NavMain } from "./nav-main"
|
||||
import { NavProjects } from "./nav-projects"
|
||||
import { NavSecondary } from "./nav-secondary"
|
||||
|
||||
function SidebarRailToggle() {
|
||||
const { state, toggleSidebar } = useSidebar()
|
||||
const isExpanded = state === "expanded"
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
||||
onClick={toggleSidebar}
|
||||
style={{
|
||||
left: isExpanded ? "var(--sidebar-width)" : "var(--sidebar-width-icon)",
|
||||
}}
|
||||
className={cn(
|
||||
"group/rail fixed top-1/2 z-30 flex h-12 w-7 -translate-y-1/2 cursor-pointer items-center pl-2 outline-none",
|
||||
"transition-[left] duration-200 ease-linear"
|
||||
)}
|
||||
>
|
||||
<span className="flex flex-col items-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-foreground/40 block h-2 w-0.5 rounded-t-full",
|
||||
"origin-bottom transition-all duration-100 ease-linear",
|
||||
isExpanded
|
||||
? "group-hover/rail:bg-foreground/60 group-hover/rail:rotate-40"
|
||||
: "group-hover/rail:bg-foreground/60 group-hover/rail:-rotate-40"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-foreground/40 block h-2 w-0.5 rounded-b-full",
|
||||
"origin-top transition-all duration-100 ease-linear",
|
||||
isExpanded
|
||||
? "group-hover/rail:bg-foreground/60 group-hover/rail:-rotate-40"
|
||||
: "group-hover/rail:bg-foreground/60 group-hover/rail:rotate-40"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"border-border bg-foreground text-background absolute left-full -ml-2 rounded-md border px-2 py-0.5 text-[11px] font-medium whitespace-nowrap shadow-xs shadow-black/5",
|
||||
"pointer-events-none transition-all duration-200 ease-out",
|
||||
"-translate-x-0.5 opacity-0",
|
||||
"group-hover/rail:translate-x-0 group-hover/rail:opacity-100"
|
||||
)}
|
||||
>
|
||||
{isExpanded ? "Collapse" : "Expand"}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
return (
|
||||
<SidebarRoot collapsible="icon">
|
||||
<SidebarHeader className="pb-0">
|
||||
<Brand />
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain />
|
||||
<NavProjects />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="px-1! in-data-[state=collapsed]:px-1!">
|
||||
<div className="px-2">
|
||||
<Separator />
|
||||
</div>
|
||||
<NavSecondary />
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarRailToggle />
|
||||
</SidebarRoot>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@cfdm/ui/components/dropdown-menu"
|
||||
import { APPS } from "./data"
|
||||
import { LayoutGridIcon } from "lucide-react"
|
||||
|
||||
export function AppsMenu() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" size="icon" aria-label="Apps" />}
|
||||
>
|
||||
<LayoutGridIcon className="size-4.5 transition-colors" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-72"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Apps</DropdownMenuLabel>
|
||||
<div className="grid grid-cols-3 gap-1 p-1">
|
||||
{APPS.map((app) => (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
render={<a href="#" />}
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">{app.icon}</span>
|
||||
<span className="text-xs font-medium">{app.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
render={<a href="#" />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Browse All Apps
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { Item, ItemMedia } from "@cfdm/ui/components/item"
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@cfdm/ui/components/sidebar"
|
||||
|
||||
import { BRAND } from "./data"
|
||||
|
||||
function AuthLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<Item
|
||||
className={cn(
|
||||
"p-0",
|
||||
"bg-primary text-primary-foreground flex size-8 shrink-0 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<svg
|
||||
width="50"
|
||||
height="50"
|
||||
viewBox="25.668 25.1352 49.6644 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-4"
|
||||
>
|
||||
<circle cx="70.634" cy="29.8334" r="4.69799" fill="currentColor" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M25.668 57.0144V29.8332C25.668 27.2386 27.7713 25.1352 30.366 25.1352C32.9606 25.1352 35.0639 27.2386 35.0639 29.8332V57.0144C35.0639 61.833 38.9702 65.7392 43.7888 65.7392H57.2116C62.0302 65.7392 65.9364 61.833 65.9364 57.0144V43.7258C65.9364 41.1312 68.0398 39.0278 70.6344 39.0278C73.229 39.0278 75.3324 41.1312 75.3324 43.7258V57.0144C75.3324 67.0222 67.2194 75.1352 57.2116 75.1352H43.7888C33.7809 75.1352 25.668 67.0222 25.668 57.0144Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function BrandLogo() {
|
||||
return (
|
||||
<span className="relative flex size-7 shrink-0 items-center justify-center">
|
||||
<AuthLogo className="absolute top-1/2 left-1/2 origin-center -translate-x-1/2 -translate-y-1/2 scale-[0.875]" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Brand() {
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{/* Sidebar */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
render={<a href="#" aria-label={`${BRAND.name} home`} />}
|
||||
className="gap-2.5"
|
||||
>
|
||||
<BrandLogo />
|
||||
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
||||
<span className="truncate text-sm font-semibold">{BRAND.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{BRAND.tier} plan
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { SettingsIcon, UsersIcon, BookOpenIcon, HouseIcon, BarChart3Icon, GlobeIcon, ExternalLinkIcon, UserPlusIcon, FlagIcon, CopyIcon, ArchiveIcon, InboxIcon, CalendarIcon, FileTextIcon, ListChecksIcon } from "lucide-react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type NavChild = {
|
||||
id: string
|
||||
label: string
|
||||
isActive?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type NavItem = {
|
||||
id: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
badge?: string | number
|
||||
isActive?: boolean
|
||||
disabled?: boolean
|
||||
children?: NavChild[]
|
||||
}
|
||||
|
||||
export type NavGroup = {
|
||||
id: string
|
||||
label?: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
export type SecondaryItem = {
|
||||
id: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export type Workspace = {
|
||||
id: string
|
||||
name: string
|
||||
tier: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
name: string
|
||||
progress: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export type ItemAction = {
|
||||
id: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
destructive: boolean
|
||||
}
|
||||
|
||||
export type AppShortcut = {
|
||||
id: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export type NotificationType =
|
||||
| "mention"
|
||||
| "comment"
|
||||
| "share"
|
||||
| "invite"
|
||||
| "billing"
|
||||
| "security"
|
||||
| "feature"
|
||||
| "deployment"
|
||||
| "usage"
|
||||
| "system"
|
||||
| "task"
|
||||
| "approval"
|
||||
| "integration"
|
||||
| "achievement"
|
||||
| "feedback"
|
||||
| "team_join"
|
||||
| "reaction"
|
||||
| "review"
|
||||
| "event"
|
||||
|
||||
export type NotificationVariant = "info" | "success" | "warning" | "destructive"
|
||||
|
||||
export type NotificationAction = {
|
||||
label: string
|
||||
variant?: "default" | "outline" | "destructive"
|
||||
}
|
||||
|
||||
export type NotificationAttachment = {
|
||||
name: string
|
||||
size: string
|
||||
}
|
||||
|
||||
export type NotificationAvatar = {
|
||||
src: string
|
||||
fallback: string
|
||||
}
|
||||
|
||||
export type NotificationGroupMember = {
|
||||
src: string
|
||||
fallback: string
|
||||
online?: boolean
|
||||
}
|
||||
|
||||
export type NotificationMeta = {
|
||||
label: string
|
||||
value: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export type Notification = {
|
||||
id: string
|
||||
type: NotificationType
|
||||
variant?: NotificationVariant
|
||||
title: string
|
||||
body?: string
|
||||
time: string
|
||||
unread?: boolean
|
||||
avatar?: NotificationAvatar
|
||||
username?: string
|
||||
link?: string
|
||||
badge?: string
|
||||
actions?: NotificationAction[]
|
||||
attachment?: NotificationAttachment
|
||||
meta?: NotificationMeta
|
||||
progress?: number
|
||||
progressVariant?: "default" | "success"
|
||||
avatarGroup?: NotificationGroupMember[]
|
||||
avatarGroupCount?: number
|
||||
rating?: number
|
||||
eventDate?: string
|
||||
eventTime?: string
|
||||
}
|
||||
|
||||
export type FooterLink = { id: string; label: string }
|
||||
|
||||
// ── Brand + account ──
|
||||
|
||||
export const BRAND = {
|
||||
name: "ReUI",
|
||||
tier: "Pro",
|
||||
} as const
|
||||
|
||||
export const USER = {
|
||||
name: "Theo Park",
|
||||
email: "[email protected]",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "TP",
|
||||
} as const
|
||||
|
||||
export const WORKSPACES: Workspace[] = [
|
||||
{
|
||||
id: "reui",
|
||||
name: "ReUI Labs",
|
||||
tier: "Enterprise",
|
||||
description: "Design systems and block releases",
|
||||
},
|
||||
{
|
||||
id: "studio",
|
||||
name: "ReUI Studio",
|
||||
tier: "Pro",
|
||||
description: "Customer workspace builds",
|
||||
},
|
||||
{
|
||||
id: "ops",
|
||||
name: "ReUI Ops",
|
||||
tier: "Team",
|
||||
description: "Billing, support, and reliability",
|
||||
},
|
||||
]
|
||||
|
||||
// ── Nav secondary ──
|
||||
|
||||
export const NAV_SECONDARY: SecondaryItem[] = [
|
||||
{
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
icon: (
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "invite",
|
||||
label: "Invite Team",
|
||||
icon: (
|
||||
<UsersIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
label: "Documentation",
|
||||
icon: (
|
||||
<BookOpenIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// ── Sidebar navigation ──
|
||||
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: "dashboards",
|
||||
label: "Dashboards",
|
||||
items: [
|
||||
{
|
||||
id: "overview",
|
||||
label: "Overview",
|
||||
icon: (
|
||||
<HouseIcon aria-hidden="true" />
|
||||
),
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "analytics",
|
||||
label: "Analytics",
|
||||
icon: (
|
||||
<BarChart3Icon aria-hidden="true" />
|
||||
),
|
||||
badge: "Soon",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "workspace",
|
||||
label: "Workspace",
|
||||
items: [
|
||||
{
|
||||
id: "audience",
|
||||
label: "Audience",
|
||||
icon: (
|
||||
<UsersIcon aria-hidden="true" />
|
||||
),
|
||||
children: [
|
||||
{ id: "people", label: "People" },
|
||||
{ id: "segments", label: "Segments" },
|
||||
{ id: "companies", label: "Companies" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "account",
|
||||
label: "Account",
|
||||
icon: (
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
),
|
||||
children: [
|
||||
{ id: "profile", label: "Profile" },
|
||||
{ id: "billing", label: "Billing", isActive: true },
|
||||
{ id: "security", label: "Security" },
|
||||
{ id: "members", label: "Members" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "network",
|
||||
label: "Network",
|
||||
icon: (
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
),
|
||||
children: [
|
||||
{ id: "directory", label: "Directory" },
|
||||
{ id: "activity", label: "Activity" },
|
||||
{ id: "leads", label: "Leads", disabled: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Active projects ──
|
||||
|
||||
export const ACTIVE_PROJECTS: Project[] = [
|
||||
{
|
||||
id: "billing-portal",
|
||||
name: "Billing Portal",
|
||||
progress: 78,
|
||||
color: "stroke-blue-500",
|
||||
},
|
||||
{
|
||||
id: "audience-sync",
|
||||
name: "Audience Sync",
|
||||
progress: 64,
|
||||
color: "stroke-emerald-500",
|
||||
},
|
||||
{
|
||||
id: "report-builder",
|
||||
name: "Report Builder",
|
||||
progress: 42,
|
||||
color: "stroke-violet-500",
|
||||
},
|
||||
{
|
||||
id: "network-map",
|
||||
name: "Network Map",
|
||||
progress: 56,
|
||||
color: "stroke-orange-500",
|
||||
},
|
||||
{
|
||||
id: "access-review",
|
||||
name: "Access Review",
|
||||
progress: 88,
|
||||
color: "stroke-rose-500",
|
||||
},
|
||||
]
|
||||
|
||||
export const ITEM_ACTIONS: ItemAction[] = [
|
||||
{
|
||||
id: "open",
|
||||
label: "Open Project",
|
||||
icon: (
|
||||
<ExternalLinkIcon aria-hidden="true" />
|
||||
),
|
||||
destructive: false,
|
||||
},
|
||||
{
|
||||
id: "owner",
|
||||
label: "Assign Owner",
|
||||
icon: (
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
),
|
||||
destructive: false,
|
||||
},
|
||||
{
|
||||
id: "milestone",
|
||||
label: "Set Milestone",
|
||||
icon: (
|
||||
<FlagIcon aria-hidden="true" />
|
||||
),
|
||||
destructive: false,
|
||||
},
|
||||
{
|
||||
id: "duplicate",
|
||||
label: "Duplicate",
|
||||
icon: (
|
||||
<CopyIcon aria-hidden="true" />
|
||||
),
|
||||
destructive: false,
|
||||
},
|
||||
{
|
||||
id: "archive",
|
||||
label: "Archive",
|
||||
icon: (
|
||||
<ArchiveIcon aria-hidden="true" />
|
||||
),
|
||||
destructive: true,
|
||||
},
|
||||
]
|
||||
|
||||
// ── Header apps ──
|
||||
|
||||
export const APPS: AppShortcut[] = [
|
||||
{
|
||||
id: "inbox",
|
||||
label: "Inbox",
|
||||
icon: (
|
||||
<InboxIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "calendar",
|
||||
label: "Calendar",
|
||||
icon: (
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
label: "Docs",
|
||||
icon: (
|
||||
<FileTextIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tasks",
|
||||
label: "Tasks",
|
||||
icon: (
|
||||
<ListChecksIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
label: "Reports",
|
||||
icon: (
|
||||
<BarChart3Icon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
icon: (
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// ── Header notifications ──
|
||||
|
||||
export const NOTIFICATIONS: Notification[] = [
|
||||
{
|
||||
id: "mention",
|
||||
type: "mention",
|
||||
title: "mentioned you in",
|
||||
body: '"Can you review the token cleanup?"',
|
||||
time: "2m ago",
|
||||
unread: true,
|
||||
avatar: {
|
||||
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
fallback: "MS",
|
||||
},
|
||||
username: "@mira",
|
||||
link: "Block QA",
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
type: "approval",
|
||||
variant: "warning",
|
||||
title: "Release Approval",
|
||||
body: "App shell updates need signoff before registry publish.",
|
||||
time: "8m ago",
|
||||
unread: true,
|
||||
actions: [
|
||||
{ label: "Approve", variant: "default" },
|
||||
{ label: "Review", variant: "outline" },
|
||||
],
|
||||
meta: { label: "Priority", value: "High", color: "text-warning" },
|
||||
},
|
||||
{
|
||||
id: "share",
|
||||
type: "share",
|
||||
title: "shared",
|
||||
body: "Dashboard polish brief",
|
||||
time: "16m ago",
|
||||
unread: true,
|
||||
avatar: {
|
||||
src: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
fallback: "LG",
|
||||
},
|
||||
username: "@leo",
|
||||
attachment: {
|
||||
name: "release-notes.pdf",
|
||||
size: "2 MB",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "task",
|
||||
type: "task",
|
||||
variant: "info",
|
||||
title: "Task Assigned",
|
||||
body: "Audit dark sidebar contrast in app-shell-18.",
|
||||
time: "28m ago",
|
||||
unread: true,
|
||||
meta: { label: "Due", value: "Today", color: "text-destructive" },
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
type: "team_join",
|
||||
variant: "success",
|
||||
title: "4 people joined ReUI Labs",
|
||||
body: "Mira, Leo, Anika and James joined the Pro workspace.",
|
||||
time: "42m ago",
|
||||
unread: true,
|
||||
avatarGroup: [
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
||||
fallback: "MR",
|
||||
online: true,
|
||||
},
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
|
||||
fallback: "AR",
|
||||
},
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
|
||||
fallback: "JW",
|
||||
},
|
||||
],
|
||||
avatarGroupCount: 1,
|
||||
},
|
||||
{
|
||||
id: "reaction",
|
||||
type: "reaction",
|
||||
variant: "info",
|
||||
title: "Reactions On Your Comment",
|
||||
body: "Mira and 2 others reacted in #blocks-review.",
|
||||
time: "1h ago",
|
||||
avatarGroup: [
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
fallback: "MS",
|
||||
online: true,
|
||||
},
|
||||
{
|
||||
src: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
fallback: "LG",
|
||||
},
|
||||
],
|
||||
avatarGroupCount: 1,
|
||||
badge: "5 reacts",
|
||||
},
|
||||
{
|
||||
id: "review-score",
|
||||
type: "review",
|
||||
variant: "success",
|
||||
title: "New Review Received",
|
||||
body: "Anika rated the app shell updates.",
|
||||
time: "2h ago",
|
||||
avatar: {
|
||||
src: "https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
|
||||
fallback: "AR",
|
||||
},
|
||||
username: "@anika",
|
||||
rating: 4,
|
||||
},
|
||||
{
|
||||
id: "event",
|
||||
type: "event",
|
||||
variant: "info",
|
||||
title: "Release Review",
|
||||
body: "Production readiness with the design systems team.",
|
||||
time: "3h ago",
|
||||
unread: true,
|
||||
eventDate: "Mar 3, 2026",
|
||||
eventTime: "10:00 to 11:00 AM",
|
||||
meta: { label: "Where", value: "Meet", color: "text-info" },
|
||||
actions: [
|
||||
{ label: "Join", variant: "default" },
|
||||
{ label: "Decline", variant: "outline" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "invite",
|
||||
type: "invite",
|
||||
variant: "info",
|
||||
title: "Workspace Invitation",
|
||||
body: "Priya invited you to ReUI Studio.",
|
||||
time: "1h ago",
|
||||
actions: [
|
||||
{ label: "Accept", variant: "default" },
|
||||
{ label: "Decline", variant: "outline" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "integration",
|
||||
type: "integration",
|
||||
variant: "success",
|
||||
title: "Linear Connected",
|
||||
body: "Issue activity now syncs with ReUI notifications.",
|
||||
time: "2h ago",
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
type: "billing",
|
||||
variant: "info",
|
||||
title: "Payment Processed",
|
||||
body: "Your ReUI Pro subscription was renewed.",
|
||||
time: "3h ago",
|
||||
badge: "$49.00",
|
||||
},
|
||||
{
|
||||
id: "achievement",
|
||||
type: "achievement",
|
||||
variant: "success",
|
||||
title: "Monthly Goal Reached",
|
||||
body: "100 of 100 review tasks completed this month.",
|
||||
time: "4h ago",
|
||||
progress: 100,
|
||||
progressVariant: "success",
|
||||
meta: { label: "Goal", value: "100 tasks", color: "text-success" },
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
type: "security",
|
||||
variant: "destructive",
|
||||
title: "New Sign-In Detected",
|
||||
body: "New login from Mac OS, Chrome.",
|
||||
time: "Yesterday",
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
type: "usage",
|
||||
variant: "warning",
|
||||
title: "API Usage At 80%",
|
||||
body: "Your workspace used 80% of the monthly API quota.",
|
||||
time: "5 days ago",
|
||||
progress: 80,
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
type: "system",
|
||||
variant: "info",
|
||||
title: "Scheduled Maintenance",
|
||||
body: "Registry maintenance is planned for Feb 28, 2026 at 2:00 AM UTC.",
|
||||
time: "1 week ago",
|
||||
},
|
||||
]
|
||||
|
||||
// ── Footer ──
|
||||
|
||||
export const FOOTER_LINKS: FooterLink[] = [
|
||||
{ id: "docs", label: "Docs" },
|
||||
{ id: "changelog", label: "Changelog" },
|
||||
{ id: "support", label: "Support" },
|
||||
{ id: "privacy", label: "Privacy" },
|
||||
{ id: "status", label: "Status" },
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Fragment } from "react"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@cfdm/ui/components/dropdown-menu"
|
||||
import { SidebarMenuAction } from "@cfdm/ui/components/sidebar"
|
||||
import { ITEM_ACTIONS } from "./data"
|
||||
import { MoreHorizontalIcon } from "lucide-react"
|
||||
|
||||
export function ItemActionMenu({ label }: { label: string }) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuAction showOnHover aria-label={`Actions for ${label}`} />
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
{/* Content */}
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="w-44"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
{ITEM_ACTIONS.map((action) => (
|
||||
<Fragment key={action.id}>
|
||||
{action.destructive && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
variant={action.destructive ? "destructive" : "default"}
|
||||
className="[&_svg]:size-3.5 [&_svg]:opacity-60"
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@cfdm/ui/components/sidebar"
|
||||
import { NAV_GROUPS, type NavChild, type NavItem } from "./data"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function NavSubItem({ child }: { child: NavChild }) {
|
||||
if (child.disabled) {
|
||||
return (
|
||||
<SidebarMenuSubItem>
|
||||
{/* Row */}
|
||||
<SidebarMenuSubButton
|
||||
aria-disabled="true"
|
||||
className="pointer-events-none opacity-60"
|
||||
>
|
||||
<span>{child.label}</span>
|
||||
<Badge className="ml-auto h-5 rounded-full px-2 text-[10px] font-medium">
|
||||
Soon
|
||||
</Badge>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuSubItem>
|
||||
{/* Row */}
|
||||
<SidebarMenuSubButton render={<a href="#" />} isActive={child.isActive}>
|
||||
{child.label}
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
}
|
||||
|
||||
// CollapsibleNavItem: isolated so only this item re-renders on open/close.
|
||||
function CollapsibleNavItem({
|
||||
item,
|
||||
}: {
|
||||
item: NavItem & { children: NavChild[] }
|
||||
}) {
|
||||
const [open, setOpen] = useState(() => item.children.some((c) => c.isActive))
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
{/* Sidebar */}
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
isActive={item.isActive}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
aria-expanded={open}
|
||||
aria-controls={`subnav-${item.id}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
<ChevronRightIcon className={cn(
|
||||
"ml-auto size-4 shrink-0 opacity-60 transition-transform duration-200 group-data-[collapsible=icon]:hidden",
|
||||
open && "rotate-90"
|
||||
)} aria-hidden="true" />
|
||||
</SidebarMenuButton>
|
||||
|
||||
{open && (
|
||||
<SidebarMenuSub id={`subnav-${item.id}`}>
|
||||
{item.children.map((child) => (
|
||||
<NavSubItem key={child.id} child={child} />
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function LeafNavItem({ item }: { item: NavItem }) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
{/* Sidebar */}
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
isActive={item.isActive}
|
||||
render={<a href="#" />}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
{item.badge !== undefined && (
|
||||
<SidebarMenuBadge className="group-data-[collapsible=icon]:hidden">
|
||||
<Badge
|
||||
className={cn(
|
||||
"h-5 min-w-5 rounded-full px-2 text-[10px] font-medium",
|
||||
typeof item.badge === "number" && "tabular-nums"
|
||||
)}
|
||||
>
|
||||
{item.badge}
|
||||
</Badge>
|
||||
</SidebarMenuBadge>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavMain() {
|
||||
return (
|
||||
<>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<SidebarGroup key={group.id}>
|
||||
{/* Sidebar */}
|
||||
{group.label && <SidebarGroupLabel>{group.label}</SidebarGroupLabel>}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-0.25">
|
||||
{group.items.map((item) =>
|
||||
item.children ? (
|
||||
<CollapsibleNavItem
|
||||
key={item.id}
|
||||
item={item as NavItem & { children: NavChild[] }}
|
||||
/>
|
||||
) : (
|
||||
<LeafNavItem key={item.id} item={item} />
|
||||
)
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@cfdm/ui/components/sidebar"
|
||||
import { ACTIVE_PROJECTS, type Project } from "./data"
|
||||
import { ItemActionMenu } from "./item-action-menu"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
const RADIUS = 6
|
||||
const CX = 8
|
||||
const CY = 8
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
||||
|
||||
function PieProgress({ progress, color }: { progress: number; color: string }) {
|
||||
const offset =
|
||||
CIRCUMFERENCE * (1 - Math.min(100, Math.max(0, progress)) / 100)
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
className="shrink-0 -rotate-90 opacity-100!"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
className="stroke-muted-foreground/20"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
className={color}
|
||||
strokeWidth="2.5"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectItem({ project }: { project: Project }) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
{/* Sidebar */}
|
||||
<SidebarMenuButton
|
||||
tooltip={`${project.name} · ${project.progress}% complete`}
|
||||
render={<a href="#" />}
|
||||
>
|
||||
<PieProgress progress={project.progress} color={project.color} />
|
||||
<span className="min-w-0 truncate">{project.name}</span>
|
||||
</SidebarMenuButton>
|
||||
{/* Row */}
|
||||
<ItemActionMenu label={project.name} />
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectList() {
|
||||
return (
|
||||
<SidebarGroupContent id="active-projects-list">
|
||||
{/* Sidebar */}
|
||||
<SidebarMenu className="gap-0.25">
|
||||
{ACTIVE_PROJECTS.map((project) => (
|
||||
<ProjectItem key={project.id} project={project} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavProjects() {
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
{/* Sidebar */}
|
||||
<SidebarGroupLabel
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
aria-expanded={open}
|
||||
aria-controls="active-projects-list"
|
||||
/>
|
||||
}
|
||||
className="focus-visible:ring-sidebar-ring w-full cursor-pointer focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
Active Projects
|
||||
<ChevronDownIcon className={cn(
|
||||
"ml-auto size-4 shrink-0 opacity-60 transition-transform duration-200",
|
||||
!open && "-rotate-90"
|
||||
)} aria-hidden="true" />
|
||||
</SidebarGroupLabel>
|
||||
|
||||
{open && <ProjectList />}
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@cfdm/ui/components/sidebar"
|
||||
|
||||
import { NAV_SECONDARY, type SecondaryItem } from "./data"
|
||||
|
||||
function SecondaryNavItem({ item }: { item: SecondaryItem }) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="sm"
|
||||
tooltip={item.label}
|
||||
render={<a href="#" />}
|
||||
className="h-8! in-data-[state=collapsed]:h-8! [&_svg]:size-3.5"
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavSecondary() {
|
||||
return (
|
||||
<SidebarGroup className="py-1">
|
||||
<SidebarMenu>
|
||||
{NAV_SECONDARY.map((item) => (
|
||||
<SecondaryNavItem key={item.id} item={item} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Rating } from "@/components/reui/rating"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
} from "@cfdm/ui/components/avatar"
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { ButtonGroup } from "@cfdm/ui/components/button-group"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@cfdm/ui/components/popover"
|
||||
import { Progress } from "@cfdm/ui/components/progress"
|
||||
import { ScrollArea } from "@cfdm/ui/components/scroll-area"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@cfdm/ui/components/tooltip"
|
||||
import {
|
||||
NOTIFICATIONS,
|
||||
type Notification,
|
||||
type NotificationGroupMember,
|
||||
type NotificationType,
|
||||
type NotificationVariant,
|
||||
} from "./data"
|
||||
import { MessageSquareIcon, PaperclipIcon, UserPlusIcon, CreditCardIcon, ShieldAlertIcon, SparklesIcon, RocketIcon, ActivityIcon, AlertCircleIcon, CircleCheckIcon, LinkIcon, StarIcon, ThumbsUpIcon, UsersIcon, SmileIcon, CalendarIcon, DownloadIcon, CheckCheckIcon, BellIcon } from "lucide-react"
|
||||
|
||||
const NOTIFICATION_ICONS: Record<NotificationType, ReactNode> = {
|
||||
mention: (
|
||||
<MessageSquareIcon aria-hidden="true" />
|
||||
),
|
||||
comment: (
|
||||
<MessageSquareIcon aria-hidden="true" />
|
||||
),
|
||||
share: (
|
||||
<PaperclipIcon aria-hidden="true" />
|
||||
),
|
||||
invite: (
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
),
|
||||
billing: (
|
||||
<CreditCardIcon aria-hidden="true" />
|
||||
),
|
||||
security: (
|
||||
<ShieldAlertIcon aria-hidden="true" />
|
||||
),
|
||||
feature: (
|
||||
<SparklesIcon aria-hidden="true" />
|
||||
),
|
||||
deployment: (
|
||||
<RocketIcon aria-hidden="true" />
|
||||
),
|
||||
usage: (
|
||||
<ActivityIcon aria-hidden="true" />
|
||||
),
|
||||
system: (
|
||||
<AlertCircleIcon aria-hidden="true" />
|
||||
),
|
||||
task: (
|
||||
<CircleCheckIcon aria-hidden="true" />
|
||||
),
|
||||
approval: (
|
||||
<CircleCheckIcon aria-hidden="true" />
|
||||
),
|
||||
integration: (
|
||||
<LinkIcon aria-hidden="true" />
|
||||
),
|
||||
achievement: (
|
||||
<StarIcon aria-hidden="true" />
|
||||
),
|
||||
feedback: (
|
||||
<ThumbsUpIcon aria-hidden="true" />
|
||||
),
|
||||
team_join: (
|
||||
<UsersIcon aria-hidden="true" />
|
||||
),
|
||||
reaction: (
|
||||
<SmileIcon aria-hidden="true" />
|
||||
),
|
||||
review: (
|
||||
<StarIcon aria-hidden="true" />
|
||||
),
|
||||
event: (
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
),
|
||||
}
|
||||
|
||||
const VARIANT_COLORS: Record<NotificationVariant, string> = {
|
||||
info: "text-info",
|
||||
success: "text-success",
|
||||
warning: "text-warning",
|
||||
destructive: "text-destructive",
|
||||
}
|
||||
|
||||
function NotifAvatarGroup({
|
||||
members,
|
||||
count,
|
||||
}: {
|
||||
members: NotificationGroupMember[]
|
||||
count?: number
|
||||
}) {
|
||||
return (
|
||||
<AvatarGroup className="mt-1.5 -space-x-1">
|
||||
{members.map((member) => (
|
||||
<Avatar key={member.src} className="size-5">
|
||||
<AvatarImage src={member.src} alt={member.fallback} />
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{member.fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
{count && count > 0 ? (
|
||||
<AvatarGroupCount className="size-5 text-[9px] leading-none">
|
||||
+{count}
|
||||
</AvatarGroupCount>
|
||||
) : null}
|
||||
</AvatarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaBadge({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
color?: string
|
||||
}) {
|
||||
return (
|
||||
<span className="border-border/60 inline-flex h-[18px] shrink-0 items-center overflow-hidden rounded-full border text-[10px] font-medium">
|
||||
<span className="bg-muted/60 text-muted-foreground border-border/50 flex h-full items-center border-r px-1.5 leading-none">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-full items-center px-1.5 leading-none",
|
||||
color || "text-foreground"
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniProgress({
|
||||
value,
|
||||
variant = "default",
|
||||
}: {
|
||||
value: number
|
||||
variant?: "default" | "success"
|
||||
}) {
|
||||
const indicatorColor =
|
||||
variant === "success"
|
||||
? "**:data-[slot=progress-indicator]:bg-success"
|
||||
: value >= 80
|
||||
? "**:data-[slot=progress-indicator]:bg-warning"
|
||||
: "**:data-[slot=progress-indicator]:bg-primary"
|
||||
|
||||
return (
|
||||
<div className="bg-muted/55 relative mt-1.5 h-1 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="text-muted-foreground pointer-events-none absolute inset-0 opacity-20"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(-45deg, currentColor 0, currentColor 1px, transparent 0, transparent 4px)",
|
||||
}}
|
||||
/>
|
||||
<Progress
|
||||
value={value}
|
||||
className={cn(
|
||||
"absolute inset-0 gap-0",
|
||||
"**:data-[slot=progress-track]:h-full **:data-[slot=progress-track]:bg-transparent",
|
||||
"**:data-[slot=progress-indicator]:h-full",
|
||||
indicatorColor
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationItem({ notification }: { notification: Notification }) {
|
||||
const {
|
||||
type,
|
||||
variant = "info",
|
||||
title,
|
||||
body,
|
||||
time,
|
||||
unread,
|
||||
avatar,
|
||||
username,
|
||||
link,
|
||||
badge,
|
||||
actions,
|
||||
attachment,
|
||||
meta,
|
||||
progress,
|
||||
avatarGroup,
|
||||
avatarGroupCount,
|
||||
rating,
|
||||
eventDate,
|
||||
eventTime,
|
||||
progressVariant,
|
||||
} = notification
|
||||
|
||||
const iconColor = VARIANT_COLORS[variant]
|
||||
const hasAvatar = Boolean(avatar)
|
||||
const hasActions = Boolean(actions?.length)
|
||||
const hasInteractiveChildren = hasActions || Boolean(attachment)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="shrink-0">
|
||||
{hasAvatar ? (
|
||||
<Avatar size="sm">
|
||||
<AvatarImage src={avatar?.src} alt={avatar?.fallback} />
|
||||
<AvatarFallback>{avatar?.fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center [&_svg]:size-4",
|
||||
iconColor
|
||||
)}
|
||||
>
|
||||
{NOTIFICATION_ICONS[type]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-foreground text-xs leading-snug">
|
||||
{username && (
|
||||
<span className="text-primary font-medium">{username}</span>
|
||||
)}{" "}
|
||||
{hasAvatar ? (
|
||||
<>
|
||||
{title}{" "}
|
||||
{link && (
|
||||
<span className="text-primary font-medium">{link}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="font-medium">{title}</span>
|
||||
)}
|
||||
</p>
|
||||
{badge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 shrink-0 rounded-full px-1.5 text-[10px]"
|
||||
>
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{body && (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">{body}</p>
|
||||
)}
|
||||
|
||||
{avatarGroup && avatarGroup.length > 0 && (
|
||||
<NotifAvatarGroup members={avatarGroup} count={avatarGroupCount} />
|
||||
)}
|
||||
|
||||
{rating !== undefined && (
|
||||
<Rating rating={rating} size="sm" className="mt-0.5" />
|
||||
)}
|
||||
|
||||
{(eventDate || eventTime) && (
|
||||
<div className="bg-muted/50 border-border/50 text-muted-foreground mt-1 inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-[11px]">
|
||||
<CalendarIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
|
||||
{eventDate && (
|
||||
<span className="text-foreground font-medium">{eventDate}</span>
|
||||
)}
|
||||
{eventTime && <span className="opacity-70">{eventTime}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress !== undefined && (
|
||||
<MiniProgress value={progress} variant={progressVariant} />
|
||||
)}
|
||||
|
||||
{attachment && (
|
||||
<div className="flex items-center gap-1 py-1">
|
||||
<ButtonGroup>
|
||||
<Button variant="outline" size="xs">
|
||||
<PaperclipIcon aria-hidden="true" />
|
||||
{attachment.name}
|
||||
<span className="opacity-60">({attachment.size})</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="icon-xs" aria-label="Download">
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActions && (
|
||||
<div className="flex items-center gap-1 py-1">
|
||||
{actions?.map((action) => (
|
||||
<Button
|
||||
key={action.label}
|
||||
size="xs"
|
||||
variant={action.variant === "outline" ? "outline" : "default"}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-0.5">
|
||||
<p className="text-muted-foreground text-[11px]">{time}</p>
|
||||
{meta && (
|
||||
<MetaBadge
|
||||
label={meta.label}
|
||||
value={meta.value}
|
||||
color={meta.color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{unread && (
|
||||
<span
|
||||
className="bg-primary ring-background pointer-events-none absolute top-3 right-3 z-10 size-1.5 rounded-full ring-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{hasInteractiveChildren ? (
|
||||
<div className="flex w-full items-start gap-2 p-2 text-left">
|
||||
{content}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto w-full items-start justify-start p-2 text-left whitespace-normal"
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationsPanel() {
|
||||
const unreadCount = NOTIFICATIONS.filter(
|
||||
(notification) => notification.unread
|
||||
).length
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-border/40 flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="h-5 min-w-5 rounded-full px-1.5 text-[11px]">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="opacity-60 hover:opacity-100"
|
||||
aria-label="Mark all as read"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CheckCheckIcon className="size-3.5" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mark all as read</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className="relative flex max-h-full">
|
||||
<ScrollArea className="max-h-[320px] grow">
|
||||
{NOTIFICATIONS.map((notification, index) => (
|
||||
<div key={notification.id}>
|
||||
<NotificationItem notification={notification} />
|
||||
{index < NOTIFICATIONS.length - 1 && (
|
||||
<Separator className="opacity-60" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 border-t px-2 py-1">
|
||||
<Button variant="ghost" size="sm" className="w-full text-xs">
|
||||
View All Notifications
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationsMenu() {
|
||||
const unreadCount = NOTIFICATIONS.filter(
|
||||
(notification) => notification.unread
|
||||
).length
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
aria-label="Open notifications"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Notifications, ${unreadCount} unread`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="relative inline-flex">
|
||||
<BellIcon className="size-4.5 transition-colors" aria-hidden="true" />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className="bg-primary ring-background absolute -top-1 -right-1 size-1.5 rounded-full ring-2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="!bg-background w-80 gap-0 p-0"
|
||||
>
|
||||
<NotificationsPanel />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { DownloadIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
export function PageToolbar() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h1 className="text-foreground text-xl leading-6 font-semibold tracking-tight">
|
||||
Overview
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your workspace at a glance.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
Export
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
New Report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useId, useState } from "react"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@cfdm/ui/components/dialog"
|
||||
import { Input } from "@cfdm/ui/components/input"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
export function SearchMenu() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const searchInputId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
return () => window.removeEventListener("keydown", onKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Search"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SearchIcon className="size-4.5 transition-colors" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
<DialogDescription>Search your workspace content.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md px-4 py-2 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
|
||||
<div className="relative flex items-center gap-3">
|
||||
<SearchIcon aria-hidden="true" className="pointer-events-none size-4 opacity-60 select-none" />
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Type to search..."
|
||||
aria-label="Search"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { FOOTER_LINKS } from "./data"
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t px-4 py-2.5 md:px-6">
|
||||
<div className="flex flex-col items-center justify-between gap-2 text-xs md:flex-row">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5">
|
||||
<span>2026 ©</span>
|
||||
<a
|
||||
href="#"
|
||||
className="text-secondary-foreground hover:text-primary font-medium"
|
||||
>
|
||||
ReUI
|
||||
</a>
|
||||
</div>
|
||||
<nav className="text-muted-foreground flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
|
||||
{FOOTER_LINKS.map((link) => (
|
||||
<a key={link.id} href="#" className="hover:text-primary">
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@cfdm/ui/components/breadcrumb"
|
||||
import { SidebarTrigger } from "@cfdm/ui/components/sidebar"
|
||||
|
||||
import { AppsMenu } from "./apps-menu"
|
||||
import { NotificationsMenu } from "./notifications-menu"
|
||||
import { SearchMenu } from "./search-menu"
|
||||
import { UserMenu } from "./user-menu"
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1 md:hidden" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink render={<a href="#" />}>
|
||||
Dashboards
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Overview</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground [&_button_svg]:text-muted-foreground [&_button:active_svg]:text-foreground! [&_button:hover>span>svg]:text-foreground! [&_button:hover>svg]:text-foreground! [&_button[aria-expanded=true]_svg]:text-foreground! [&_button[data-popup-open]_svg]:text-foreground! ml-auto flex items-center gap-2">
|
||||
<SearchMenu />
|
||||
<NotificationsMenu />
|
||||
<AppsMenu />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, type ComponentType } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
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,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@cfdm/ui/components/dropdown-menu"
|
||||
import { USER, WORKSPACES, type Workspace } from "./data"
|
||||
import { SunIcon, MoonIcon, MonitorIcon, CheckIcon, PlusIcon, UserIcon, CreditCardIcon, SettingsIcon, PaletteIcon, LogOutIcon } from "lucide-react"
|
||||
|
||||
function ReuiLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
width="100%"
|
||||
height="100%"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sb12-reui"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="28"
|
||||
y2="28"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#6366f1" />
|
||||
<stop offset="52%" stopColor="#8b5cf6" />
|
||||
<stop offset="100%" stopColor="#ec4899" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="14" cy="14" r="14" fill="url(#sb12-reui)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StudioLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
width="100%"
|
||||
height="100%"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sb12-studio"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="28"
|
||||
y2="28"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#0ea5e9" />
|
||||
<stop offset="50%" stopColor="#06b6d4" />
|
||||
<stop offset="100%" stopColor="#10b981" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="14" cy="14" r="14" fill="url(#sb12-studio)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function OpsLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
width="100%"
|
||||
height="100%"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sb12-ops"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="28"
|
||||
y2="28"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#f97316" />
|
||||
<stop offset="50%" stopColor="#f59e0b" />
|
||||
<stop offset="100%" stopColor="#84cc16" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="14" cy="14" r="14" fill="url(#sb12-ops)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const WORKSPACE_LOGOS: Record<string, ComponentType<{ className?: string }>> = {
|
||||
reui: ReuiLogo,
|
||||
studio: StudioLogo,
|
||||
ops: OpsLogo,
|
||||
}
|
||||
|
||||
const THEMES = [
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
icon: (
|
||||
<SunIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
icon: (
|
||||
<MoonIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "system",
|
||||
label: "System",
|
||||
icon: (
|
||||
<MonitorIcon className="size-3.5" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function ThemeSegmentedToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const currentTheme = mounted ? (theme ?? "system") : "system"
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Theme"
|
||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||
>
|
||||
{THEMES.map(({ value, label, icon }) => {
|
||||
const isActive = currentTheme === value
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
isActive
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceAvatar({
|
||||
workspace,
|
||||
className,
|
||||
}: {
|
||||
workspace: Workspace
|
||||
className?: string
|
||||
}) {
|
||||
const LogoComponent = WORKSPACE_LOGOS[workspace.id]
|
||||
|
||||
if (LogoComponent) {
|
||||
return <LogoComponent className={cn("shrink-0", className)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar className={cn("shrink-0", className)}>
|
||||
<AvatarFallback className="bg-background border-border text-foreground border text-sm font-medium">
|
||||
{workspace.name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceItem({
|
||||
workspace,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: {
|
||||
workspace: Workspace
|
||||
isActive: boolean
|
||||
onSelect: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem onClick={() => onSelect(workspace.id)}>
|
||||
<WorkspaceAvatar workspace={workspace} className="size-5" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-medium">{workspace.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{workspace.tier}
|
||||
</span>
|
||||
</div>
|
||||
{isActive && (
|
||||
<CheckIcon className="ml-auto size-3.5 shrink-0 opacity-60" aria-hidden="true" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function UserMenu() {
|
||||
const [activeWorkspaceId, setActiveWorkspaceId] = useState(WORKSPACES[0].id)
|
||||
|
||||
const activeWorkspace =
|
||||
WORKSPACES.find((workspace) => workspace.id === activeWorkspaceId) ??
|
||||
WORKSPACES[0]
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" aria-label="Open user menu" />
|
||||
}
|
||||
>
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={USER.image} alt={USER.name} />
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{USER.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="!bg-background w-64"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="flex items-center gap-2 py-2">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={USER.image} alt={USER.name} />
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{USER.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-foreground truncate text-sm font-semibold">
|
||||
{USER.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{USER.email}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs font-normal">
|
||||
Organizations
|
||||
</DropdownMenuLabel>
|
||||
{WORKSPACES.map((workspace) => (
|
||||
<WorkspaceItem
|
||||
key={workspace.id}
|
||||
workspace={workspace}
|
||||
isActive={activeWorkspaceId === workspace.id}
|
||||
onSelect={setActiveWorkspaceId}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuItem>
|
||||
<PlusIcon aria-hidden="true" className="mx-0.5" />
|
||||
New Organization
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs font-normal">
|
||||
Account
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem>
|
||||
<UserIcon aria-hidden="true" />
|
||||
Profile
|
||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCardIcon aria-hidden="true" />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
Preferences
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent!">
|
||||
<PaletteIcon aria-hidden="true" />
|
||||
Theme
|
||||
<div className="ml-auto">
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<LogOutIcon aria-hidden="true" />
|
||||
Sign Out
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppShell } from "./components/app-shell"
|
||||
|
||||
export function Page() {
|
||||
return <AppShell />
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { Item, ItemMedia } from "@cfdm/ui/components/item"
|
||||
|
||||
export function AuthLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<Item
|
||||
className={cn(
|
||||
"p-0",
|
||||
"bg-primary text-primary-foreground flex size-8 shrink-0 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<svg
|
||||
width="50"
|
||||
height="50"
|
||||
viewBox="25.668 25.1352 49.6644 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-4"
|
||||
>
|
||||
<circle cx="70.634" cy="29.8334" r="4.69799" fill="currentColor" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M25.668 57.0144V29.8332C25.668 27.2386 27.7713 25.1352 30.366 25.1352C32.9606 25.1352 35.0639 27.2386 35.0639 29.8332V57.0144C35.0639 61.833 38.9702 65.7392 43.7888 65.7392H57.2116C62.0302 65.7392 65.9364 61.833 65.9364 57.0144V43.7258C65.9364 41.1312 68.0398 39.0278 70.6344 39.0278C73.229 39.0278 75.3324 41.1312 75.3324 43.7258V57.0144C75.3324 67.0222 67.2194 75.1352 57.2116 75.1352H43.7888C33.7809 75.1352 25.668 67.0222 25.668 57.0144Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { type ComponentProps } from "react"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { AUTH13_SIDEBAR_IMAGE_DARK, AUTH13_SIDEBAR_IMAGE_LIGHT } from "./data"
|
||||
import { LoginForm } from "./login-form"
|
||||
|
||||
type FormSubmitHandler = NonNullable<ComponentProps<"form">["onSubmit"]>
|
||||
type FormSubmitEvent = Parameters<FormSubmitHandler>[0]
|
||||
|
||||
function SidebarFrame() {
|
||||
return (
|
||||
<Frame
|
||||
spacing="lg"
|
||||
className="border-border/70 h-full w-full bg-transparent"
|
||||
>
|
||||
{/* Content */}
|
||||
<FramePanel className="border-border/70 h-full min-h-[32rem] overflow-hidden p-0 shadow-none before:hidden">
|
||||
{/* Light */}
|
||||
<img
|
||||
src={AUTH13_SIDEBAR_IMAGE_LIGHT}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover dark:hidden"
|
||||
/>
|
||||
{/* Dark */}
|
||||
<img
|
||||
src={AUTH13_SIDEBAR_IMAGE_DARK}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="hidden h-full w-full object-cover dark:block"
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export function Auth() {
|
||||
function handleSubmit(event: FormSubmitEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-svh w-full lg:h-svh lg:overflow-hidden">
|
||||
{/* Grid */}
|
||||
<div className="grid min-h-svh w-full gap-8 px-4 py-4 sm:px-6 sm:py-6 lg:h-svh lg:min-h-0 lg:grid-cols-[32rem_minmax(0,1fr)] lg:gap-8 lg:px-0 lg:py-0">
|
||||
<section className="order-2 flex min-h-[calc(100svh-2rem)] items-center justify-center px-4 py-8 sm:px-8 lg:order-2 lg:h-full lg:min-h-0 lg:px-12 lg:py-0 xl:px-16 2xl:px-20">
|
||||
<LoginForm onSubmit={handleSubmit} />
|
||||
</section>
|
||||
|
||||
<aside className="order-1 flex min-h-full items-stretch justify-start lg:order-1 lg:h-full lg:min-h-0 lg:w-full lg:self-stretch lg:justify-self-start lg:py-7 lg:pl-7">
|
||||
<div className="w-full lg:sticky lg:top-7 lg:h-[calc(100svh-3.5rem)]">
|
||||
<SidebarFrame />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { Apple } from "@cfdm/ui/components/svgs/apple"
|
||||
import { AppleDark } from "@cfdm/ui/components/svgs/appleDark"
|
||||
import { GithubDark } from "@cfdm/ui/components/svgs/githubDark"
|
||||
import { GithubLight } from "@cfdm/ui/components/svgs/githubLight"
|
||||
import { Google } from "@cfdm/ui/components/svgs/google"
|
||||
|
||||
export type AuthProvider = {
|
||||
id: string
|
||||
label: string
|
||||
logo: ReactNode
|
||||
}
|
||||
|
||||
function ThemeLogo({ light, dark }: { light: ReactNode; dark: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<span aria-hidden="true" className="dark:hidden">
|
||||
{light}
|
||||
</span>
|
||||
<span aria-hidden="true" className="hidden dark:block">
|
||||
{dark}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const AUTH13_SOCIAL_PROVIDERS: AuthProvider[] = [
|
||||
{
|
||||
id: "google",
|
||||
label: "Continue with Google",
|
||||
logo: <Google aria-hidden="true" data-icon="inline-start" />,
|
||||
},
|
||||
{
|
||||
id: "apple",
|
||||
label: "Continue with Apple",
|
||||
logo: (
|
||||
<ThemeLogo
|
||||
light={<Apple aria-hidden="true" data-icon="inline-start" />}
|
||||
dark={<AppleDark aria-hidden="true" data-icon="inline-start" />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
label: "Continue with GitHub",
|
||||
logo: (
|
||||
<ThemeLogo
|
||||
light={<GithubLight aria-hidden="true" data-icon="inline-start" />}
|
||||
dark={<GithubDark aria-hidden="true" data-icon="inline-start" />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export const AUTH13_SIDEBAR_IMAGE_LIGHT =
|
||||
"https://images.unsplash.com/photo-1556139943-4bdca53adf1e?auto=format&fit=crop&w=1200&h=1800&q=80"
|
||||
|
||||
export const AUTH13_SIDEBAR_IMAGE_DARK =
|
||||
"https://images.unsplash.com/photo-1709990740078-05aa8ee5b9b7?auto=format&fit=crop&w=1200&h=1800&q=80"
|
||||
@@ -0,0 +1,86 @@
|
||||
import { type ComponentProps } from "react"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@cfdm/ui/components/field"
|
||||
import { Input } from "@cfdm/ui/components/input"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import { AuthLogo } from "./auth-logo"
|
||||
import { AUTH13_SOCIAL_PROVIDERS } from "./data"
|
||||
import { ArrowRightIcon } from "lucide-react"
|
||||
|
||||
type FormSubmitHandler = NonNullable<ComponentProps<"form">["onSubmit"]>
|
||||
type FormSubmitEvent = Parameters<FormSubmitHandler>[0]
|
||||
|
||||
export function LoginForm({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (event: FormSubmitEvent) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[22rem] flex-col gap-8">
|
||||
{/* Heading */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<AuthLogo />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl leading-tight font-semibold text-balance">
|
||||
Sign in
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-base text-pretty">
|
||||
Enter your work email to get a secure magic link.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form className="flex flex-col gap-5" onSubmit={onSubmit}>
|
||||
<FieldGroup className="gap-4">
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="auth-13-email">Work email</FieldLabel>
|
||||
<Input
|
||||
id="auth-13-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="[email protected]"
|
||||
className="bg-background"
|
||||
/>
|
||||
<FieldDescription>
|
||||
Your sign-in link stays active for 15 minutes.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
Send magic link
|
||||
<ArrowRightIcon aria-hidden="true" data-icon="inline-end" />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">Or continue with</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid gap-3">
|
||||
{AUTH13_SOCIAL_PROVIDERS.map((provider) => (
|
||||
<Button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-center px-4 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
{provider.logo}
|
||||
{provider.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useId, type ComponentProps } from "react"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
export interface NoiseTextureProps extends ComponentProps<"svg"> {
|
||||
className?: string
|
||||
frequency?: number
|
||||
octaves?: number
|
||||
slope?: number
|
||||
noiseOpacity?: number
|
||||
}
|
||||
|
||||
export const NoiseTexture = ({
|
||||
className,
|
||||
frequency = 0.4,
|
||||
octaves = 6,
|
||||
slope = 0.15,
|
||||
noiseOpacity = 0.6,
|
||||
...props
|
||||
}: NoiseTextureProps) => {
|
||||
const filterId = useId()
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 z-0 h-full w-full opacity-50 select-none dark:opacity-[0.75]",
|
||||
className
|
||||
)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<filter id={filterId}>
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency={frequency}
|
||||
numOctaves={octaves}
|
||||
stitchTiles="stitch"
|
||||
/>
|
||||
<feColorMatrix type="saturate" values="0" />
|
||||
<feComponentTransfer>
|
||||
<feFuncR type="linear" slope={slope} />
|
||||
<feFuncG type="linear" slope={slope} />
|
||||
<feFuncB type="linear" slope={slope} />
|
||||
</feComponentTransfer>
|
||||
</filter>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
filter={`url(#${filterId})`}
|
||||
opacity={noiseOpacity}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Auth } from "./components/auth"
|
||||
import { NoiseTexture } from "./components/noise-texture"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="bg-background relative min-h-svh w-full overflow-hidden">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<NoiseTexture
|
||||
className="text-foreground/[0.015] dark:text-foreground/[0.03]"
|
||||
frequency={0.5}
|
||||
octaves={5}
|
||||
slope={0.08}
|
||||
noiseOpacity={0.28}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 min-h-svh w-full">
|
||||
<Auth />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { CardItem } from "./card-item"
|
||||
import { CARDS } from "./data"
|
||||
|
||||
export function CardGrid() {
|
||||
return (
|
||||
<div className="@container w-full">
|
||||
{/* Grid */}
|
||||
<div className="grid gap-5 @2xl:grid-cols-3">
|
||||
{CARDS.map((card) => (
|
||||
<CardItem key={card.label} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
} from "@/components/reui/frame"
|
||||
import { ICard } from "./data"
|
||||
import { LinkIcon } from "lucide-react"
|
||||
|
||||
export function CardItem({ card }: { card: ICard }) {
|
||||
return (
|
||||
<Frame spacing="sm">
|
||||
{/* Header */}
|
||||
<FrameHeader className="px-1! py-1!">
|
||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||
{card.icon}
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
{/* Content */}
|
||||
<FramePanel className="space-y-3.5">
|
||||
<p className="text-xs leading-relaxed">{card.description}</p>
|
||||
<a
|
||||
href="#"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
<LinkIcon aria-hidden="true" className="size-2.5 shrink-0" />
|
||||
{card.link}
|
||||
</a>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { PackageIcon, TrendingUp, MapPinIcon } from "lucide-react"
|
||||
|
||||
export interface ICard {
|
||||
label: string
|
||||
icon: ReactNode
|
||||
description: string
|
||||
link: string
|
||||
}
|
||||
|
||||
export const CARDS: ICard[] = [
|
||||
{
|
||||
label: "Binance",
|
||||
icon: (
|
||||
<PackageIcon aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Track trading volumes, liquidity shifts, and price movements for informed decisions",
|
||||
link: "https://www.binance.com/en/markets/over..",
|
||||
},
|
||||
{
|
||||
label: "Revenue",
|
||||
icon: (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Get instant insights into earnings and cash flow performance.",
|
||||
link: "https://nexo.io/earn/crypto-detailed-portfol..",
|
||||
},
|
||||
{
|
||||
label: "Shipments",
|
||||
icon: (
|
||||
<MapPinIcon aria-hidden="true" />
|
||||
),
|
||||
description:
|
||||
"Stay on top of deliveries and track shipment statuses efficiently.",
|
||||
link: "https://www.educare.io/platform/analytics/e..",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { CardGrid } from "./components/card-grid"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full max-w-5xl items-center justify-center p-6">
|
||||
<CardGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Cell, Pie, PieChart } from "recharts"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarImage,
|
||||
} from "@cfdm/ui/components/avatar"
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@cfdm/ui/components/chart"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@cfdm/ui/components/tabs"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@cfdm/ui/components/tooltip"
|
||||
import {
|
||||
allocationMemberCount,
|
||||
allocationMembers,
|
||||
allocationPeriods,
|
||||
inflowChartConfig,
|
||||
inflowPeriods,
|
||||
SEGMENT_COUNT,
|
||||
type AllocationPeriod,
|
||||
type InflowFund,
|
||||
type InflowPeriod,
|
||||
} from "./data"
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
const segments = Array.from({ length: SEGMENT_COUNT }, (_, index) => index)
|
||||
|
||||
const CHART_REVEAL_STYLE = `
|
||||
@keyframes dashboard-1-flow-reveal-up {
|
||||
from {
|
||||
clip-path: inset(100% 0 0 0);
|
||||
opacity: 0.75;
|
||||
}
|
||||
to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-1-flow-reveal-up {
|
||||
animation: dashboard-1-flow-reveal-up 680ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-1-flow-reveal-up {
|
||||
animation: none;
|
||||
clip-path: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
function AllocationMeter({ period }: { period: AllocationPeriod }) {
|
||||
return (
|
||||
<div
|
||||
aria-label={`${period.label} capacity allocation is ${period.allocation}`}
|
||||
className="flex h-7 w-full items-stretch justify-between"
|
||||
role="img"
|
||||
>
|
||||
{segments.map((segment) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
key={segment}
|
||||
className={cn(
|
||||
"h-full w-1 shrink-0 rounded-full",
|
||||
segment < period.filledSegments ? "bg-success" : "bg-muted"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberStack() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<AvatarGroup className="-space-x-2">
|
||||
{allocationMembers.map((member) => (
|
||||
<Avatar key={member.name} className="size-6">
|
||||
{member.avatar ? (
|
||||
<AvatarImage src={member.avatar} alt={member.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="bg-background text-xs font-medium">
|
||||
{member.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{allocationMemberCount} Members
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AllocationChart() {
|
||||
return (
|
||||
<Frame className="@container h-full w-full">
|
||||
<FramePanel>
|
||||
<Tabs
|
||||
defaultValue={allocationPeriods[0].value}
|
||||
className="h-full w-full min-w-0 gap-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h2 className="text-sm font-medium">Capacity Allocation</h2>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground/70 hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex shrink-0 rounded-full p-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label="Capacity Allocation info"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="max-w-56 px-2.5 py-1.5 text-xs leading-5"
|
||||
>
|
||||
Fulfillment capacity by selected period.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<TabsList>
|
||||
{allocationPeriods.map((period) => (
|
||||
<TabsTrigger key={period.value} value={period.value}>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{allocationPeriods.map((period) => (
|
||||
<TabsContent
|
||||
key={period.value}
|
||||
value={period.value}
|
||||
className="mt-0"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Metric */}
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="text-[26px] font-medium">
|
||||
{period.allocation}
|
||||
</span>
|
||||
<span className="text-success text-xs font-medium">
|
||||
{period.delta}
|
||||
</span>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
{period.comparison}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<AllocationMeter period={period} />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
|
||||
<p>
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
Queued Orders:
|
||||
</span>{" "}
|
||||
<span className="text-sm font-medium">
|
||||
{period.exposure}
|
||||
</span>
|
||||
</p>
|
||||
<MemberStack />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
type DonutSlice =
|
||||
| InflowFund
|
||||
| {
|
||||
key: "reserve"
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
function getDonutData(period: InflowPeriod) {
|
||||
const trackedShare = period.funds.reduce(
|
||||
(total, fund) => total + fund.share,
|
||||
0
|
||||
)
|
||||
const reserveShare = Math.max(100 - trackedShare, 0)
|
||||
|
||||
return [
|
||||
...period.funds,
|
||||
{
|
||||
key: "reserve",
|
||||
name: "Reserve Capacity",
|
||||
amount: "",
|
||||
share: reserveShare,
|
||||
color: "var(--muted)",
|
||||
fill: "var(--color-reserve)",
|
||||
},
|
||||
] satisfies DonutSlice[]
|
||||
}
|
||||
|
||||
function ChartTooltipFormatter(item: unknown) {
|
||||
const fund = item as DonutSlice
|
||||
const value = fund.key === "reserve" ? `${fund.share}%` : fund.amount
|
||||
|
||||
return (
|
||||
<div className="flex min-w-40 items-center justify-between gap-6">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground truncate">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-foreground font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoTooltip() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="About Decision Flow"
|
||||
className="text-muted-foreground/70 -my-1"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<InfoIcon aria-hidden="true" className="text-sm" data-icon="inline-start" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<p>Tracked decisions entering fulfillment lanes.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowDonut({ period }: { period: InflowPeriod }) {
|
||||
const chartData = getDonutData(period)
|
||||
|
||||
return (
|
||||
<div className="dashboard-1-flow-reveal-up relative size-[8.25rem] shrink-0">
|
||||
<ChartContainer
|
||||
aria-label={`Decision Flow: ${period.total} total for ${period.label}`}
|
||||
className="aspect-square size-[8.25rem]"
|
||||
config={inflowChartConfig}
|
||||
initialDimension={{ width: 132, height: 132 }}
|
||||
>
|
||||
<PieChart margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
wrapperStyle={{ zIndex: 30 }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
hideIndicator
|
||||
formatter={(_value, _name, item) =>
|
||||
ChartTooltipFormatter(item.payload)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="share"
|
||||
endAngle={-230}
|
||||
innerRadius={47}
|
||||
isAnimationActive={false}
|
||||
nameKey="name"
|
||||
outerRadius={62}
|
||||
paddingAngle={1}
|
||||
cornerRadius={3}
|
||||
startAngle={130}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{chartData.map((item) => (
|
||||
<Cell key={item.key} fill={item.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="bg-background/90 border-border/70 flex size-[5.25rem] flex-col items-center justify-center rounded-full border border-dashed">
|
||||
<span className="text-muted-foreground/70 text-xs">Flow</span>
|
||||
<span className="mt-0.5 text-sm font-semibold">{period.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowLegend({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<ul className="flex min-w-0 flex-1 flex-col">
|
||||
{period.funds.map((fund, index) => (
|
||||
<li key={fund.key}>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
|
||||
style={{ backgroundColor: fund.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium">{fund.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{fund.amount}</span>
|
||||
<span className="text-muted-foreground/70 w-8 text-right text-xs">
|
||||
{fund.share}%
|
||||
</span>
|
||||
</div>
|
||||
{index < period.funds.length - 1 ? (
|
||||
<Separator className="w-auto" />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowPeriodPanel({ period }: { period: InflowPeriod }) {
|
||||
return (
|
||||
<div className="grid gap-6 @sm:grid-cols-[8.25rem_minmax(0,1fr)] @sm:items-center">
|
||||
<InflowDonut period={period} />
|
||||
<InflowLegend period={period} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InflowChart() {
|
||||
return (
|
||||
<TooltipProvider delay={150}>
|
||||
<style>{CHART_REVEAL_STYLE}</style>
|
||||
<Frame className="@container h-full w-full">
|
||||
<FramePanel className="ps-3.5! pe-5! pt-5! pb-3.5!">
|
||||
<Tabs defaultValue="week" className="gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-0.5 ps-1.5">
|
||||
<h2 className="text-sm font-medium">Decision Flow</h2>
|
||||
<InfoTooltip />
|
||||
</div>
|
||||
|
||||
<TabsList className="w-full @sm:w-auto">
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsTrigger key={period.value} value={period.value}>
|
||||
{period.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{inflowPeriods.map((period) => (
|
||||
<TabsContent
|
||||
key={period.value}
|
||||
value={period.value}
|
||||
className="mt-0"
|
||||
>
|
||||
<InflowPeriodPanel period={period} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<div className="@container grid h-full w-full min-w-0 auto-rows-fr gap-3">
|
||||
<AllocationChart />
|
||||
<InflowChart />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { Item, ItemMedia } from "@cfdm/ui/components/item"
|
||||
|
||||
import { FULFILLMENT_CARDS, type FulfillmentCard } from "./data"
|
||||
|
||||
function CardItem({ card }: { card: FulfillmentCard }) {
|
||||
return (
|
||||
<FramePanel>
|
||||
{/* Heading */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Item
|
||||
className={cn(
|
||||
"p-0",
|
||||
"border-background flex size-10 items-center justify-center border-2 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white",
|
||||
card.iconBg
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.typeLabel}
|
||||
</p>
|
||||
<h3 className="text-sm leading-tight font-medium">{card.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-1.5">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.metricLabel}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xl font-medium tracking-tight">
|
||||
{card.balance}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
card.change.positive ? "text-teal-600" : "text-rose-600"
|
||||
)}
|
||||
>
|
||||
{card.change.percent} ({card.change.amount})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chart() {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
{/* Grid */}
|
||||
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
|
||||
{FULFILLMENT_CARDS.map((card) => (
|
||||
<CardItem key={card.title} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameFooter,
|
||||
FramePanel,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { Progress } from "@cfdm/ui/components/progress"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@cfdm/ui/components/select"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import {
|
||||
PERFORMANCE_RANGE_OPTIONS,
|
||||
SHIFT_ACTIVITY,
|
||||
SHIFT_PERFORMANCE,
|
||||
SHIFT_PIPELINE_PROGRESS,
|
||||
} from "./data"
|
||||
import { TrendingUp, TrendingDown, CircleCheckIcon } from "lucide-react"
|
||||
|
||||
export function InvestorCard() {
|
||||
return (
|
||||
<Frame className="h-full w-full">
|
||||
{/* Content */}
|
||||
<FramePanel>
|
||||
<div className="mb-6 flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h3 className="text-base font-semibold">Shift Performance</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select defaultValue="today" items={PERFORMANCE_RANGE_OPTIONS}>
|
||||
<SelectTrigger className="h-8! w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
align="start"
|
||||
alignItemWithTrigger={false}
|
||||
className="w-28"
|
||||
>
|
||||
{PERFORMANCE_RANGE_OPTIONS.map((range) => (
|
||||
<SelectItem key={range.value} value={range.value}>
|
||||
{range.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SHIFT_PERFORMANCE.map((item) => (
|
||||
<div
|
||||
className="flex flex-col items-start justify-start"
|
||||
key={item.label}
|
||||
>
|
||||
<div className="text-foreground text-xl font-bold">
|
||||
{item.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
{item.label}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 text-xs font-semibold [&_svg]:h-3 [&_svg]:w-3",
|
||||
item.trend === "positive"
|
||||
? "text-emerald-500"
|
||||
: "text-destructive"
|
||||
)}
|
||||
>
|
||||
{item.trend === "positive" ? (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
) : (
|
||||
<TrendingDown aria-hidden="true" />
|
||||
)}
|
||||
{item.delta}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
Pipeline Progress
|
||||
</span>
|
||||
<span className="text-foreground text-xs font-semibold">
|
||||
{SHIFT_PIPELINE_PROGRESS}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={SHIFT_PIPELINE_PROGRESS}
|
||||
className="h-1! **:data-[slot=progress-track]:h-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="text-foreground mb-2.5 text-sm font-medium">
|
||||
Recent Activity
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{SHIFT_ACTIVITY.map((activity) => (
|
||||
<li
|
||||
key={activity.id}
|
||||
className="flex items-center justify-between gap-2.5 text-sm"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<CircleCheckIcon className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
activity.tone === "success" && "text-emerald-500",
|
||||
activity.tone === "info" && "text-sky-500",
|
||||
activity.tone === "warning" && "text-amber-500"
|
||||
)} aria-hidden="true" />
|
||||
<span className="text-foreground truncate text-xs">
|
||||
{activity.title}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
activity.tone === "success"
|
||||
? "success-light"
|
||||
: activity.tone === "info"
|
||||
? "info-light"
|
||||
: "warning-light"
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
{activity.status}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
{/* Footer */}
|
||||
<FrameFooter className="flex-row items-center gap-2.5 p-2!">
|
||||
<Button variant="outline" className="flex-1">
|
||||
Schedule
|
||||
</Button>
|
||||
<Button className="flex-1">Full Report</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { Chart as CapacityChart } from "./capacity-chart"
|
||||
import { Chart as ChartCards } from "./chart-cards"
|
||||
import { InvestorCard as CommanderCard } from "./commander-card"
|
||||
import { ExceptionGrid } from "./exception-grid"
|
||||
import { Navbar } from "./navbar"
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-2">
|
||||
<Navbar />
|
||||
|
||||
<section aria-label="Fulfillment metrics">
|
||||
<ChartCards />
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Fulfillment operations"
|
||||
className="grid min-w-0 items-stretch gap-3 @5xl:grid-cols-2"
|
||||
>
|
||||
<div className="flex min-w-0">
|
||||
<CommanderCard />
|
||||
</div>
|
||||
<div className="flex min-w-0">
|
||||
<CapacityChart />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="Fulfillment exception queue">
|
||||
<ExceptionGrid />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { type BadgeProps } from "@/components/reui/badge"
|
||||
|
||||
import { type ChartConfig } from "@cfdm/ui/components/chart"
|
||||
import { PackageIcon, TruckIcon, TriangleAlertIcon, BotIcon } from "lucide-react"
|
||||
|
||||
export type FulfillmentStatus = "On Time" | "At Risk" | "Delayed" | "Blocked"
|
||||
export type AutomationLevel = "Autopilot" | "Copilot" | "Manual"
|
||||
|
||||
export interface TeamMember {
|
||||
name: string
|
||||
initials: string
|
||||
avatar: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface FulfillmentException {
|
||||
id: string
|
||||
reference: string
|
||||
customer: string
|
||||
email: string
|
||||
avatar: string
|
||||
initials: string
|
||||
lane: string
|
||||
facility: string
|
||||
stage: string
|
||||
promise: string
|
||||
slaMinutes: number
|
||||
automation: AutomationLevel
|
||||
owner: string
|
||||
units: number
|
||||
value: number
|
||||
risk: string
|
||||
status: FulfillmentStatus
|
||||
}
|
||||
|
||||
export const STATUS_ORDER: FulfillmentStatus[] = [
|
||||
"On Time",
|
||||
"At Risk",
|
||||
"Delayed",
|
||||
"Blocked",
|
||||
]
|
||||
|
||||
export const STATUS_BADGE_VARIANT: Record<
|
||||
FulfillmentStatus,
|
||||
BadgeProps["variant"]
|
||||
> = {
|
||||
"On Time": "success-outline",
|
||||
"At Risk": "warning-outline",
|
||||
Delayed: "info-outline",
|
||||
Blocked: "destructive-outline",
|
||||
}
|
||||
|
||||
export const AUTOMATION_BADGE_VARIANT: Record<
|
||||
AutomationLevel,
|
||||
BadgeProps["variant"]
|
||||
> = {
|
||||
Autopilot: "success-light",
|
||||
Copilot: "info-light",
|
||||
Manual: "warning-light",
|
||||
}
|
||||
|
||||
export const NAV_MEMBERS: TeamMember[] = [
|
||||
{
|
||||
name: "Maya Singh",
|
||||
initials: "MS",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
role: "Fulfillment lead",
|
||||
},
|
||||
{
|
||||
name: "Leo Martins",
|
||||
initials: "LM",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
role: "Automation owner",
|
||||
},
|
||||
{
|
||||
name: "Nora Albright",
|
||||
initials: "NA",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
||||
role: "Capacity planner",
|
||||
},
|
||||
]
|
||||
|
||||
export const TEAM_MEMBERS = NAV_MEMBERS.map((member) => ({
|
||||
src: member.avatar,
|
||||
initials: member.initials,
|
||||
name: member.name,
|
||||
}))
|
||||
|
||||
export const TEAM_EXTRA_COUNT = 11
|
||||
|
||||
export interface FulfillmentCardChange {
|
||||
positive: boolean
|
||||
percent: string
|
||||
amount: string
|
||||
}
|
||||
|
||||
export interface FulfillmentCard {
|
||||
typeLabel: string
|
||||
title: string
|
||||
metricLabel: string
|
||||
balance: string
|
||||
change: FulfillmentCardChange
|
||||
icon: ReactNode
|
||||
iconBg: string
|
||||
}
|
||||
|
||||
export const FULFILLMENT_CARDS: FulfillmentCard[] = [
|
||||
{
|
||||
typeLabel: "Outbound",
|
||||
title: "Orders Ready",
|
||||
metricLabel: "Ready Volume",
|
||||
balance: "18,420",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+11.8%",
|
||||
amount: "1,946",
|
||||
},
|
||||
iconBg: "bg-neutral-950",
|
||||
icon: (
|
||||
<PackageIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Promise",
|
||||
title: "Same-Day SLA",
|
||||
metricLabel: "Service Level",
|
||||
balance: "94.8%",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+1.2 pts",
|
||||
amount: "shift",
|
||||
},
|
||||
iconBg: "bg-indigo-600",
|
||||
icon: (
|
||||
<TruckIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Inventory",
|
||||
title: "Stock Risk",
|
||||
metricLabel: "Blocked SKUs",
|
||||
balance: "31",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "13 fewer",
|
||||
amount: "since 06:00",
|
||||
},
|
||||
iconBg: "bg-amber-400",
|
||||
icon: (
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
typeLabel: "Policy",
|
||||
title: "AI Autopilot",
|
||||
metricLabel: "Auto Resolved",
|
||||
balance: "71.6%",
|
||||
change: {
|
||||
positive: true,
|
||||
percent: "+8.4 pts",
|
||||
amount: "policy",
|
||||
},
|
||||
iconBg: "bg-cyan-600",
|
||||
icon: (
|
||||
<BotIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export type AllocationPeriod = {
|
||||
value: "week" | "month" | "year"
|
||||
label: string
|
||||
allocation: string
|
||||
delta: string
|
||||
comparison: string
|
||||
exposure: string
|
||||
filledSegments: number
|
||||
}
|
||||
|
||||
export type AllocationMember = {
|
||||
name: string
|
||||
initials: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export const SEGMENT_COUNT = 56
|
||||
export const allocationMemberCount = 6
|
||||
|
||||
export const allocationPeriods: AllocationPeriod[] = [
|
||||
{
|
||||
value: "week",
|
||||
label: "Week",
|
||||
allocation: "86%",
|
||||
delta: "+5.8%",
|
||||
comparison: "vs labor plan",
|
||||
exposure: "3,840 orders",
|
||||
filledSegments: 48,
|
||||
},
|
||||
{
|
||||
value: "month",
|
||||
label: "Month",
|
||||
allocation: "79%",
|
||||
delta: "+2.4%",
|
||||
comparison: "vs prior month",
|
||||
exposure: "18 priority lanes",
|
||||
filledSegments: 44,
|
||||
},
|
||||
{
|
||||
value: "year",
|
||||
label: "Year",
|
||||
allocation: "74%",
|
||||
delta: "+9.2%",
|
||||
comparison: "automation lift",
|
||||
exposure: "6 facilities",
|
||||
filledSegments: 41,
|
||||
},
|
||||
]
|
||||
|
||||
export const allocationMembers: AllocationMember[] = TEAM_MEMBERS.map(
|
||||
(member) => ({
|
||||
name: member.name,
|
||||
initials: member.initials,
|
||||
avatar: member.src,
|
||||
})
|
||||
)
|
||||
|
||||
export type PerformanceTrend = "positive" | "negative"
|
||||
export type ActivityTone = "success" | "info" | "warning"
|
||||
|
||||
export interface PerformanceMetric {
|
||||
label: string
|
||||
value: string
|
||||
trend: PerformanceTrend
|
||||
delta: string
|
||||
}
|
||||
|
||||
export interface ShiftActivity {
|
||||
id: string
|
||||
title: string
|
||||
time: string
|
||||
status: string
|
||||
tone: ActivityTone
|
||||
}
|
||||
|
||||
export const PERFORMANCE_RANGE_OPTIONS = [
|
||||
{ label: "Today", value: "today" },
|
||||
{ label: "Week", value: "week" },
|
||||
{ label: "Month", value: "month" },
|
||||
]
|
||||
|
||||
export const SHIFT_PERFORMANCE: PerformanceMetric[] = [
|
||||
{
|
||||
label: "Orders Cleared",
|
||||
value: "18.4k",
|
||||
trend: "positive",
|
||||
delta: "+11.8%",
|
||||
},
|
||||
{
|
||||
label: "SLA Recovery",
|
||||
value: "94.8%",
|
||||
trend: "positive",
|
||||
delta: "+1.2 pts",
|
||||
},
|
||||
{
|
||||
label: "Risk Exposure",
|
||||
value: "$128k",
|
||||
trend: "negative",
|
||||
delta: "-9.4%",
|
||||
},
|
||||
]
|
||||
|
||||
export const SHIFT_PIPELINE_PROGRESS = 76
|
||||
|
||||
export const SHIFT_ACTIVITY: ShiftActivity[] = [
|
||||
{
|
||||
id: "wave-release",
|
||||
title: "Released priority wave to dock B",
|
||||
time: "4 min ago",
|
||||
status: "Cleared",
|
||||
tone: "success",
|
||||
},
|
||||
{
|
||||
id: "carrier-reprice",
|
||||
title: "Carrier mix repriced for zone 6",
|
||||
time: "12 min ago",
|
||||
status: "Review",
|
||||
tone: "info",
|
||||
},
|
||||
{
|
||||
id: "inventory-hold",
|
||||
title: "Inventory hold isolated to 3 SKUs",
|
||||
time: "23 min ago",
|
||||
status: "Watch",
|
||||
tone: "warning",
|
||||
},
|
||||
]
|
||||
|
||||
export type InflowFundKey = "autopilot" | "copilot" | "manual" | "reserve"
|
||||
|
||||
export interface InflowFund {
|
||||
key: Exclude<InflowFundKey, "reserve">
|
||||
name: string
|
||||
amount: string
|
||||
share: number
|
||||
color: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
export interface InflowPeriod {
|
||||
value: "week" | "month" | "year"
|
||||
label: string
|
||||
total: string
|
||||
headline: string
|
||||
description: string
|
||||
delta: string
|
||||
funds: InflowFund[]
|
||||
}
|
||||
|
||||
const inflowAutopilotColor = "oklch(0.62 0.19 149)"
|
||||
const inflowCopilotColor = "oklch(0.58 0.18 257)"
|
||||
const inflowManualColor = "oklch(0.72 0.16 78)"
|
||||
|
||||
export const inflowChartConfig = {
|
||||
flow: {
|
||||
label: "Flow",
|
||||
},
|
||||
autopilot: {
|
||||
label: "Autopilot",
|
||||
color: inflowAutopilotColor,
|
||||
},
|
||||
copilot: {
|
||||
label: "Copilot",
|
||||
color: inflowCopilotColor,
|
||||
},
|
||||
manual: {
|
||||
label: "Manual",
|
||||
color: inflowManualColor,
|
||||
},
|
||||
reserve: {
|
||||
label: "Reserve",
|
||||
color: "oklch(0.7 0.04 260)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const inflowPeriods: InflowPeriod[] = [
|
||||
{
|
||||
value: "week",
|
||||
label: "Week",
|
||||
total: "18.4k",
|
||||
headline: "Exception Flow",
|
||||
description: "Orders entering decision lanes",
|
||||
delta: "+6.2%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "9.1k",
|
||||
share: 49.5,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "5.2k",
|
||||
share: 28.3,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "2.8k",
|
||||
share: 15.2,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "month",
|
||||
label: "Month",
|
||||
total: "76.8k",
|
||||
headline: "Resolved Flow",
|
||||
description: "Completed decisions this month",
|
||||
delta: "+14.8%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "41.6k",
|
||||
share: 54.2,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "20.3k",
|
||||
share: 26.4,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "9.8k",
|
||||
share: 12.8,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "year",
|
||||
label: "Year",
|
||||
total: "812k",
|
||||
headline: "Network Flow",
|
||||
description: "Decisions across six facilities",
|
||||
delta: "+21.5%",
|
||||
funds: [
|
||||
{
|
||||
key: "autopilot",
|
||||
name: "Autopilot",
|
||||
amount: "428k",
|
||||
share: 52.7,
|
||||
color: inflowAutopilotColor,
|
||||
fill: "var(--color-autopilot)",
|
||||
},
|
||||
{
|
||||
key: "copilot",
|
||||
name: "Copilot",
|
||||
amount: "224k",
|
||||
share: 27.6,
|
||||
color: inflowCopilotColor,
|
||||
fill: "var(--color-copilot)",
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
name: "Manual",
|
||||
amount: "103k",
|
||||
share: 12.7,
|
||||
color: inflowManualColor,
|
||||
fill: "var(--color-manual)",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const FULFILLMENT_ROWS: FulfillmentException[] = [
|
||||
{
|
||||
id: "row-1001",
|
||||
reference: "NSC-84721",
|
||||
customer: "Avery Outdoor",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&dpr=2&q=80",
|
||||
initials: "AO",
|
||||
lane: "Chicago to Austin",
|
||||
facility: "ORD-2",
|
||||
stage: "Carrier tender",
|
||||
promise: "Today 18:00",
|
||||
slaMinutes: 42,
|
||||
automation: "Copilot",
|
||||
owner: "Maya Singh",
|
||||
units: 480,
|
||||
value: 38240,
|
||||
risk: "Carrier capacity is tight after midday cutoff",
|
||||
status: "At Risk",
|
||||
},
|
||||
{
|
||||
id: "row-1002",
|
||||
reference: "NSC-84734",
|
||||
customer: "Field & Frame",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
|
||||
initials: "FF",
|
||||
lane: "Dallas to Phoenix",
|
||||
facility: "DFW-1",
|
||||
stage: "Pick wave",
|
||||
promise: "Today 16:30",
|
||||
slaMinutes: 88,
|
||||
automation: "Autopilot",
|
||||
owner: "Leo Martins",
|
||||
units: 310,
|
||||
value: 21480,
|
||||
risk: "Wave optimized by carton density",
|
||||
status: "On Time",
|
||||
},
|
||||
{
|
||||
id: "row-1003",
|
||||
reference: "NSC-84755",
|
||||
customer: "MetroFit Labs",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519345182560-3f2917c472ef?w=96&h=96&dpr=2&q=80",
|
||||
initials: "ML",
|
||||
lane: "Newark to Boston",
|
||||
facility: "EWR-3",
|
||||
stage: "Inventory hold",
|
||||
promise: "Today 15:15",
|
||||
slaMinutes: -24,
|
||||
automation: "Manual",
|
||||
owner: "Nora Albright",
|
||||
units: 126,
|
||||
value: 18760,
|
||||
risk: "Lot trace requires human release",
|
||||
status: "Blocked",
|
||||
},
|
||||
{
|
||||
id: "row-1004",
|
||||
reference: "NSC-84763",
|
||||
customer: "Northline Studio",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
|
||||
initials: "NS",
|
||||
lane: "Los Angeles to Seattle",
|
||||
facility: "LAX-4",
|
||||
stage: "Packing",
|
||||
promise: "Today 19:45",
|
||||
slaMinutes: 114,
|
||||
automation: "Autopilot",
|
||||
owner: "Leo Martins",
|
||||
units: 840,
|
||||
value: 52210,
|
||||
risk: "Packing line is running above plan",
|
||||
status: "On Time",
|
||||
},
|
||||
{
|
||||
id: "row-1005",
|
||||
reference: "NSC-84801",
|
||||
customer: "Urban Pantry",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?w=96&h=96&dpr=2&q=80",
|
||||
initials: "UP",
|
||||
lane: "Atlanta to Miami",
|
||||
facility: "ATL-2",
|
||||
stage: "Cold chain",
|
||||
promise: "Today 17:00",
|
||||
slaMinutes: 9,
|
||||
automation: "Copilot",
|
||||
owner: "Maya Singh",
|
||||
units: 212,
|
||||
value: 30440,
|
||||
risk: "Reefer handoff needs confirmation",
|
||||
status: "Delayed",
|
||||
},
|
||||
{
|
||||
id: "row-1006",
|
||||
reference: "NSC-84819",
|
||||
customer: "Glow Market",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1489424731084-a5d8b219a5bb?w=96&h=96&dpr=2&q=80",
|
||||
initials: "GM",
|
||||
lane: "Las Vegas to Denver",
|
||||
facility: "LAS-1",
|
||||
stage: "Labeling",
|
||||
promise: "Tomorrow 09:20",
|
||||
slaMinutes: 312,
|
||||
automation: "Autopilot",
|
||||
owner: "Nora Albright",
|
||||
units: 94,
|
||||
value: 10920,
|
||||
risk: "No current risk",
|
||||
status: "On Time",
|
||||
},
|
||||
{
|
||||
id: "row-1007",
|
||||
reference: "NSC-84827",
|
||||
customer: "Ridge Supply",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
||||
initials: "RS",
|
||||
lane: "Portland to San Jose",
|
||||
facility: "PDX-1",
|
||||
stage: "Split shipment",
|
||||
promise: "Today 20:00",
|
||||
slaMinutes: 36,
|
||||
automation: "Copilot",
|
||||
owner: "Maya Singh",
|
||||
units: 176,
|
||||
value: 14680,
|
||||
risk: "Two SKUs short at primary node",
|
||||
status: "At Risk",
|
||||
},
|
||||
{
|
||||
id: "row-1008",
|
||||
reference: "NSC-84842",
|
||||
customer: "Casa Verde",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544725176-7c40e5a71c5e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "CV",
|
||||
lane: "Nashville to Charlotte",
|
||||
facility: "BNA-2",
|
||||
stage: "Dock queue",
|
||||
promise: "Today 14:30",
|
||||
slaMinutes: -51,
|
||||
automation: "Manual",
|
||||
owner: "Nora Albright",
|
||||
units: 265,
|
||||
value: 22750,
|
||||
risk: "Outbound door is constrained",
|
||||
status: "Delayed",
|
||||
},
|
||||
{
|
||||
id: "row-1009",
|
||||
reference: "NSC-84864",
|
||||
customer: "Beacon Cycle",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1552058544-f2b08422138a?w=96&h=96&dpr=2&q=80",
|
||||
initials: "BC",
|
||||
lane: "Columbus to Pittsburgh",
|
||||
facility: "CMH-1",
|
||||
stage: "Fraud review",
|
||||
promise: "Tomorrow 11:45",
|
||||
slaMinutes: 510,
|
||||
automation: "Manual",
|
||||
owner: "Maya Singh",
|
||||
units: 58,
|
||||
value: 8920,
|
||||
risk: "Payment review blocks release",
|
||||
status: "Blocked",
|
||||
},
|
||||
{
|
||||
id: "row-1010",
|
||||
reference: "NSC-84888",
|
||||
customer: "Aster Goods",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?w=96&h=96&dpr=2&q=80",
|
||||
initials: "AG",
|
||||
lane: "Reno to Salt Lake City",
|
||||
facility: "RNO-1",
|
||||
stage: "Manifest",
|
||||
promise: "Today 22:15",
|
||||
slaMinutes: 177,
|
||||
automation: "Autopilot",
|
||||
owner: "Leo Martins",
|
||||
units: 390,
|
||||
value: 19340,
|
||||
risk: "Manifest is ready for carrier scan",
|
||||
status: "On Time",
|
||||
},
|
||||
]
|
||||
|
||||
export function fulfillmentSearchBlob(row: FulfillmentException): string {
|
||||
return [
|
||||
row.reference,
|
||||
row.customer,
|
||||
row.email,
|
||||
row.lane,
|
||||
row.facility,
|
||||
row.stage,
|
||||
row.promise,
|
||||
row.automation,
|
||||
row.owner,
|
||||
row.risk,
|
||||
row.status,
|
||||
String(row.units),
|
||||
String(row.value),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
import { memo } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { type ColumnDef, type Row } from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@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, ItemMedia } from "@cfdm/ui/components/item"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@cfdm/ui/components/tooltip"
|
||||
import {
|
||||
AUTOMATION_BADGE_VARIANT,
|
||||
STATUS_BADGE_VARIANT,
|
||||
type AutomationLevel,
|
||||
type FulfillmentException,
|
||||
type FulfillmentStatus,
|
||||
} from "./data"
|
||||
import { PackageIcon, InfoIcon, MoreHorizontalIcon, EyeIcon, BellIcon, CopyIcon, TriangleAlertIcon } from "lucide-react"
|
||||
|
||||
const currencyCompact = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
|
||||
const numberCompact = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
|
||||
const availabilityColor: Record<FulfillmentStatus, string> = {
|
||||
"On Time": "bg-success",
|
||||
"At Risk": "bg-warning",
|
||||
Delayed: "bg-info",
|
||||
Blocked: "bg-destructive",
|
||||
}
|
||||
|
||||
const stageProgress: Record<string, number> = {
|
||||
"Carrier tender": 72,
|
||||
"Pick wave": 64,
|
||||
"Inventory hold": 28,
|
||||
Packing: 82,
|
||||
"Cold chain": 48,
|
||||
Labeling: 76,
|
||||
"Split shipment": 39,
|
||||
"Dock queue": 31,
|
||||
"Fraud review": 24,
|
||||
Manifest: 90,
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const StatusBadge = memo(function StatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: FulfillmentStatus
|
||||
}) {
|
||||
return (
|
||||
<Badge variant={STATUS_BADGE_VARIANT[status]} className="gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-1.5 rounded-full", availabilityColor[status])}
|
||||
/>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
|
||||
function AutomationBadge({ level }: { level: AutomationLevel }) {
|
||||
return <Badge variant={AUTOMATION_BADGE_VARIANT[level]}>{level}</Badge>
|
||||
}
|
||||
|
||||
const ReferenceCell = memo(function ReferenceCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<a
|
||||
href="#"
|
||||
className="text-primary truncate text-sm font-medium underline-offset-2 transition-colors hover:underline"
|
||||
aria-label={`View order ${order.reference}`}
|
||||
>
|
||||
{order.reference}
|
||||
</a>
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<span className="shrink-0">{order.facility}</span>
|
||||
<DotSeparator />
|
||||
<span className="truncate">{order.owner}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const CustomerCell = memo(function CustomerCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={order.avatar} alt={order.customer} />
|
||||
<AvatarFallback>{order.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
|
||||
availabilityColor[order.status]
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<a
|
||||
href="#"
|
||||
className="text-foreground hover:text-primary line-clamp-1 font-medium underline-offset-2 transition-colors hover:underline"
|
||||
aria-label={`View customer ${order.customer}`}
|
||||
>
|
||||
{order.customer}
|
||||
</a>
|
||||
<div
|
||||
className="text-muted-foreground line-clamp-1 text-xs"
|
||||
title={order.email}
|
||||
>
|
||||
{order.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const StageCell = memo(function StageCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
const progress = stageProgress[order.stage] ?? 50
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Item render={<span />} className="w-auto shrink-0 border-0 p-0">
|
||||
<ItemMedia variant="icon" className="text-muted-foreground size-auto">
|
||||
<PackageIcon className="size-4" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-foreground min-w-0 truncate font-medium">
|
||||
{order.stage}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="bg-muted block h-1.5 min-w-16 flex-1 overflow-hidden rounded-full">
|
||||
<span
|
||||
className={cn(
|
||||
"block h-full rounded-full",
|
||||
availabilityColor[order.status]
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const LaneCell = memo(function LaneCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const order = row.original
|
||||
|
||||
return (
|
||||
<div className="flex max-w-full min-w-0 flex-col gap-0.5">
|
||||
<span
|
||||
className="text-foreground block max-w-full min-w-0 truncate font-medium"
|
||||
title={order.lane}
|
||||
>
|
||||
{order.lane}
|
||||
</span>
|
||||
<span
|
||||
className="text-muted-foreground block max-w-full min-w-0 truncate text-xs"
|
||||
title={order.facility}
|
||||
>
|
||||
{order.facility}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const ValueCell = memo(function ValueCell({
|
||||
row,
|
||||
}: {
|
||||
row: Row<FulfillmentException>
|
||||
}) {
|
||||
const valueHint =
|
||||
row.original.value >= 30000 ? "Priority lane" : "Standard lane"
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{currencyCompact.format(row.original.value)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex size-5 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
aria-label={`Value hint for ${row.original.reference}: ${valueHint}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-48 p-2.5 text-xs leading-5">
|
||||
<div className="flex flex-col">
|
||||
<span>{valueHint}</span>
|
||||
<span className="text-background/80">
|
||||
{numberCompact.format(row.original.units)} units
|
||||
</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function StateCell({ row }: { row: Row<FulfillmentException> }) {
|
||||
const sla = row.original.slaMinutes
|
||||
const slaHint =
|
||||
sla < 0
|
||||
? `${Math.abs(sla)} min overdue`
|
||||
: sla <= 45
|
||||
? `${sla} min buffer`
|
||||
: `Due ${row.original.promise}`
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-start gap-1">
|
||||
<StatusBadge status={row.original.status} />
|
||||
<span className="text-muted-foreground max-w-full truncate text-xs">
|
||||
{slaHint}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskCell({ row }: { row: Row<FulfillmentException> }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex items-center gap-1.5 rounded-full focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
aria-label={`Risk note for ${row.original.reference}: ${row.original.risk}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3.5" aria-hidden="true" />
|
||||
<span className="max-w-32 truncate text-xs">{row.original.risk}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs p-3 text-xs leading-5">
|
||||
{row.original.risk}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionsCell({ row }: { row: Row<FulfillmentException> }) {
|
||||
const copyReference = async () => {
|
||||
await navigator.clipboard?.writeText(row.original.reference)
|
||||
toast.success("Reference copied", {
|
||||
description: row.original.reference,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label={`Actions for ${row.original.reference}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-44">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Opening order", {
|
||||
description: row.original.reference,
|
||||
})
|
||||
}
|
||||
>
|
||||
<EyeIcon className="size-4" aria-hidden="true" />
|
||||
View order
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Owner notified", {
|
||||
description: row.original.owner,
|
||||
})
|
||||
}
|
||||
>
|
||||
<BellIcon className="size-4" aria-hidden="true" />
|
||||
Notify owner
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyReference}>
|
||||
<CopyIcon className="size-4" aria-hidden="true" />
|
||||
Copy reference
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
toast.warning("Escalation staged", {
|
||||
description: "Connect this action to your incident workflow.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<TriangleAlertIcon className="size-4" aria-hidden="true" />
|
||||
Escalate
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<FulfillmentException>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
id: "id",
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
enableSorting: false,
|
||||
size: 35,
|
||||
enableResizing: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
headerClassName: "ps-4!",
|
||||
cellClassName: "ps-4!",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "reference",
|
||||
id: "reference",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ReferenceCell row={row} />,
|
||||
size: 138,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Order",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "customer",
|
||||
id: "customer",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <CustomerCell row={row} />,
|
||||
size: 210,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: true,
|
||||
minSize: 190,
|
||||
meta: {
|
||||
headerTitle: "Customer",
|
||||
autoSize: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "lane",
|
||||
id: "lane",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <LaneCell row={row} />,
|
||||
size: 165,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Lane",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "stage",
|
||||
id: "stage",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <StageCell row={row} />,
|
||||
size: 160,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Stage",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "automation",
|
||||
id: "automation",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <AutomationBadge level={row.original.automation} />,
|
||||
size: 112,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Automation",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
id: "value",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <ValueCell row={row} />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Value",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "risk",
|
||||
id: "risk",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <RiskCell row={row} />,
|
||||
size: 170,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Risk",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <StateCell row={row} />,
|
||||
size: 142,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "State",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => <ActionsCell row={row} />,
|
||||
size: 46,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGrid as ReuiDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { Checkbox } from "@cfdm/ui/components/checkbox"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@cfdm/ui/components/dropdown-menu"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@cfdm/ui/components/input-group"
|
||||
import { Label } from "@cfdm/ui/components/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@cfdm/ui/components/popover"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import { TooltipProvider } from "@cfdm/ui/components/tooltip"
|
||||
import {
|
||||
FULFILLMENT_ROWS,
|
||||
fulfillmentSearchBlob,
|
||||
STATUS_ORDER,
|
||||
type FulfillmentStatus,
|
||||
} from "./data"
|
||||
import { columns, StatusBadge } from "./exception-columns"
|
||||
import { SearchIcon, XIcon, FilterIcon, MoreHorizontalIcon, FileDownIcon, RefreshCwIcon, SettingsIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
interface ToolbarProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (value: string) => void
|
||||
selectedStatuses: FulfillmentStatus[]
|
||||
onStatusChange: (checked: boolean, status: FulfillmentStatus) => void
|
||||
onClearFilters: () => void
|
||||
hasActiveFilters: boolean
|
||||
statusCounts: Record<string, number>
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
selectedStatuses,
|
||||
onStatusChange,
|
||||
onClearFilters,
|
||||
hasActiveFilters,
|
||||
statusCounts,
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<InputGroup className="w-full min-w-52 sm:w-60">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search orders..."
|
||||
aria-label="Search orders"
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
aria-label="Clear search"
|
||||
size="icon-xs"
|
||||
onClick={() => onSearchChange("")}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" aria-label="Filter by order status">
|
||||
<FilterIcon aria-hidden="true" />
|
||||
Status
|
||||
{selectedStatuses.length > 0 && (
|
||||
<Badge variant="info-outline">
|
||||
{selectedStatuses.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="flex w-48 flex-col gap-2.5 p-3"
|
||||
>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Filter by status
|
||||
</span>
|
||||
{STATUS_ORDER.map((status) => (
|
||||
<div key={status} className="flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
id={`status-${status.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
checked={selectedStatuses.includes(status)}
|
||||
onCheckedChange={(checked) =>
|
||||
onStatusChange(checked === true, status)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`status-${status.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-2 font-normal"
|
||||
>
|
||||
<StatusBadge status={status} />
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{statusCounts[status] ?? 0}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" aria-label="Exception queue actions">
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
Actions
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.success("Export ready", {
|
||||
description: "Exception queue export prepared.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<FileDownIcon aria-hidden="true" />
|
||||
Export CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.message("Queue refreshed", {
|
||||
description: "Live data would refresh through your API.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<RefreshCwIcon aria-hidden="true" />
|
||||
Refresh
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("View settings", {
|
||||
description: "Column and density controls are available.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
View settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExceptionGrid() {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "value", desc: true },
|
||||
])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<FulfillmentStatus[]>(
|
||||
[]
|
||||
)
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(
|
||||
columns.map((column) => column.id as string)
|
||||
)
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
risk: false,
|
||||
})
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
|
||||
const statusCounts = useMemo(
|
||||
() =>
|
||||
FULFILLMENT_ROWS.reduce(
|
||||
(acc, row) => {
|
||||
acc[row.status] = (acc[row.status] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return FULFILLMENT_ROWS.filter((row) => {
|
||||
const matchesStatus =
|
||||
!selectedStatuses.length || selectedStatuses.includes(row.status)
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
fulfillmentSearchBlob(row).includes(searchQuery.toLowerCase())
|
||||
|
||||
return matchesStatus && matchesSearch
|
||||
})
|
||||
}, [searchQuery, selectedStatuses])
|
||||
|
||||
const hasActiveFilters =
|
||||
searchQuery.trim().length > 0 || selectedStatuses.length > 0
|
||||
|
||||
const resetToFirstPage = () => {
|
||||
setPagination((current) =>
|
||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
|
||||
)
|
||||
}
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value)
|
||||
resetToFirstPage()
|
||||
}
|
||||
|
||||
const handleStatusChange = (checked: boolean, status: FulfillmentStatus) => {
|
||||
setSelectedStatuses((current) =>
|
||||
checked ? [...current, status] : current.filter((item) => item !== status)
|
||||
)
|
||||
resetToFirstPage()
|
||||
}
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSelectedStatuses([])
|
||||
setSearchQuery("")
|
||||
resetToFirstPage()
|
||||
}
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: filteredData,
|
||||
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
|
||||
getRowId: (row) => row.id,
|
||||
state: { pagination, sorting, columnOrder, columnVisibility, rowSelection },
|
||||
columnResizeMode: "onChange",
|
||||
enableRowSelection: true,
|
||||
autoResetPageIndex: false,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={200}>
|
||||
<ReuiDataGrid
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage={
|
||||
filteredData.length === 0
|
||||
? "No fulfillment exceptions match your filters."
|
||||
: undefined
|
||||
}
|
||||
tableLayout={{
|
||||
columnsPinnable: true,
|
||||
columnsResizable: true,
|
||||
columnsMovable: true,
|
||||
columnsVisibility: true,
|
||||
headerSticky: true,
|
||||
dense: true,
|
||||
}}
|
||||
tableClassNames={{
|
||||
bodyRow: "[&>td]:h-16",
|
||||
}}
|
||||
>
|
||||
<Frame variant="default" spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FrameTitle className="text-balance">Exception Queue</FrameTitle>
|
||||
<FrameDescription className="text-xs text-pretty">
|
||||
{filteredData.length} of {FULFILLMENT_ROWS.length} fulfillment
|
||||
records
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toast.info("Create exception", {
|
||||
description:
|
||||
"Connect this button to your incident intake flow.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add exception
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
<FramePanel className="bg-card p-0! shadow-none!">
|
||||
<div className="px-4 py-3">
|
||||
<Toolbar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
selectedStatuses={selectedStatuses}
|
||||
onStatusChange={handleStatusChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
statusCounts={statusCounts}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</FramePanel>
|
||||
<FrameFooter>
|
||||
<DataGridPagination />
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
</ReuiDataGrid>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { type DateRange } from "react-day-picker"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { Calendar } from "@cfdm/ui/components/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@cfdm/ui/components/popover"
|
||||
import { CalendarIcon, DownloadIcon } from "lucide-react"
|
||||
|
||||
type PeriodKey = "last30" | "prev30"
|
||||
|
||||
type ReportDateRange = {
|
||||
from: Date
|
||||
to: Date
|
||||
}
|
||||
|
||||
type DateRangePreset = {
|
||||
id: string
|
||||
label: string
|
||||
period: PeriodKey
|
||||
range: ReportDateRange
|
||||
}
|
||||
|
||||
const reportRange = (
|
||||
fromMonth: number,
|
||||
fromDay: number,
|
||||
toMonth: number,
|
||||
toDay: number,
|
||||
year = 2026
|
||||
): ReportDateRange => ({
|
||||
from: new Date(year, fromMonth, fromDay),
|
||||
to: new Date(year, toMonth, toDay),
|
||||
})
|
||||
|
||||
const preset = (
|
||||
id: string,
|
||||
label: string,
|
||||
period: PeriodKey,
|
||||
range: ReportDateRange
|
||||
): DateRangePreset => ({ id, label, period, range })
|
||||
|
||||
const LAST_30_RANGE = reportRange(4, 12, 5, 10)
|
||||
const PREVIOUS_30_RANGE = reportRange(3, 12, 4, 11)
|
||||
|
||||
const REPORT_RANGE_PRESETS: DateRangePreset[] = [
|
||||
preset("today", "Today", "last30", reportRange(5, 10, 5, 10)),
|
||||
preset("yesterday", "Yesterday", "last30", reportRange(5, 9, 5, 9)),
|
||||
preset("last7", "Last 7 days", "last30", reportRange(5, 4, 5, 10)),
|
||||
preset("last30", "Last 30 days", "last30", LAST_30_RANGE),
|
||||
preset("monthToDate", "Month to date", "last30", reportRange(5, 1, 5, 10)),
|
||||
preset("lastMonth", "Last month", "last30", reportRange(4, 1, 4, 31)),
|
||||
preset("yearToDate", "Year to date", "last30", reportRange(0, 1, 5, 10)),
|
||||
preset("lastYear", "Last year", "prev30", reportRange(0, 1, 11, 31, 2025)),
|
||||
]
|
||||
|
||||
const MAX_REPORT_DATE = LAST_30_RANGE.to
|
||||
|
||||
function isSameRange(first: ReportDateRange, second: DateRange) {
|
||||
const secondFrom = second.from
|
||||
const secondTo = second.to ?? second.from
|
||||
|
||||
return (
|
||||
Boolean(secondFrom && secondTo) &&
|
||||
first.from.getTime() === secondFrom?.getTime() &&
|
||||
first.to.getTime() === secondTo?.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRange(
|
||||
range: DateRange | undefined,
|
||||
fallback: ReportDateRange
|
||||
): ReportDateRange {
|
||||
if (!range?.from) return fallback
|
||||
|
||||
const from = range.from
|
||||
const to = range.to ?? range.from
|
||||
|
||||
return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from }
|
||||
}
|
||||
|
||||
function formatReportRange(range: ReportDateRange) {
|
||||
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`
|
||||
}
|
||||
|
||||
function getPeriodForRange(range: ReportDateRange) {
|
||||
const matchingPreset = getMatchingPreset(range)
|
||||
|
||||
if (matchingPreset) return matchingPreset.period
|
||||
return range.to.getTime() <= PREVIOUS_30_RANGE.to.getTime()
|
||||
? "prev30"
|
||||
: "last30"
|
||||
}
|
||||
|
||||
function getMatchingPreset(range: DateRange | undefined) {
|
||||
if (!range?.from || !range.to) return undefined
|
||||
const normalizedRange = normalizeRange(range, LAST_30_RANGE)
|
||||
|
||||
return REPORT_RANGE_PRESETS.find((preset) =>
|
||||
isSameRange(preset.range, normalizedRange)
|
||||
)
|
||||
}
|
||||
|
||||
function ReportDateRangePicker({
|
||||
period,
|
||||
onPeriodChange,
|
||||
}: {
|
||||
period: PeriodKey
|
||||
onPeriodChange: (value: PeriodKey) => void
|
||||
}) {
|
||||
const initialRange = period === "prev30" ? PREVIOUS_30_RANGE : LAST_30_RANGE
|
||||
const [open, setOpen] = useState(false)
|
||||
const [committedRange, setCommittedRange] =
|
||||
useState<ReportDateRange>(initialRange)
|
||||
const [draftRange, setDraftRange] = useState<DateRange | undefined>(
|
||||
initialRange
|
||||
)
|
||||
|
||||
const selectedPresetId = getMatchingPreset(draftRange ?? committedRange)?.id
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen) {
|
||||
setDraftRange(committedRange)
|
||||
}
|
||||
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const nextRange = normalizeRange(draftRange, committedRange)
|
||||
|
||||
setCommittedRange(nextRange)
|
||||
onPeriodChange(getPeriodForRange(nextRange))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="group/pick-date w-[250px] max-w-full justify-between leading-none font-normal tabular-nums"
|
||||
>
|
||||
<span className="truncate">
|
||||
{formatReportRange(committedRange)}
|
||||
</span>
|
||||
<CalendarIcon className="text-muted-foreground/80 group-hover/pick-date:text-foreground shrink-0 transition-colors" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-auto p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col sm:grid sm:grid-cols-[10rem_1fr]">
|
||||
<div className="border-border flex flex-wrap gap-1 border-b p-2 sm:flex-col sm:border-r sm:border-b-0">
|
||||
{REPORT_RANGE_PRESETS.map((preset) => {
|
||||
const selected = selectedPresetId === preset.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "secondary" : "ghost"}
|
||||
className={
|
||||
selected
|
||||
? "justify-start"
|
||||
: "text-muted-foreground justify-start"
|
||||
}
|
||||
onClick={() => setDraftRange(preset.range)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={draftRange}
|
||||
onSelect={setDraftRange}
|
||||
numberOfMonths={2}
|
||||
defaultMonth={draftRange?.from ?? committedRange.from}
|
||||
disabled={{
|
||||
after: MAX_REPORT_DATE,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-border flex items-center justify-between gap-2 border-t px-3 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDraftRange(LAST_30_RANGE)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDraftRange(committedRange)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleApply}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
// Header action controls reused from the solution-agents-8 report toolbar.
|
||||
export function NavbarActions() {
|
||||
const [periodKey, setPeriodKey] = useState<PeriodKey>("last30")
|
||||
|
||||
function handleExport() {
|
||||
toast.success("Export queued", {
|
||||
description: "Fulfillment command report is being prepared.",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ReportDateRangePicker period={periodKey} onPeriodChange={setPeriodKey} />
|
||||
|
||||
<Button size="sm" type="button" onClick={handleExport}>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span className="hidden sm:block">Export</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@cfdm/ui/components/breadcrumb"
|
||||
|
||||
// Navbar breadcrumb
|
||||
|
||||
export function NavbarBreadcrumb() {
|
||||
return (
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="hidden md:inline-flex">
|
||||
<BreadcrumbLink render={<a href="#" />}>Operations</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbSeparator className="hidden md:flex" />
|
||||
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">Fulfillment</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
} from "@cfdm/ui/components/avatar"
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { Input } from "@cfdm/ui/components/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@cfdm/ui/components/popover"
|
||||
import { TEAM_EXTRA_COUNT, TEAM_MEMBERS } from "./data"
|
||||
import { UserPlusIcon } from "lucide-react"
|
||||
|
||||
// Header presence controls with team avatars and invite action.
|
||||
|
||||
export function NavbarPresence() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleInvite = () => {
|
||||
if (!email.trim()) return
|
||||
setEmail("")
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<AvatarGroup>
|
||||
{TEAM_MEMBERS.map((member, index) => (
|
||||
<Avatar key={index} size="sm">
|
||||
<AvatarImage src={member.src} alt={member.name} />
|
||||
<AvatarFallback className="text-[9px]! font-medium">
|
||||
{member.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
<AvatarGroupCount className="text-[10px]! font-medium">
|
||||
+{TEAM_EXTRA_COUNT}
|
||||
</AvatarGroupCount>
|
||||
</AvatarGroup>
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Invite team member"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent sideOffset={7} align="end" className="w-72">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h4 className="text-foreground text-sm">Invite team member</h4>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleInvite()}
|
||||
/>
|
||||
<Button onClick={handleInvite} disabled={!email.trim()}>
|
||||
Send invite
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NavbarActions } from "./navbar-actions"
|
||||
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
|
||||
|
||||
// Navbar with breadcrumb and report range actions.
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<header
|
||||
className="flex min-h-9 w-full shrink-0 items-center justify-between gap-2 pb-1"
|
||||
aria-label="Fulfillment command header"
|
||||
>
|
||||
<NavbarBreadcrumb />
|
||||
|
||||
<NavbarActions />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Dashboard } from "./components/dashboard"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main className="bg-background min-h-svh w-full p-3 sm:p-4 lg:p-6">
|
||||
<Dashboard />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { Rating } from "@/components/reui/rating"
|
||||
import { type ReactNode } from "react"
|
||||
import { type ColumnDef } from "@tanstack/react-table"
|
||||
import { format, parseISO } from "date-fns"
|
||||
|
||||
import { cn } from "@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, ItemMedia } from "@cfdm/ui/components/item"
|
||||
import { Switch } from "@cfdm/ui/components/switch"
|
||||
import {
|
||||
type AutomationKind,
|
||||
type AutomationOwnerAvailability,
|
||||
type AutomationState,
|
||||
type IAutomationRecord,
|
||||
} from "./data"
|
||||
import { GitBranchIcon, RouteIcon, SparklesIcon, MailIcon, BellRingIcon, EllipsisVerticalIcon, PencilIcon, EyeIcon, CircleCheckIcon, ArchiveIcon } from "lucide-react"
|
||||
|
||||
export type AutomationAction = "edit" | "open" | "archive"
|
||||
|
||||
const automationKindStyles: Record<
|
||||
AutomationKind,
|
||||
{ chipClassName: string; icon: ReactNode }
|
||||
> = {
|
||||
sequence: {
|
||||
chipClassName:
|
||||
"bg-sky-50 text-sky-600 ring-sky-200 dark:bg-sky-500/15 dark:text-sky-300 dark:ring-sky-400/30",
|
||||
icon: (
|
||||
<GitBranchIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
routing: {
|
||||
chipClassName:
|
||||
"bg-violet-50 text-violet-600 ring-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:ring-violet-400/30",
|
||||
icon: (
|
||||
<RouteIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
enrichment: {
|
||||
chipClassName:
|
||||
"bg-emerald-50 text-emerald-600 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-400/30",
|
||||
icon: (
|
||||
<SparklesIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
digest: {
|
||||
chipClassName:
|
||||
"bg-amber-50 text-amber-600 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:ring-amber-400/30",
|
||||
icon: (
|
||||
<MailIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
escalation: {
|
||||
chipClassName:
|
||||
"bg-rose-50 text-rose-600 ring-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:ring-rose-400/30",
|
||||
icon: (
|
||||
<BellRingIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const stateBadgeStyles: Record<
|
||||
AutomationState,
|
||||
{ label: string; dotClassName?: string }
|
||||
> = {
|
||||
live: {
|
||||
label: "Live",
|
||||
dotClassName: "bg-emerald-500",
|
||||
},
|
||||
review: {
|
||||
label: "Needs approval",
|
||||
dotClassName: "bg-amber-500",
|
||||
},
|
||||
drafts: {
|
||||
label: "Draft",
|
||||
dotClassName: "bg-slate-400 dark:bg-slate-300",
|
||||
},
|
||||
paused: {
|
||||
label: "Paused",
|
||||
dotClassName: "bg-zinc-400 dark:bg-zinc-300",
|
||||
},
|
||||
}
|
||||
|
||||
const updatedBucketLabel: Record<IAutomationRecord["updatedBucket"], string> = {
|
||||
today: "Today",
|
||||
"this-week": "This week",
|
||||
older: "Older",
|
||||
}
|
||||
|
||||
const availabilityColor: Record<AutomationOwnerAvailability, string> = {
|
||||
online: "bg-green-500",
|
||||
away: "bg-yellow-400",
|
||||
busy: "bg-red-500",
|
||||
offline: "bg-gray-500",
|
||||
}
|
||||
|
||||
function AutomationKindChip({ kind }: { kind: AutomationKind }) {
|
||||
const style = automationKindStyles[kind]
|
||||
|
||||
return (
|
||||
<Item
|
||||
render={<span />}
|
||||
className={cn(
|
||||
"p-0",
|
||||
"inline-flex size-10 items-center justify-center ring-1 ring-inset",
|
||||
style.chipClassName
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{style.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function AutomationNameCell({ automation }: { automation: IAutomationRecord }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<AutomationKindChip kind={automation.kind} />
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<span className="text-foreground truncate text-sm font-medium">
|
||||
{automation.title}
|
||||
</span>
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<span className="truncate">{automation.runWindowLabel}</span>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{automation.audienceLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OwnerCell({ automation }: { automation: IAutomationRecord }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="size-8">
|
||||
{automation.owner.avatar ? (
|
||||
<AvatarImage
|
||||
src={automation.owner.avatar}
|
||||
alt={automation.owner.name}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback>{automation.owner.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
|
||||
availabilityColor[automation.owner.availability]
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground line-clamp-1 font-medium">
|
||||
{automation.owner.name}
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground line-clamp-1 text-xs"
|
||||
title={automation.owner.email}
|
||||
>
|
||||
{automation.owner.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StateCell({ automation }: { automation: IAutomationRecord }) {
|
||||
const stateStyle = stateBadgeStyles[automation.state]
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center">
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
{stateStyle.dotClassName ? (
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
stateStyle.dotClassName
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{stateStyle.label}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UpdatedCell({ automation }: { automation: IAutomationRecord }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<span className="text-foreground text-sm">
|
||||
{format(parseISO(automation.updatedAt), "MMM d, yyyy")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{updatedBucketLabel[automation.updatedBucket]}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RatingCell({ automation }: { automation: IAutomationRecord }) {
|
||||
return <Rating rating={automation.rating} size="sm" showValue={true} />
|
||||
}
|
||||
|
||||
function AutomationActionsCell({
|
||||
automation,
|
||||
onAction,
|
||||
onToggleEnabled,
|
||||
}: {
|
||||
automation: IAutomationRecord
|
||||
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
|
||||
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Open actions for ${automation.title}`}
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{/* Content */}
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onAction("edit", automation)}>
|
||||
<PencilIcon className="size-4" aria-hidden="true" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAction("open", automation)}>
|
||||
<EyeIcon className="size-4" aria-hidden="true" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
closeOnClick={false}
|
||||
onClick={(event) => {
|
||||
// The Switch toggles itself and its click bubbles here; skip it
|
||||
// so item-level activation (row click, Enter/Space) toggles once.
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest('[data-slot="switch"]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
onToggleEnabled(automation, !automation.enabled)
|
||||
}}
|
||||
className="justify-between gap-4"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<CircleCheckIcon className="size-4" aria-hidden="true" />
|
||||
Enabled
|
||||
</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
aria-label={`Toggle ${automation.title}`}
|
||||
checked={automation.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleEnabled(automation, checked)
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onAction("archive", automation)}
|
||||
>
|
||||
<ArchiveIcon className="size-4" aria-hidden="true" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function createAutomationColumns({
|
||||
onAction,
|
||||
onToggleEnabled,
|
||||
}: {
|
||||
onAction: (action: AutomationAction, automation: IAutomationRecord) => void
|
||||
onToggleEnabled: (automation: IAutomationRecord, nextValue: boolean) => void
|
||||
}): ColumnDef<IAutomationRecord>[] {
|
||||
return [
|
||||
{
|
||||
id: "select",
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
size: 30,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
headerClassName:
|
||||
"[--data-grid-header-cell-ps:var(--frame-panel-header-px)]",
|
||||
cellClassName: "[--data-grid-body-cell-ps:var(--frame-panel-px)]",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.title,
|
||||
id: "workflow",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Workflow" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <AutomationNameCell automation={row.original} />,
|
||||
size: 340,
|
||||
minSize: 200,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
autoSize: true,
|
||||
headerClassName: "pl-3!",
|
||||
cellClassName: "pl-3!",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.owner.name,
|
||||
id: "owner",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Owner" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <OwnerCell automation={row.original} />,
|
||||
size: 175,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.rating,
|
||||
id: "rating",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Score" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <RatingCell automation={row.original} />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => row.state,
|
||||
id: "state",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="State" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <StateCell automation={row.original} />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) => parseISO(row.updatedAt).getTime(),
|
||||
id: "updatedAt",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Last updated" column={column} />
|
||||
),
|
||||
cell: ({ row }) => <UpdatedCell automation={row.original} />,
|
||||
size: 125,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end">
|
||||
<AutomationActionsCell
|
||||
automation={row.original}
|
||||
onAction={onAction}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
size: 56,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerClassName:
|
||||
"[--data-grid-header-cell-pe:var(--frame-panel-header-px)]",
|
||||
cellClassName: "[--data-grid-body-cell-pe:var(--frame-panel-px)]",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
createFilter,
|
||||
Filters,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from "@/components/reui/filters"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@cfdm/ui/components/alert-dialog"
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@cfdm/ui/components/tabs"
|
||||
import { createAutomationColumns } from "./columns"
|
||||
import {
|
||||
AUTOMATION_TABS,
|
||||
AUTOMATIONS,
|
||||
DELIVERY_FILTER_OPTIONS,
|
||||
getAutomationTabFromState,
|
||||
OWNER_FILTER_OPTIONS,
|
||||
UPDATED_FILTER_OPTIONS,
|
||||
type AutomationState,
|
||||
type AutomationTab,
|
||||
type IAutomationRecord,
|
||||
} from "./data"
|
||||
import { SearchIcon, UsersIcon, RouteIcon, ClockIcon, PlusIcon, FilterIcon, FunnelXIcon } from "lucide-react"
|
||||
|
||||
type ToastTone = "success" | "neutral" | "destructive"
|
||||
|
||||
const toneStyles: Record<ToastTone, { dot: string }> = {
|
||||
success: { dot: "bg-emerald-500" },
|
||||
neutral: { dot: "bg-sky-500" },
|
||||
destructive: { dot: "bg-rose-500" },
|
||||
}
|
||||
|
||||
function showAutomationToast({
|
||||
tone,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
tone: ToastTone
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
toast.custom((id) => (
|
||||
<div className="bg-popover text-popover-foreground border-border flex w-[356px] flex-col gap-3 rounded-md border p-4 shadow-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 flex size-2 shrink-0 rounded-full",
|
||||
toneStyles[tone].dot
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed text-pretty">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="xs" variant="outline" onClick={() => toast.dismiss(id)}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
function getAutomationSearchBlob(automation: IAutomationRecord) {
|
||||
return [
|
||||
automation.title,
|
||||
automation.kind,
|
||||
automation.state,
|
||||
automation.deliveryMode,
|
||||
automation.audienceLabel,
|
||||
automation.runWindowLabel,
|
||||
automation.owner.name,
|
||||
automation.owner.email,
|
||||
automation.owner.teamLabel,
|
||||
automation.approvalRequired ? "approval required" : "auto-approved",
|
||||
automation.enabled ? "enabled" : "disabled",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function getActiveFilters(filters: Filter[]) {
|
||||
return filters.filter((filter) => {
|
||||
const { values } = filter
|
||||
if (!values || values.length === 0) return false
|
||||
if (
|
||||
values.every((value) => typeof value === "string" && value.trim() === "")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => value === null || value === undefined)) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function renderSelectedCount(values: unknown[]) {
|
||||
if (values.length === 0) return "Select..."
|
||||
if (values.length > 1) return `${values.length} selected`
|
||||
return null
|
||||
}
|
||||
|
||||
function renderSingleSelectedLabel(
|
||||
values: unknown[],
|
||||
options: { value: string; label: string }[]
|
||||
) {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
|
||||
const option = options.find((item) => item.value === values[0])
|
||||
return option?.label ?? String(values[0])
|
||||
}
|
||||
|
||||
function filterFieldValue(
|
||||
automation: IAutomationRecord,
|
||||
field: string
|
||||
): unknown {
|
||||
switch (field) {
|
||||
case "workflow":
|
||||
return getAutomationSearchBlob(automation)
|
||||
case "ownerTeam":
|
||||
return automation.owner.team
|
||||
case "deliveryMode":
|
||||
return automation.deliveryMode
|
||||
case "updatedBucket":
|
||||
return automation.updatedBucket
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function applyFiltersToData(
|
||||
data: IAutomationRecord[],
|
||||
filters: Filter[]
|
||||
): IAutomationRecord[] {
|
||||
const active = getActiveFilters(filters)
|
||||
let result = [...data]
|
||||
|
||||
active.forEach((filter) => {
|
||||
const { field, operator, values } = filter
|
||||
|
||||
result = result.filter((item) => {
|
||||
const raw = filterFieldValue(item, field)
|
||||
const fieldValue = raw != null ? raw : ""
|
||||
|
||||
switch (operator) {
|
||||
case "is":
|
||||
return values.includes(fieldValue)
|
||||
case "is_not":
|
||||
return !values.includes(fieldValue)
|
||||
case "is_any_of":
|
||||
return values.some((value) => fieldValue === value)
|
||||
case "is_not_any_of":
|
||||
return !values.some((value) => fieldValue === value)
|
||||
case "contains": {
|
||||
const tokens = values
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean)
|
||||
if (tokens.length === 0) return true
|
||||
return tokens.some((token) =>
|
||||
String(fieldValue).toLowerCase().includes(token.toLowerCase())
|
||||
)
|
||||
}
|
||||
case "not_contains":
|
||||
return !values.some((value) =>
|
||||
String(fieldValue)
|
||||
.toLowerCase()
|
||||
.includes(String(value).toLowerCase())
|
||||
)
|
||||
case "starts_with":
|
||||
return values.some((value) =>
|
||||
String(fieldValue)
|
||||
.toLowerCase()
|
||||
.startsWith(String(value).toLowerCase())
|
||||
)
|
||||
case "ends_with":
|
||||
return values.some((value) =>
|
||||
String(fieldValue)
|
||||
.toLowerCase()
|
||||
.endsWith(String(value).toLowerCase())
|
||||
)
|
||||
case "empty":
|
||||
return fieldValue === "" || fieldValue == null
|
||||
case "not_empty":
|
||||
return fieldValue !== "" && fieldValue != null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const OWNER_TEAM_FILTER_OPTIONS = OWNER_FILTER_OPTIONS.filter(
|
||||
(option) => option.value !== "everyone"
|
||||
).map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))
|
||||
|
||||
const DELIVERY_MODE_FILTER_OPTIONS = DELIVERY_FILTER_OPTIONS.filter(
|
||||
(option) => option.value !== "any"
|
||||
).map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))
|
||||
|
||||
const UPDATED_BUCKET_FILTER_OPTIONS = UPDATED_FILTER_OPTIONS.filter(
|
||||
(option) => option.value !== "any"
|
||||
).map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))
|
||||
|
||||
const filterFields: FilterFieldConfig[] = [
|
||||
{
|
||||
key: "workflow",
|
||||
label: "Workflow",
|
||||
icon: (
|
||||
<SearchIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "text",
|
||||
className: "w-52",
|
||||
placeholder: "Search...",
|
||||
},
|
||||
{
|
||||
key: "ownerTeam",
|
||||
label: "Owner team",
|
||||
icon: (
|
||||
<UsersIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[168px]",
|
||||
options: OWNER_TEAM_FILTER_OPTIONS,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, OWNER_TEAM_FILTER_OPTIONS),
|
||||
},
|
||||
{
|
||||
key: "deliveryMode",
|
||||
label: "Delivery mode",
|
||||
icon: (
|
||||
<RouteIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[168px]",
|
||||
options: DELIVERY_MODE_FILTER_OPTIONS,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, DELIVERY_MODE_FILTER_OPTIONS),
|
||||
},
|
||||
{
|
||||
key: "updatedBucket",
|
||||
label: "Last updated",
|
||||
icon: (
|
||||
<ClockIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[160px]",
|
||||
options: UPDATED_BUCKET_FILTER_OPTIONS,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, UPDATED_BUCKET_FILTER_OPTIONS),
|
||||
},
|
||||
]
|
||||
|
||||
function createDefaultAutomationFilters(): Filter[] {
|
||||
return [createFilter("workflow", "contains", [""])]
|
||||
}
|
||||
|
||||
function getTabCounts(records: IAutomationRecord[]) {
|
||||
return {
|
||||
all: records.length,
|
||||
live: records.filter((record) => record.state === "live").length,
|
||||
review: records.filter((record) => record.state === "review").length,
|
||||
drafts: records.filter((record) => record.state === "drafts").length,
|
||||
paused: records.filter((record) => record.state === "paused").length,
|
||||
} satisfies Record<AutomationTab, number>
|
||||
}
|
||||
|
||||
export function AutomationLibraryGridView() {
|
||||
const [automations, setAutomations] =
|
||||
useState<IAutomationRecord[]>(AUTOMATIONS)
|
||||
const [activeTab, setActiveTab] = useState<AutomationTab>("all")
|
||||
const [filters, setFilters] = useState<Filter[]>(
|
||||
createDefaultAutomationFilters
|
||||
)
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "updatedAt", desc: true },
|
||||
])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
const [automationPendingArchive, setAutomationPendingArchive] =
|
||||
useState<IAutomationRecord | null>(null)
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) =>
|
||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
|
||||
)
|
||||
}, [])
|
||||
|
||||
const filteredBaseAutomations = useMemo(() => {
|
||||
return applyFiltersToData(automations, filters)
|
||||
}, [automations, filters])
|
||||
|
||||
const filteredAutomations = useMemo(
|
||||
() =>
|
||||
filteredBaseAutomations.filter((automation) =>
|
||||
activeTab === "all"
|
||||
? true
|
||||
: getAutomationTabFromState(automation.state) === activeTab
|
||||
),
|
||||
[activeTab, filteredBaseAutomations]
|
||||
)
|
||||
|
||||
const tabCounts = useMemo(
|
||||
() => getTabCounts(filteredBaseAutomations),
|
||||
[filteredBaseAutomations]
|
||||
)
|
||||
|
||||
const filteredLiveCount = useMemo(
|
||||
() =>
|
||||
filteredAutomations.filter((automation) => automation.state === "live")
|
||||
.length,
|
||||
[filteredAutomations]
|
||||
)
|
||||
|
||||
const filteredReviewCount = useMemo(
|
||||
() =>
|
||||
filteredAutomations.filter((automation) => automation.state === "review")
|
||||
.length,
|
||||
[filteredAutomations]
|
||||
)
|
||||
|
||||
const selectedCount = useMemo(
|
||||
() => Object.keys(rowSelection).length,
|
||||
[rowSelection]
|
||||
)
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createAutomationColumns({
|
||||
onAction: (action, automation) => {
|
||||
if (action === "archive") {
|
||||
setAutomationPendingArchive(automation)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
showAutomationToast({
|
||||
tone: "neutral",
|
||||
title: "Workflow editor",
|
||||
description: `Connect "${automation.title}" to your builder, side panel, or automation step editor.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showAutomationToast({
|
||||
tone: "success",
|
||||
title: "Workflow details",
|
||||
description: `"${automation.title}" is ready for a detail route, run history drawer, or audit panel.`,
|
||||
})
|
||||
},
|
||||
onToggleEnabled: (automation, nextValue) => {
|
||||
const nextState: AutomationState =
|
||||
nextValue && automation.state === "paused"
|
||||
? "live"
|
||||
: nextValue && automation.state === "drafts"
|
||||
? "review"
|
||||
: !nextValue && automation.state === "live"
|
||||
? "paused"
|
||||
: automation.state
|
||||
|
||||
setAutomations((current) =>
|
||||
current.map((item) =>
|
||||
item.id === automation.id
|
||||
? {
|
||||
...item,
|
||||
enabled: nextValue,
|
||||
state: nextState,
|
||||
}
|
||||
: item
|
||||
)
|
||||
)
|
||||
|
||||
showAutomationToast({
|
||||
tone: nextValue ? "success" : "neutral",
|
||||
title: nextValue ? "Workflow enabled" : "Workflow paused",
|
||||
description: nextValue
|
||||
? `"${automation.title}" is ready to run in the ${nextState === "review" ? "review" : "live"} queue.`
|
||||
: `"${automation.title}" will stay available but will not continue running until resumed.`,
|
||||
})
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredAutomations,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
})
|
||||
|
||||
const handleClearControls = useCallback(() => {
|
||||
setFilters(createDefaultAutomationFilters())
|
||||
resetPagination()
|
||||
}, [resetPagination])
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(nextFilters: Filter[]) => {
|
||||
setFilters(nextFilters)
|
||||
resetPagination()
|
||||
},
|
||||
[resetPagination]
|
||||
)
|
||||
|
||||
const handleArchiveAutomation = useCallback(() => {
|
||||
if (!automationPendingArchive) return
|
||||
|
||||
const automationToArchive = automationPendingArchive
|
||||
|
||||
setAutomations((current) =>
|
||||
current.filter(
|
||||
(automation) => automation.id !== automationPendingArchive.id
|
||||
)
|
||||
)
|
||||
setRowSelection((current) => {
|
||||
const next = { ...current }
|
||||
delete next[automationPendingArchive.id]
|
||||
return next
|
||||
})
|
||||
setAutomationPendingArchive(null)
|
||||
resetPagination()
|
||||
|
||||
showAutomationToast({
|
||||
tone: "destructive",
|
||||
title: "Workflow archived",
|
||||
description: `"${automationToArchive.title}" was removed from this automation library.`,
|
||||
})
|
||||
}, [automationPendingArchive, resetPagination])
|
||||
|
||||
const emptyMessage =
|
||||
"No workflows match this automation slice. Switch tabs or clear the filters."
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Table */}
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredAutomations.length}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={{
|
||||
dense: true,
|
||||
}}
|
||||
>
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<FrameTitle className="text-balance">
|
||||
Automation Library
|
||||
</FrameTitle>
|
||||
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
||||
<span>
|
||||
{filteredAutomations.length} workflow
|
||||
{filteredAutomations.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{filteredLiveCount} live</span>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{filteredReviewCount} review</span>
|
||||
{selectedCount > 0 ? (
|
||||
<>
|
||||
<span
|
||||
className="bg-input size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{selectedCount} selected</span>
|
||||
</>
|
||||
) : null}
|
||||
</FrameDescription>
|
||||
</div>
|
||||
|
||||
<Button type="button" className="shrink-0">
|
||||
<PlusIcon className="size-4" aria-hidden="true" />
|
||||
New workflow
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
setActiveTab(value as AutomationTab)
|
||||
resetPagination()
|
||||
}}
|
||||
>
|
||||
<TabsList variant="line" className="gap-5">
|
||||
{AUTOMATION_TABS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="gap-2 px-0 pb-3 text-sm"
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
||||
{tabCounts[tab.value]}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button variant="outline" aria-label="Filters">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
Filters
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
{selectedCount} selected
|
||||
</Badge>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClearControls}
|
||||
>
|
||||
<FunnelXIcon className="size-4" aria-hidden="true" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FrameFooter>
|
||||
<DataGridPagination />
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
|
||||
<AlertDialog
|
||||
open={automationPendingArchive != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setAutomationPendingArchive(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Archive workflow?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{automationPendingArchive
|
||||
? `Archive "${automationPendingArchive.title}" from this automation library. Run history and ownership context can stay available in your backend, but this row will disappear from the grid preview.`
|
||||
: "Archive this workflow from the automation library."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleArchiveAutomation}
|
||||
render={
|
||||
<Button type="button" variant="destructive">
|
||||
Archive
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
export type AutomationTab = "all" | "live" | "review" | "drafts" | "paused"
|
||||
|
||||
export type AutomationKind =
|
||||
| "sequence"
|
||||
| "routing"
|
||||
| "enrichment"
|
||||
| "digest"
|
||||
| "escalation"
|
||||
|
||||
export type OwnerFilter =
|
||||
| "everyone"
|
||||
| "product"
|
||||
| "engineering"
|
||||
| "operations"
|
||||
| "revenue"
|
||||
| "support"
|
||||
|
||||
export type DeliveryFilter =
|
||||
| "any"
|
||||
| "scheduled"
|
||||
| "event-driven"
|
||||
| "manual"
|
||||
| "hybrid"
|
||||
|
||||
export type UpdatedFilter = "any" | "today" | "this-week" | "older"
|
||||
|
||||
export type AutomationState = Exclude<AutomationTab, "all">
|
||||
export type AutomationOwnerAvailability = "online" | "away" | "busy" | "offline"
|
||||
|
||||
export interface IAutomationOwner {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
initials: string
|
||||
avatar?: string
|
||||
availability: AutomationOwnerAvailability
|
||||
team: Exclude<OwnerFilter, "everyone">
|
||||
teamLabel: string
|
||||
}
|
||||
|
||||
export interface IAutomationRecord {
|
||||
id: string
|
||||
title: string
|
||||
kind: AutomationKind
|
||||
state: AutomationState
|
||||
rating: number
|
||||
deliveryMode: Exclude<DeliveryFilter, "any">
|
||||
owner: IAutomationOwner
|
||||
updatedAt: string
|
||||
updatedBucket: Exclude<UpdatedFilter, "any">
|
||||
enabled: boolean
|
||||
approvalRequired: boolean
|
||||
audienceLabel: string
|
||||
runWindowLabel: string
|
||||
}
|
||||
|
||||
const OWNERS: Record<string, IAutomationOwner> = {
|
||||
maya: {
|
||||
id: "maya-patel",
|
||||
name: "Maya Patel",
|
||||
email: "[email protected]",
|
||||
initials: "MP",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
availability: "online",
|
||||
team: "product",
|
||||
teamLabel: "Product",
|
||||
},
|
||||
jonas: {
|
||||
id: "jonas-reed",
|
||||
name: "Jonas Reed",
|
||||
email: "[email protected]",
|
||||
initials: "JR",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
availability: "busy",
|
||||
team: "engineering",
|
||||
teamLabel: "Engineering",
|
||||
},
|
||||
priya: {
|
||||
id: "priya-nair",
|
||||
name: "Priya Nair",
|
||||
email: "[email protected]",
|
||||
initials: "PN",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
|
||||
availability: "away",
|
||||
team: "operations",
|
||||
teamLabel: "Operations",
|
||||
},
|
||||
emil: {
|
||||
id: "emil-novak",
|
||||
name: "Emil Novak",
|
||||
email: "[email protected]",
|
||||
initials: "EN",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1560250097-0b93528c311a?w=96&h=96&dpr=2&q=80",
|
||||
availability: "offline",
|
||||
team: "revenue",
|
||||
teamLabel: "Revenue",
|
||||
},
|
||||
nora: {
|
||||
id: "nora-ibrahim",
|
||||
name: "Nora Ibrahim",
|
||||
email: "[email protected]",
|
||||
initials: "NI",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
|
||||
availability: "online",
|
||||
team: "support",
|
||||
teamLabel: "Support",
|
||||
},
|
||||
}
|
||||
|
||||
function automation(
|
||||
input: Omit<IAutomationRecord, "owner"> & {
|
||||
owner: keyof typeof OWNERS
|
||||
}
|
||||
): IAutomationRecord {
|
||||
return {
|
||||
...input,
|
||||
owner: OWNERS[input.owner],
|
||||
}
|
||||
}
|
||||
|
||||
export const AUTOMATION_TABS: { value: AutomationTab; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "live", label: "Live" },
|
||||
{ value: "review", label: "Needs Review" },
|
||||
{ value: "drafts", label: "Drafts" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
]
|
||||
|
||||
export const OWNER_FILTER_OPTIONS: {
|
||||
value: OwnerFilter
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "everyone", label: "Owner team" },
|
||||
{ value: "product", label: "Product" },
|
||||
{ value: "engineering", label: "Engineering" },
|
||||
{ value: "operations", label: "Operations" },
|
||||
{ value: "revenue", label: "Revenue" },
|
||||
{ value: "support", label: "Support" },
|
||||
]
|
||||
|
||||
export const DELIVERY_FILTER_OPTIONS: {
|
||||
value: DeliveryFilter
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "any", label: "Delivery mode" },
|
||||
{ value: "scheduled", label: "Scheduled" },
|
||||
{ value: "event-driven", label: "Event-driven" },
|
||||
{ value: "manual", label: "Manual" },
|
||||
{ value: "hybrid", label: "Hybrid" },
|
||||
]
|
||||
|
||||
export const UPDATED_FILTER_OPTIONS: {
|
||||
value: UpdatedFilter
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "any", label: "Last updated" },
|
||||
{ value: "today", label: "Today" },
|
||||
{ value: "this-week", label: "This week" },
|
||||
{ value: "older", label: "Older" },
|
||||
]
|
||||
|
||||
export function getAutomationTabFromState(
|
||||
state: AutomationState
|
||||
): Exclude<AutomationTab, "all"> {
|
||||
return state
|
||||
}
|
||||
|
||||
export const AUTOMATIONS: IAutomationRecord[] = [
|
||||
automation({
|
||||
id: "renewal-touchpoint-orchestration",
|
||||
title: "Renewal touchpoint orchestration",
|
||||
kind: "sequence",
|
||||
state: "live",
|
||||
rating: 4.8,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "emil",
|
||||
updatedAt: "2026-04-11",
|
||||
updatedBucket: "today",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Renewal accounts",
|
||||
runWindowLabel: "Weekdays 09:00",
|
||||
}),
|
||||
automation({
|
||||
id: "delegated-sender-review-route",
|
||||
title: "Delegated sender review route",
|
||||
kind: "routing",
|
||||
state: "review",
|
||||
rating: 4.2,
|
||||
deliveryMode: "manual",
|
||||
owner: "priya",
|
||||
updatedAt: "2026-04-11",
|
||||
updatedBucket: "today",
|
||||
enabled: false,
|
||||
approvalRequired: true,
|
||||
audienceLabel: "Delegated senders",
|
||||
runWindowLabel: "Queue-based release",
|
||||
}),
|
||||
automation({
|
||||
id: "launch-handoff-digest",
|
||||
title: "Launch handoff digest",
|
||||
kind: "digest",
|
||||
state: "live",
|
||||
rating: 4.7,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "maya",
|
||||
updatedAt: "2026-04-10",
|
||||
updatedBucket: "this-week",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Launch squad",
|
||||
runWindowLabel: "Daily 08:30",
|
||||
}),
|
||||
automation({
|
||||
id: "sla-escalation-watch",
|
||||
title: "SLA escalation watch",
|
||||
kind: "escalation",
|
||||
state: "live",
|
||||
rating: 4.9,
|
||||
deliveryMode: "event-driven",
|
||||
owner: "nora",
|
||||
updatedAt: "2026-04-10",
|
||||
updatedBucket: "this-week",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Priority tickets",
|
||||
runWindowLabel: "On trigger",
|
||||
}),
|
||||
automation({
|
||||
id: "lead-enrichment-pass",
|
||||
title: "Lead enrichment pass",
|
||||
kind: "enrichment",
|
||||
state: "paused",
|
||||
rating: 3.9,
|
||||
deliveryMode: "hybrid",
|
||||
owner: "jonas",
|
||||
updatedAt: "2026-04-09",
|
||||
updatedBucket: "this-week",
|
||||
enabled: false,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Inbound pipeline",
|
||||
runWindowLabel: "Hourly batch",
|
||||
}),
|
||||
automation({
|
||||
id: "sandbox-onboarding-sequence",
|
||||
title: "Sandbox onboarding sequence",
|
||||
kind: "sequence",
|
||||
state: "drafts",
|
||||
rating: 4.1,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "priya",
|
||||
updatedAt: "2026-04-08",
|
||||
updatedBucket: "this-week",
|
||||
enabled: false,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Trial workspaces",
|
||||
runWindowLabel: "Pending QA",
|
||||
}),
|
||||
automation({
|
||||
id: "partner-routing-fallback",
|
||||
title: "Partner routing fallback",
|
||||
kind: "routing",
|
||||
state: "live",
|
||||
rating: 4.4,
|
||||
deliveryMode: "hybrid",
|
||||
owner: "emil",
|
||||
updatedAt: "2026-04-07",
|
||||
updatedBucket: "this-week",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Partner renewals",
|
||||
runWindowLabel: "Live + nightly",
|
||||
}),
|
||||
automation({
|
||||
id: "weekly-adoption-digest",
|
||||
title: "Weekly adoption digest",
|
||||
kind: "digest",
|
||||
state: "paused",
|
||||
rating: 3.8,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "maya",
|
||||
updatedAt: "2026-04-06",
|
||||
updatedBucket: "this-week",
|
||||
enabled: false,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Workspace champions",
|
||||
runWindowLabel: "Fridays 16:00",
|
||||
}),
|
||||
automation({
|
||||
id: "enterprise-risk-escalation",
|
||||
title: "Enterprise risk escalation",
|
||||
kind: "escalation",
|
||||
state: "review",
|
||||
rating: 4.3,
|
||||
deliveryMode: "manual",
|
||||
owner: "nora",
|
||||
updatedAt: "2026-04-05",
|
||||
updatedBucket: "older",
|
||||
enabled: false,
|
||||
approvalRequired: true,
|
||||
audienceLabel: "Enterprise accounts",
|
||||
runWindowLabel: "Manual release",
|
||||
}),
|
||||
automation({
|
||||
id: "crm-enrichment-backfill",
|
||||
title: "CRM enrichment backfill",
|
||||
kind: "enrichment",
|
||||
state: "live",
|
||||
rating: 4.6,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "jonas",
|
||||
updatedAt: "2026-04-04",
|
||||
updatedBucket: "older",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Open opportunities",
|
||||
runWindowLabel: "Nightly 01:00",
|
||||
}),
|
||||
automation({
|
||||
id: "trial-conversion-follow-up",
|
||||
title: "Trial conversion follow-up",
|
||||
kind: "sequence",
|
||||
state: "drafts",
|
||||
rating: 4.0,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "emil",
|
||||
updatedAt: "2026-04-03",
|
||||
updatedBucket: "older",
|
||||
enabled: false,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Product-led signups",
|
||||
runWindowLabel: "Awaiting copy",
|
||||
}),
|
||||
automation({
|
||||
id: "owner-assignment-router",
|
||||
title: "Owner assignment router",
|
||||
kind: "routing",
|
||||
state: "live",
|
||||
rating: 4.5,
|
||||
deliveryMode: "event-driven",
|
||||
owner: "priya",
|
||||
updatedAt: "2026-04-02",
|
||||
updatedBucket: "older",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Workspace requests",
|
||||
runWindowLabel: "Immediate",
|
||||
}),
|
||||
automation({
|
||||
id: "ops-exception-digest",
|
||||
title: "Ops exception digest",
|
||||
kind: "digest",
|
||||
state: "review",
|
||||
rating: 4.1,
|
||||
deliveryMode: "scheduled",
|
||||
owner: "priya",
|
||||
updatedAt: "2026-04-11",
|
||||
updatedBucket: "today",
|
||||
enabled: false,
|
||||
approvalRequired: true,
|
||||
audienceLabel: "Ops leadership",
|
||||
runWindowLabel: "Daily 18:00",
|
||||
}),
|
||||
automation({
|
||||
id: "billing-retry-escalation",
|
||||
title: "Billing retry escalation",
|
||||
kind: "escalation",
|
||||
state: "live",
|
||||
rating: 4.7,
|
||||
deliveryMode: "event-driven",
|
||||
owner: "nora",
|
||||
updatedAt: "2026-04-01",
|
||||
updatedBucket: "older",
|
||||
enabled: true,
|
||||
approvalRequired: false,
|
||||
audienceLabel: "Recovery queue",
|
||||
runWindowLabel: "On failure",
|
||||
}),
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AutomationLibraryGridView } from "./components/data-grid-view"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Automation library data grid
|
||||
</h1>
|
||||
<AutomationLibraryGridView />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
export type TicketStatus = "open" | "waiting" | "resolved"
|
||||
export type TicketPriority = "low" | "medium" | "high" | "urgent"
|
||||
export type TicketSource = "portal" | "inbox" | "api"
|
||||
export type TicketCategory = "access" | "security" | "billing" | "workflow"
|
||||
|
||||
export type TicketSelectOption<TValue extends string = string> = {
|
||||
value: TValue
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type SupportMember = {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
src: string
|
||||
initials: string
|
||||
}
|
||||
|
||||
export type TicketDetailsValue = {
|
||||
dueDate: string
|
||||
status: TicketStatus
|
||||
slaMet: boolean
|
||||
priority: TicketPriority
|
||||
source: TicketSource
|
||||
channel: string
|
||||
requestForm: string
|
||||
category: TicketCategory
|
||||
notifyRequester: boolean
|
||||
tags: string[]
|
||||
collaboratorIds: string[]
|
||||
}
|
||||
|
||||
export const STATUS_OPTIONS: TicketSelectOption<TicketStatus>[] = [
|
||||
{
|
||||
value: "open",
|
||||
label: "Open",
|
||||
description: "Active and ready for the next response.",
|
||||
},
|
||||
{
|
||||
value: "waiting",
|
||||
label: "Waiting",
|
||||
description: "Paused until requester or vendor input arrives.",
|
||||
},
|
||||
{
|
||||
value: "resolved",
|
||||
label: "Resolved",
|
||||
description: "Completed and ready for closure.",
|
||||
},
|
||||
]
|
||||
|
||||
export const PRIORITY_OPTIONS: TicketSelectOption<TicketPriority>[] = [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "urgent", label: "Urgent" },
|
||||
]
|
||||
|
||||
export const SOURCE_OPTIONS: TicketSelectOption<TicketSource>[] = [
|
||||
{ value: "portal", label: "Customer Portal" },
|
||||
{ value: "inbox", label: "Shared Inbox" },
|
||||
{ value: "api", label: "API Intake" },
|
||||
]
|
||||
|
||||
export const REQUEST_FORM_OPTIONS: TicketSelectOption[] = [
|
||||
{
|
||||
value: "workspace-access",
|
||||
label: "Workspace Access",
|
||||
description: "Provisioning, group access, and app authorization.",
|
||||
},
|
||||
{
|
||||
value: "vendor-review",
|
||||
label: "Vendor Review",
|
||||
description: "Security and procurement review for new tools.",
|
||||
},
|
||||
{
|
||||
value: "billing-exception",
|
||||
label: "Billing Exception",
|
||||
description: "Invoice changes, credits, and payment routing.",
|
||||
},
|
||||
{
|
||||
value: "automation-change",
|
||||
label: "Automation Change",
|
||||
description: "Workflow updates owned by operations.",
|
||||
},
|
||||
]
|
||||
|
||||
export const CATEGORY_OPTIONS: TicketSelectOption<TicketCategory>[] = [
|
||||
{ value: "access", label: "Access Request" },
|
||||
{ value: "security", label: "Security Review" },
|
||||
{ value: "billing", label: "Billing Support" },
|
||||
{ value: "workflow", label: "Workflow Change" },
|
||||
]
|
||||
|
||||
export const TAG_OPTIONS = [
|
||||
"Feature",
|
||||
"VIP",
|
||||
"Automation",
|
||||
"Security",
|
||||
"Renewal",
|
||||
"Finance",
|
||||
]
|
||||
|
||||
export const COLLABORATORS: SupportMember[] = [
|
||||
{
|
||||
id: "mira",
|
||||
name: "Mira Stone",
|
||||
role: "Identity owner",
|
||||
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
initials: "MS",
|
||||
},
|
||||
{
|
||||
id: "leo",
|
||||
name: "Leo Grant",
|
||||
role: "Support lead",
|
||||
src: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "LG",
|
||||
},
|
||||
{
|
||||
id: "nora",
|
||||
name: "Nora Vale",
|
||||
role: "Workflow admin",
|
||||
src: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
|
||||
initials: "NV",
|
||||
},
|
||||
{
|
||||
id: "theo",
|
||||
name: "Theo Park",
|
||||
role: "Security reviewer",
|
||||
src: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "TP",
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_TICKET_DETAILS: TicketDetailsValue = {
|
||||
dueDate: "2026-04-30",
|
||||
status: "open",
|
||||
slaMet: true,
|
||||
priority: "medium",
|
||||
source: "portal",
|
||||
channel: "identity-access",
|
||||
requestForm: "workspace-access",
|
||||
category: "access",
|
||||
notifyRequester: true,
|
||||
tags: ["Feature", "Security"],
|
||||
collaboratorIds: ["mira", "leo"],
|
||||
}
|
||||
|
||||
export const TICKET_TIMESTAMPS = {
|
||||
createdAt: "Jan 21, 2026 at 10:16 PM",
|
||||
updatedAt: "Just now",
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { Field, FieldTitle } from "@cfdm/ui/components/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
} from "@cfdm/ui/components/input-group"
|
||||
import { Item, ItemMedia } from "@cfdm/ui/components/item"
|
||||
import { Spinner } from "@cfdm/ui/components/spinner"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@cfdm/ui/components/tooltip"
|
||||
import { InfoIcon, PencilIcon, XIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
interface EditableDetailRowProps {
|
||||
label: string
|
||||
hint?: string
|
||||
editing?: boolean
|
||||
display: ReactNode
|
||||
renderEdit?: (active: boolean) => ReactNode
|
||||
align?: "center" | "start"
|
||||
actionsDisabled?: boolean
|
||||
saving?: boolean
|
||||
onEdit?: () => void
|
||||
onCancel?: () => void
|
||||
onSave?: () => void
|
||||
}
|
||||
|
||||
function RowHint({ label, children }: { label: string; children: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground -my-1 shrink-0"
|
||||
aria-label={label}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
|
||||
{children}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditableDetailRow({
|
||||
label,
|
||||
hint,
|
||||
editing = false,
|
||||
display,
|
||||
renderEdit,
|
||||
align = "center",
|
||||
actionsDisabled = false,
|
||||
saving = false,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: EditableDetailRowProps) {
|
||||
const editable = Boolean(renderEdit && onEdit && onCancel && onSave)
|
||||
const controlsDisabled = actionsDisabled || saving
|
||||
const controlActive = !controlsDisabled
|
||||
const editActionsDisabled = !editing || controlsDisabled
|
||||
const editRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
return
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const control = editRef.current?.querySelector<HTMLElement>(
|
||||
[
|
||||
"[data-slot='input-group-control']:not(:disabled)",
|
||||
"[data-slot='combobox-chip-input']:not(:disabled)",
|
||||
"button:not(:disabled)",
|
||||
"input:not(:disabled)",
|
||||
].join(",")
|
||||
)
|
||||
|
||||
control?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [editing])
|
||||
|
||||
return (
|
||||
<Field
|
||||
className={cn(
|
||||
"group/row grid gap-x-2 gap-y-1 px-4 py-1.5 sm:grid-cols-[minmax(7.75rem,0.5fr)_minmax(0,1.5fr)] sm:gap-4",
|
||||
align === "start" ? "sm:items-start" : "sm:items-center"
|
||||
)}
|
||||
>
|
||||
<FieldTitle
|
||||
className={cn(
|
||||
"text-muted-foreground flex min-w-0 items-center gap-1 text-sm font-normal",
|
||||
align === "start" && "sm:min-h-8"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
{hint ? <RowHint label={`${label} info`}>{hint}</RowHint> : null}
|
||||
</FieldTitle>
|
||||
|
||||
{renderEdit ? (
|
||||
<div
|
||||
className={cn(
|
||||
"relative col-start-1 row-start-2 min-h-8 min-w-0 sm:col-start-2 sm:row-start-1",
|
||||
align === "start" ? "items-start" : "items-center"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!editable || controlsDisabled || editing}
|
||||
aria-label={`Edit ${label}`}
|
||||
aria-hidden={editing}
|
||||
className={cn(
|
||||
"group/value flex w-full min-w-0 rounded-md border border-transparent px-2.5 text-left transition-[opacity,color,background-color] duration-150 outline-none",
|
||||
align === "start"
|
||||
? "h-auto min-h-8 items-start py-1"
|
||||
: "h-8 items-center",
|
||||
editable &&
|
||||
"hover:bg-muted/40 active:bg-muted/40 sm:group-hover/row:bg-muted/40 focus-visible:border-transparent! focus-visible:ring-0! focus-visible:outline-none!",
|
||||
controlsDisabled && "pointer-events-none",
|
||||
editing
|
||||
? "pointer-events-none absolute inset-x-0 top-0 opacity-0"
|
||||
: "relative opacity-100"
|
||||
)}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0",
|
||||
align === "start" ? "items-start" : "items-center"
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{editable ? (
|
||||
<Item
|
||||
render={<span />}
|
||||
className={cn(
|
||||
"p-0",
|
||||
"text-muted-foreground ml-1.5 flex size-5 shrink-0 items-center justify-center opacity-100 transition-opacity sm:opacity-0 sm:group-hover/row:opacity-100 sm:group-focus-visible/value:opacity-100",
|
||||
controlsDisabled && "invisible opacity-0 sm:opacity-0"
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<PencilIcon className="size-3.5" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={editRef}
|
||||
aria-hidden={!editing}
|
||||
inert={!editing ? true : undefined}
|
||||
className={cn(
|
||||
"min-w-0 transition-opacity duration-150",
|
||||
editing
|
||||
? "relative opacity-100"
|
||||
: "pointer-events-none absolute inset-x-0 top-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-input! box-border w-full has-[[data-slot=input-group-control]:focus-visible]:shadow-none! has-[[data-slot=input-group-control]:focus-visible]:ring-0!",
|
||||
align === "start" ? "h-auto! min-h-8! items-start" : "h-8"
|
||||
)}
|
||||
>
|
||||
{renderEdit(controlActive)}
|
||||
{editable ? (
|
||||
<InputGroupAddon
|
||||
align="inline-end"
|
||||
className={cn(
|
||||
"gap-1 pr-2",
|
||||
align === "start" && "self-start pt-1"
|
||||
)}
|
||||
>
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Discard ${label}`}
|
||||
disabled={editActionsDisabled}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={saving ? `Saving ${label}` : `Save ${label}`}
|
||||
disabled={editActionsDisabled}
|
||||
onClick={onSave}
|
||||
>
|
||||
{saving ? (
|
||||
<Spinner className="size-3.5" />
|
||||
) : (
|
||||
<CheckIcon className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-start-1 row-start-2 flex min-h-8 min-w-0 items-center px-2.5 sm:col-start-2 sm:row-start-1">
|
||||
{display}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import { TicketDetailsForm } from "./components/ticket-details-form"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main className="flex min-h-svh w-full items-center justify-center px-4 py-6 sm:px-8 sm:py-10">
|
||||
<h1 className="sr-only">Inline editable ticket details form</h1>
|
||||
<TicketDetailsForm />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { type BadgeProps } from "@/components/reui/badge"
|
||||
import { CreditCardIcon, ShieldCheckIcon, WebhookIcon, BarChart3Icon, ReceiptIcon } from "lucide-react"
|
||||
|
||||
export type ReleaseColumnId =
|
||||
| "intake"
|
||||
| "review-needed"
|
||||
| "blocked"
|
||||
| "scheduled"
|
||||
| "ready"
|
||||
| "shipped"
|
||||
|
||||
export type ReleaseRisk = "Critical" | "High" | "Medium" | "Low"
|
||||
|
||||
export type ReleaseProgressTone =
|
||||
| "neutral"
|
||||
| "sky"
|
||||
| "violet"
|
||||
| "amber"
|
||||
| "rose"
|
||||
| "emerald"
|
||||
|
||||
export type ReleaseOwner = {
|
||||
name: string
|
||||
initials: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export type ReleaseService = {
|
||||
label: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export type ReleaseChange = {
|
||||
id: string
|
||||
changeKey: string
|
||||
title: string
|
||||
service: ReleaseService
|
||||
environment: string
|
||||
owner: ReleaseOwner
|
||||
risk: ReleaseRisk
|
||||
launchWindow: string
|
||||
checksDone: number
|
||||
checksTotal: number
|
||||
progressTone: ReleaseProgressTone
|
||||
}
|
||||
|
||||
export type ReleaseColumn = {
|
||||
id: ReleaseColumnId
|
||||
title: string
|
||||
description: string
|
||||
dotClassName: string
|
||||
addLabel: string
|
||||
}
|
||||
|
||||
const OWNERS = {
|
||||
maya: {
|
||||
name: "Maya Patel",
|
||||
initials: "MP",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
jonah: {
|
||||
name: "Jonah Lee",
|
||||
initials: "JL",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
nina: {
|
||||
name: "Nina Santos",
|
||||
initials: "NS",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
omar: {
|
||||
name: "Omar Haddad",
|
||||
initials: "OH",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
priya: {
|
||||
name: "Priya Menon",
|
||||
initials: "PM",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1557296387-5358ad7997bb?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
theo: {
|
||||
name: "Theo Vincent",
|
||||
initials: "TV",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507591064344-4c6ce005b128?w=96&h=96&dpr=2&q=80",
|
||||
},
|
||||
} satisfies Record<string, ReleaseOwner>
|
||||
|
||||
const SERVICES = {
|
||||
checkout: {
|
||||
label: "Checkout",
|
||||
icon: (
|
||||
<CreditCardIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
identity: {
|
||||
label: "Identity",
|
||||
icon: (
|
||||
<ShieldCheckIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
webhooks: {
|
||||
label: "Webhooks",
|
||||
icon: (
|
||||
<WebhookIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
analytics: {
|
||||
label: "Analytics",
|
||||
icon: (
|
||||
<BarChart3Icon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
billing: {
|
||||
label: "Billing",
|
||||
icon: (
|
||||
<ReceiptIcon className="size-4" aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
} satisfies Record<string, ReleaseService>
|
||||
|
||||
export const RISK_BADGE_VARIANT: Record<ReleaseRisk, BadgeProps["variant"]> = {
|
||||
Critical: "destructive-light",
|
||||
High: "warning-light",
|
||||
Medium: "info-light",
|
||||
Low: "secondary",
|
||||
}
|
||||
|
||||
export const RELEASE_BOARD_TITLE = "Release Readiness Board"
|
||||
export const RELEASE_BOARD_DESCRIPTION = "Readiness, risk, and launch checks."
|
||||
|
||||
export const RELEASE_COLUMNS: ReleaseColumn[] = [
|
||||
{
|
||||
id: "intake",
|
||||
title: "Intake",
|
||||
description: "New changes",
|
||||
dotClassName: "bg-muted-foreground/45",
|
||||
addLabel: "Add intake change",
|
||||
},
|
||||
{
|
||||
id: "review-needed",
|
||||
title: "Review Needed",
|
||||
description: "Waiting on approvers",
|
||||
dotClassName: "bg-info",
|
||||
addLabel: "Add review change",
|
||||
},
|
||||
{
|
||||
id: "blocked",
|
||||
title: "Blocked",
|
||||
description: "Needs escalation",
|
||||
dotClassName: "bg-destructive",
|
||||
addLabel: "Add blocked change",
|
||||
},
|
||||
{
|
||||
id: "scheduled",
|
||||
title: "Scheduled",
|
||||
description: "Window assigned",
|
||||
dotClassName: "bg-chart-2",
|
||||
addLabel: "Add scheduled change",
|
||||
},
|
||||
{
|
||||
id: "ready",
|
||||
title: "Ready",
|
||||
description: "Cleared to launch",
|
||||
dotClassName: "bg-success",
|
||||
addLabel: "Add ready change",
|
||||
},
|
||||
{
|
||||
id: "shipped",
|
||||
title: "Shipped",
|
||||
description: "Post-launch watch",
|
||||
dotClassName: "bg-success/70",
|
||||
addLabel: "Add shipped change",
|
||||
},
|
||||
]
|
||||
|
||||
export const INITIAL_RELEASE_CHANGES: Record<ReleaseColumnId, ReleaseChange[]> =
|
||||
{
|
||||
intake: [
|
||||
{
|
||||
id: "release-8429",
|
||||
changeKey: "REL-8429",
|
||||
title: "Enable saved cards for guest checkout",
|
||||
service: SERVICES.checkout,
|
||||
environment: "Production",
|
||||
owner: OWNERS.nina,
|
||||
risk: "Medium",
|
||||
launchWindow: "Apr 30, 20:00 UTC",
|
||||
checksDone: 1,
|
||||
checksTotal: 4,
|
||||
progressTone: "sky",
|
||||
},
|
||||
{
|
||||
id: "release-8427",
|
||||
changeKey: "REL-8427",
|
||||
title: "Add resend controls to login recovery",
|
||||
service: SERVICES.identity,
|
||||
environment: "Staging",
|
||||
owner: OWNERS.jonah,
|
||||
risk: "Low",
|
||||
launchWindow: "May 1, 16:30 UTC",
|
||||
checksDone: 2,
|
||||
checksTotal: 4,
|
||||
progressTone: "neutral",
|
||||
},
|
||||
{
|
||||
id: "release-8425",
|
||||
changeKey: "REL-8425",
|
||||
title: "Log receipt resend events for admins",
|
||||
service: SERVICES.billing,
|
||||
environment: "Staging",
|
||||
owner: OWNERS.theo,
|
||||
risk: "Low",
|
||||
launchWindow: "May 1, 19:00 UTC",
|
||||
checksDone: 1,
|
||||
checksTotal: 3,
|
||||
progressTone: "neutral",
|
||||
},
|
||||
],
|
||||
"review-needed": [
|
||||
{
|
||||
id: "release-8421",
|
||||
changeKey: "REL-8421",
|
||||
title: "Split EU checkout traffic by processor",
|
||||
service: SERVICES.checkout,
|
||||
environment: "Production",
|
||||
owner: OWNERS.maya,
|
||||
risk: "Critical",
|
||||
launchWindow: "Apr 30, 22:00 UTC",
|
||||
checksDone: 2,
|
||||
checksTotal: 5,
|
||||
progressTone: "rose",
|
||||
},
|
||||
{
|
||||
id: "release-8418",
|
||||
changeKey: "REL-8418",
|
||||
title: "Raise webhook retry ceiling for partners",
|
||||
service: SERVICES.webhooks,
|
||||
environment: "Production",
|
||||
owner: OWNERS.omar,
|
||||
risk: "High",
|
||||
launchWindow: "May 1, 18:30 UTC",
|
||||
checksDone: 3,
|
||||
checksTotal: 5,
|
||||
progressTone: "amber",
|
||||
},
|
||||
{
|
||||
id: "release-8417",
|
||||
changeKey: "REL-8417",
|
||||
title: "Confirm processor failover limits",
|
||||
service: SERVICES.checkout,
|
||||
environment: "Production",
|
||||
owner: OWNERS.priya,
|
||||
risk: "High",
|
||||
launchWindow: "May 1, 21:00 UTC",
|
||||
checksDone: 2,
|
||||
checksTotal: 5,
|
||||
progressTone: "amber",
|
||||
},
|
||||
],
|
||||
blocked: [
|
||||
{
|
||||
id: "release-8416",
|
||||
changeKey: "REL-8416",
|
||||
title: "Rotate signing keys for session tokens",
|
||||
service: SERVICES.identity,
|
||||
environment: "Production",
|
||||
owner: OWNERS.priya,
|
||||
risk: "High",
|
||||
launchWindow: "May 2, 19:30 UTC",
|
||||
checksDone: 2,
|
||||
checksTotal: 4,
|
||||
progressTone: "amber",
|
||||
},
|
||||
{
|
||||
id: "release-8413",
|
||||
changeKey: "REL-8413",
|
||||
title: "Rebuild analytics partitions for exports",
|
||||
service: SERVICES.analytics,
|
||||
environment: "Staging",
|
||||
owner: OWNERS.theo,
|
||||
risk: "Critical",
|
||||
launchWindow: "May 2, 23:00 UTC",
|
||||
checksDone: 1,
|
||||
checksTotal: 5,
|
||||
progressTone: "rose",
|
||||
},
|
||||
],
|
||||
scheduled: [
|
||||
{
|
||||
id: "release-8408",
|
||||
changeKey: "REL-8408",
|
||||
title: "Move invoice workers to new queues",
|
||||
service: SERVICES.billing,
|
||||
environment: "Staging",
|
||||
owner: OWNERS.theo,
|
||||
risk: "Medium",
|
||||
launchWindow: "May 3, 21:00 UTC",
|
||||
checksDone: 4,
|
||||
checksTotal: 5,
|
||||
progressTone: "sky",
|
||||
},
|
||||
{
|
||||
id: "release-8405",
|
||||
changeKey: "REL-8405",
|
||||
title: "Expand partner delivery event schema",
|
||||
service: SERVICES.webhooks,
|
||||
environment: "Production",
|
||||
owner: OWNERS.omar,
|
||||
risk: "Medium",
|
||||
launchWindow: "May 4, 17:30 UTC",
|
||||
checksDone: 3,
|
||||
checksTotal: 4,
|
||||
progressTone: "violet",
|
||||
},
|
||||
{
|
||||
id: "release-8402",
|
||||
changeKey: "REL-8402",
|
||||
title: "Reshard billing export queue",
|
||||
service: SERVICES.billing,
|
||||
environment: "Production",
|
||||
owner: OWNERS.nina,
|
||||
risk: "Medium",
|
||||
launchWindow: "May 4, 23:00 UTC",
|
||||
checksDone: 3,
|
||||
checksTotal: 5,
|
||||
progressTone: "sky",
|
||||
},
|
||||
],
|
||||
ready: [
|
||||
{
|
||||
id: "release-8399",
|
||||
changeKey: "REL-8399",
|
||||
title: "Open LATAM webhook fanout",
|
||||
service: SERVICES.webhooks,
|
||||
environment: "Production",
|
||||
owner: OWNERS.omar,
|
||||
risk: "Low",
|
||||
launchWindow: "May 5, 18:00 UTC",
|
||||
checksDone: 5,
|
||||
checksTotal: 5,
|
||||
progressTone: "emerald",
|
||||
},
|
||||
{
|
||||
id: "release-8394",
|
||||
changeKey: "REL-8394",
|
||||
title: "Ship invoice PDF branding controls",
|
||||
service: SERVICES.billing,
|
||||
environment: "Production",
|
||||
owner: OWNERS.nina,
|
||||
risk: "Low",
|
||||
launchWindow: "May 5, 20:30 UTC",
|
||||
checksDone: 4,
|
||||
checksTotal: 4,
|
||||
progressTone: "emerald",
|
||||
},
|
||||
],
|
||||
shipped: [
|
||||
{
|
||||
id: "release-8379",
|
||||
changeKey: "REL-8379",
|
||||
title: "Reduce invite session TTL",
|
||||
service: SERVICES.identity,
|
||||
environment: "Production",
|
||||
owner: OWNERS.maya,
|
||||
risk: "Low",
|
||||
launchWindow: "Apr 29, 17:00 UTC",
|
||||
checksDone: 3,
|
||||
checksTotal: 3,
|
||||
progressTone: "emerald",
|
||||
},
|
||||
{
|
||||
id: "release-8372",
|
||||
changeKey: "REL-8372",
|
||||
title: "Archive legacy checkout experiment flags",
|
||||
service: SERVICES.checkout,
|
||||
environment: "Production",
|
||||
owner: OWNERS.jonah,
|
||||
risk: "Low",
|
||||
launchWindow: "Apr 28, 15:00 UTC",
|
||||
checksDone: 4,
|
||||
checksTotal: 4,
|
||||
progressTone: "emerald",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
"use client"
|
||||
|
||||
import { useState, type ComponentProps, type ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
Kanban,
|
||||
KanbanBoard as KanbanBoardPrimitive,
|
||||
KanbanColumn,
|
||||
KanbanColumnContent,
|
||||
KanbanColumnHandle,
|
||||
KanbanItem,
|
||||
KanbanItemHandle,
|
||||
KanbanOverlay,
|
||||
} from "@/components/reui/kanban"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@cfdm/ui/components/avatar"
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemFooter,
|
||||
ItemHeader,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@cfdm/ui/components/item"
|
||||
import { Progress, ProgressLabel } from "@cfdm/ui/components/progress"
|
||||
import {
|
||||
INITIAL_RELEASE_CHANGES,
|
||||
RELEASE_BOARD_DESCRIPTION,
|
||||
RELEASE_BOARD_TITLE,
|
||||
RELEASE_COLUMNS,
|
||||
RISK_BADGE_VARIANT,
|
||||
type ReleaseChange,
|
||||
type ReleaseColumn,
|
||||
type ReleaseColumnId,
|
||||
type ReleaseProgressTone,
|
||||
} from "./data"
|
||||
import { FilterIcon, CalendarClockIcon, PlusIcon, GripVerticalIcon } from "lucide-react"
|
||||
|
||||
const RELEASE_COLUMN_BY_ID = new Map(
|
||||
RELEASE_COLUMNS.map((column) => [column.id, column])
|
||||
)
|
||||
|
||||
const COLUMN_HEADER_ACTION_BUTTON_CLASSNAME =
|
||||
"text-muted-foreground hover:border-border! hover:bg-background! hover:text-foreground border border-transparent bg-transparent"
|
||||
|
||||
const progressToneClass: Record<ReleaseProgressTone, string> = {
|
||||
amber: "**:data-[slot=progress-indicator]:bg-warning",
|
||||
emerald: "**:data-[slot=progress-indicator]:bg-success",
|
||||
neutral: "**:data-[slot=progress-indicator]:bg-muted-foreground/35",
|
||||
rose: "**:data-[slot=progress-indicator]:bg-destructive",
|
||||
sky: "**:data-[slot=progress-indicator]:bg-chart-2",
|
||||
violet: "**:data-[slot=progress-indicator]:bg-info",
|
||||
}
|
||||
|
||||
function BoardScrollArea({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className="relative w-full min-w-0 pb-3"
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 w-full rounded-lg transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
<ScrollAreaPrimitive.Content
|
||||
data-slot="scroll-area-content"
|
||||
className="w-max min-w-full"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Content>
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation="horizontal"
|
||||
orientation="horizontal"
|
||||
className="flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-foreground/15 relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function BoardToolbar() {
|
||||
return (
|
||||
<header className="px-1 py-1" aria-label="Release readiness toolbar">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-lg leading-7 font-semibold">
|
||||
{RELEASE_BOARD_TITLE}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-1 max-w-[44ch] text-sm leading-5">
|
||||
{RELEASE_BOARD_DESCRIPTION}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex w-full flex-wrap items-center gap-2 lg:w-auto lg:justify-end"
|
||||
role="group"
|
||||
aria-label="Release board actions"
|
||||
>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<FilterIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Filters
|
||||
</Button>
|
||||
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<CalendarClockIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Windows
|
||||
</Button>
|
||||
|
||||
<Button type="button" size="sm">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
New change
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseProgress({ change }: { change: ReleaseChange }) {
|
||||
const percent =
|
||||
change.checksTotal === 0
|
||||
? 100
|
||||
: Math.round((change.checksDone / change.checksTotal) * 100)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2 text-sm leading-5">
|
||||
<span className="text-muted-foreground">Checklist</span>
|
||||
<span className="text-foreground text-xs leading-4 tabular-nums">
|
||||
{change.checksDone}/{change.checksTotal} - {percent}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={percent}
|
||||
className={cn(
|
||||
"**:data-[slot=progress-track]:bg-muted gap-0 **:data-[slot=progress-indicator]:rounded-full **:data-[slot=progress-track]:h-1.5 **:data-[slot=progress-track]:rounded-full",
|
||||
progressToneClass[change.progressTone]
|
||||
)}
|
||||
>
|
||||
<ProgressLabel className="sr-only">
|
||||
{change.changeKey} checklist progress
|
||||
</ProgressLabel>
|
||||
</Progress>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseMetaRow({
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-2 text-sm leading-5">
|
||||
<Item
|
||||
render={<span />}
|
||||
className="text-muted-foreground flex size-5 items-center justify-center border-0 p-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ReleaseCardProps extends Omit<
|
||||
ComponentProps<typeof KanbanItem>,
|
||||
"value" | "children"
|
||||
> {
|
||||
change: ReleaseChange
|
||||
isOverlay?: boolean
|
||||
}
|
||||
|
||||
function ReleaseCard({ change, isOverlay, ...props }: ReleaseCardProps) {
|
||||
const item = (
|
||||
<Item
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"bg-card hover:bg-muted/20 items-stretch gap-3 transition-colors",
|
||||
isOverlay && "shadow-lg"
|
||||
)}
|
||||
>
|
||||
<ItemHeader className="min-w-0 items-start gap-2.5">
|
||||
<ItemContent className="min-w-0 gap-1">
|
||||
<ItemDescription className="text-muted-foreground text-xs leading-4 font-medium tracking-normal tabular-nums">
|
||||
{change.changeKey}
|
||||
</ItemDescription>
|
||||
<ItemTitle
|
||||
className="line-clamp-2 text-[0.9375rem] leading-5 font-medium"
|
||||
title={change.title}
|
||||
>
|
||||
{change.title}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions className="shrink-0">
|
||||
<Badge variant={RISK_BADGE_VARIANT[change.risk]}>{change.risk}</Badge>
|
||||
</ItemActions>
|
||||
</ItemHeader>
|
||||
|
||||
<ItemContent className="min-w-0 gap-2.5">
|
||||
<ReleaseMetaRow icon={change.service.icon}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{change.service.label}</span>
|
||||
<Badge variant="outline" size="sm" className="bg-background">
|
||||
{change.environment}
|
||||
</Badge>
|
||||
</div>
|
||||
</ReleaseMetaRow>
|
||||
|
||||
<ReleaseMetaRow
|
||||
icon={
|
||||
<CalendarClockIcon className="size-4" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate tabular-nums">{change.launchWindow}</span>
|
||||
</div>
|
||||
</ReleaseMetaRow>
|
||||
|
||||
<ReleaseMetaRow
|
||||
icon={
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={change.owner.avatar} alt="" />
|
||||
<AvatarFallback className="text-[0.5rem] font-semibold">
|
||||
{change.owner.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<span className="truncate font-medium">{change.owner.name}</span>
|
||||
</ReleaseMetaRow>
|
||||
</ItemContent>
|
||||
|
||||
<ItemFooter className="min-w-0 flex-col items-stretch gap-2">
|
||||
<ReleaseProgress change={change} />
|
||||
</ItemFooter>
|
||||
</Item>
|
||||
)
|
||||
|
||||
return (
|
||||
<KanbanItem value={change.id} {...props}>
|
||||
{isOverlay ? (
|
||||
item
|
||||
) : (
|
||||
<KanbanItemHandle className="block">{item}</KanbanItemHandle>
|
||||
)}
|
||||
</KanbanItem>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyColumn({ column }: { column: ReleaseColumn }) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-muted-foreground hover:text-foreground bg-background/70 h-20 w-full border-dashed text-sm"
|
||||
aria-label={column.addLabel}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{column.addLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function ColumnHeaderActions({
|
||||
column,
|
||||
isOverlay,
|
||||
}: {
|
||||
column: ReleaseColumn
|
||||
isOverlay?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within/kanban-column:opacity-100 group-hover/kanban-column:opacity-100",
|
||||
isOverlay && "hidden"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={COLUMN_HEADER_ACTION_BUTTON_CLASSNAME}
|
||||
aria-label={column.addLabel}
|
||||
title={column.addLabel}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<KanbanColumnHandle
|
||||
className="group-focus-within/kanban-column:opacity-100"
|
||||
render={({ className, ...handleProps }) => (
|
||||
<Button
|
||||
{...handleProps}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Move ${column.title} column`}
|
||||
title={`Move ${column.title} column`}
|
||||
className={cn(COLUMN_HEADER_ACTION_BUTTON_CLASSNAME, className)}
|
||||
>
|
||||
<GripVerticalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ReleaseColumnProps extends Omit<
|
||||
ComponentProps<typeof KanbanColumn>,
|
||||
"value" | "children"
|
||||
> {
|
||||
column: ReleaseColumn
|
||||
changes: ReleaseChange[]
|
||||
isOverlay?: boolean
|
||||
}
|
||||
|
||||
function ReleaseColumnView({
|
||||
column,
|
||||
changes,
|
||||
isOverlay,
|
||||
...props
|
||||
}: ReleaseColumnProps) {
|
||||
return (
|
||||
<KanbanColumn
|
||||
value={column.id}
|
||||
className="w-[calc(100vw-3rem)] max-w-[19rem] shrink-0 sm:w-[19rem]"
|
||||
{...props}
|
||||
>
|
||||
<Frame
|
||||
spacing="sm"
|
||||
className={cn("group/column", isOverlay && "shadow-lg")}
|
||||
aria-label={`${column.title}: ${column.description}`}
|
||||
>
|
||||
<FrameHeader className="flex min-h-10 flex-row items-center gap-2 px-2 py-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2.5 shrink-0 rounded-full",
|
||||
column.dotClassName
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<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">
|
||||
{changes.length}
|
||||
</span>
|
||||
<ColumnHeaderActions column={column} isOverlay={isOverlay} />
|
||||
</FrameHeader>
|
||||
|
||||
<KanbanColumnContent value={column.id} className="gap-2 p-0.5">
|
||||
{changes.map((change) => (
|
||||
<ReleaseCard key={change.id} change={change} />
|
||||
))}
|
||||
{changes.length === 0 ? <EmptyColumn column={column} /> : null}
|
||||
</KanbanColumnContent>
|
||||
</Frame>
|
||||
</KanbanColumn>
|
||||
)
|
||||
}
|
||||
|
||||
function findReleaseChange(
|
||||
columns: Record<string, ReleaseChange[]>,
|
||||
changeId: string
|
||||
) {
|
||||
for (const changes of Object.values(columns)) {
|
||||
const change = changes.find((item) => item.id === changeId)
|
||||
|
||||
if (change) {
|
||||
return change
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function KanbanBoard() {
|
||||
const [changesByColumn, setChangesByColumn] = useState<
|
||||
Record<string, ReleaseChange[]>
|
||||
>(() => INITIAL_RELEASE_CHANGES)
|
||||
|
||||
return (
|
||||
<section className="mx-auto flex w-full max-w-[1280px] flex-col gap-4">
|
||||
<BoardToolbar />
|
||||
|
||||
<Kanban
|
||||
value={changesByColumn}
|
||||
onValueChange={setChangesByColumn}
|
||||
getItemValue={(item) => item.id}
|
||||
className="w-full"
|
||||
>
|
||||
<BoardScrollArea>
|
||||
<KanbanBoardPrimitive className="grid min-w-max auto-cols-[19rem] grid-flow-col grid-cols-none items-start gap-3 p-1">
|
||||
{Object.entries(changesByColumn).map(([columnId, changes]) => {
|
||||
const column = RELEASE_COLUMN_BY_ID.get(
|
||||
columnId as ReleaseColumnId
|
||||
)
|
||||
|
||||
if (!column) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ReleaseColumnView
|
||||
key={columnId}
|
||||
column={column}
|
||||
changes={changes}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</KanbanBoardPrimitive>
|
||||
</BoardScrollArea>
|
||||
|
||||
<KanbanOverlay>
|
||||
{({ value, variant }) => {
|
||||
if (variant === "column") {
|
||||
const column = RELEASE_COLUMN_BY_ID.get(
|
||||
String(value) as ReleaseColumnId
|
||||
)
|
||||
|
||||
if (!column) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ReleaseColumnView
|
||||
column={column}
|
||||
changes={changesByColumn[column.id] ?? []}
|
||||
isOverlay
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const change = findReleaseChange(changesByColumn, String(value))
|
||||
|
||||
return change ? <ReleaseCard change={change} isOverlay /> : null
|
||||
}}
|
||||
</KanbanOverlay>
|
||||
</Kanban>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { KanbanBoard } from "./components/kanban-board"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="bg-background flex w-full justify-center p-4 sm:p-6"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Release readiness kanban board
|
||||
</h1>
|
||||
<KanbanBoard />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Alert,
|
||||
AlertAction,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/reui/alert"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { ShieldCheckIcon } from "lucide-react"
|
||||
|
||||
export function AccessReviewAlert() {
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<ShieldCheckIcon aria-hidden="true" />
|
||||
<AlertTitle>Access review</AlertTitle>
|
||||
{/* Description */}
|
||||
<AlertDescription>
|
||||
Review billing roles and approvers before renewal.
|
||||
</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
toast.message("Dismissed", {
|
||||
description:
|
||||
"Hide the billing access reminder in your app state.",
|
||||
})
|
||||
}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
toast.info("Review access", {
|
||||
description: "Open your billing roles and approver review flow.",
|
||||
})
|
||||
}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { Checkbox } from "@cfdm/ui/components/checkbox"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldTitle,
|
||||
} from "@cfdm/ui/components/field"
|
||||
|
||||
import { SEAT_LIMIT_OPTIONS } from "./data"
|
||||
|
||||
export function BillingSeatSelectionCards() {
|
||||
const [selectedSeatLimit, setSelectedSeatLimit] = useState(
|
||||
SEAT_LIMIT_OPTIONS[1]?.value ?? ""
|
||||
)
|
||||
|
||||
return (
|
||||
<FieldGroup className="grid w-full gap-3 md:grid-cols-3">
|
||||
{SEAT_LIMIT_OPTIONS.map((option) => {
|
||||
const checked = option.value === selectedSeatLimit
|
||||
const inputId = `billing-seat-${option.value}`
|
||||
|
||||
return (
|
||||
<FieldLabel
|
||||
key={option.value}
|
||||
htmlFor={inputId}
|
||||
className="relative flex h-full w-full min-w-0 cursor-pointer p-0"
|
||||
>
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className={cn(
|
||||
"relative h-full w-full px-2 py-2 shadow-xs transition-colors",
|
||||
checked
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-border/70 hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={inputId}
|
||||
checked={checked}
|
||||
onCheckedChange={(nextChecked) => {
|
||||
if (nextChecked) {
|
||||
setSelectedSeatLimit(option.value)
|
||||
}
|
||||
}}
|
||||
aria-label={option.label}
|
||||
className="absolute -top-2 -right-2 size-5 rounded-full border-none shadow-none"
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<FieldTitle className="text-sm font-medium">
|
||||
{option.label}
|
||||
</FieldTitle>
|
||||
<FieldDescription className="line-clamp-2 text-sm leading-5">
|
||||
{option.description}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
)
|
||||
})}
|
||||
</FieldGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { FieldGroup } from "@cfdm/ui/components/field"
|
||||
import { Input } from "@cfdm/ui/components/input"
|
||||
import { Switch } from "@cfdm/ui/components/switch"
|
||||
|
||||
import { AccessReviewAlert } from "./access-review-alert"
|
||||
import { BillingSeatSelectionCards } from "./billing-seat-selection-cards"
|
||||
import { DIGEST_OPTIONS, WORKSPACE_IDENTITY } from "./data"
|
||||
import {
|
||||
BillingAmountField,
|
||||
BillingApproversCombobox,
|
||||
ProfileAdvancedSelectField,
|
||||
} from "./profile-form-fields"
|
||||
import { BillingSummaryFrame } from "./profile-summary-frames"
|
||||
import { SettingRow } from "./setting-row"
|
||||
|
||||
export function BillingTabContent() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<AccessReviewAlert />
|
||||
|
||||
<Frame spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle className="capitalize">Billing setup</FrameTitle>
|
||||
<FrameDescription>Plan, seats, and invoices.</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0!">
|
||||
<FieldGroup className="gap-0">
|
||||
<SettingRow
|
||||
title="Current plan"
|
||||
description="Includes scale, audit, and admin tools."
|
||||
titleAddon={
|
||||
<Badge variant="primary-light" size="sm">
|
||||
Current
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<div className="flex w-full flex-col items-end justify-end text-right">
|
||||
<span className="text-sm font-medium">$149 / workspace</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Billed monthly
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Billing owner"
|
||||
description="Primary approver for plan changes."
|
||||
labelFor="billing-owner"
|
||||
>
|
||||
<Input
|
||||
id="billing-owner"
|
||||
defaultValue={WORKSPACE_IDENTITY.billingOwner}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Invoice approvers"
|
||||
description="Users who approve plan changes."
|
||||
>
|
||||
<BillingApproversCombobox />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow title="Invoice email" labelFor="billing-email">
|
||||
<Input
|
||||
id="billing-email"
|
||||
defaultValue={WORKSPACE_IDENTITY.invoiceEmail}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow title="Cost center" labelFor="billing-cost-center">
|
||||
<Input
|
||||
id="billing-cost-center"
|
||||
defaultValue={WORKSPACE_IDENTITY.costCenter}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Seat selection"
|
||||
description="Set team capacity."
|
||||
stacked
|
||||
contentClassName="@md/field-group:max-w-none"
|
||||
last
|
||||
>
|
||||
<BillingSeatSelectionCards />
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="flex-row justify-end gap-2">
|
||||
<Button type="button" variant="outline">
|
||||
Manage plan
|
||||
</Button>
|
||||
<Button type="button">Update billing</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
|
||||
<Frame spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle className="capitalize">Billing controls</FrameTitle>
|
||||
<FrameDescription>Approval and renewal rules.</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0!">
|
||||
<FieldGroup className="gap-0">
|
||||
<SettingRow
|
||||
title="Auto-add seats"
|
||||
description="Add seats when invites exceed your cap."
|
||||
labelFor="billing-auto-add-seats"
|
||||
compact
|
||||
>
|
||||
<Switch id="billing-auto-add-seats" />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Purchase order required"
|
||||
description="Hold invoice changes until a PO is present."
|
||||
labelFor="billing-po-required"
|
||||
compact
|
||||
>
|
||||
<Switch id="billing-po-required" defaultChecked />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Approval threshold"
|
||||
description="Invoices above this need approval."
|
||||
compact
|
||||
>
|
||||
<BillingAmountField id="billing-approval-threshold" />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow title="Invoice reminders" compact last>
|
||||
<ProfileAdvancedSelectField
|
||||
id="billing-reminders"
|
||||
options={DIGEST_OPTIONS}
|
||||
defaultValue={DIGEST_OPTIONS[2]}
|
||||
/>
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="flex-row justify-end gap-2">
|
||||
<Button type="button" variant="outline">
|
||||
Review approvals
|
||||
</Button>
|
||||
<Button type="button" variant="outline">
|
||||
Review plans
|
||||
</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
|
||||
<BillingSummaryFrame />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
import { BRAND_COLORS } from "./data"
|
||||
|
||||
export function BrandAccentPicker() {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Accent color"
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
{BRAND_COLORS.map((color) => (
|
||||
<label key={color.id} className="relative cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="profile-1-brand-accent"
|
||||
value={color.id}
|
||||
defaultChecked={color.active}
|
||||
aria-label={color.label}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"ring-offset-background peer-checked:ring-foreground/70 peer-focus-visible:ring-foreground/70 hover:ring-foreground/20 flex size-6 items-center justify-center rounded-full border border-white/80 shadow-sm ring-offset-2 transition-[box-shadow] peer-checked:ring-2 peer-focus-visible:ring-2 hover:ring-2"
|
||||
)}
|
||||
style={{ backgroundColor: color.value }}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { type BadgeProps } from "@/components/reui/badge"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface SummaryMetric {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
detail?: string
|
||||
badge?: {
|
||||
label: string
|
||||
variant: BadgeProps["variant"]
|
||||
}
|
||||
}
|
||||
|
||||
export interface BillingSpendCard {
|
||||
id: "spend" | "seats" | "invoice"
|
||||
title: string
|
||||
subtitle: string
|
||||
value: string
|
||||
detail: string
|
||||
badge: {
|
||||
label: string
|
||||
variant: BadgeProps["variant"]
|
||||
}
|
||||
}
|
||||
|
||||
export interface TeamMember {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
department: string
|
||||
email: string
|
||||
access: TeamMemberAccess
|
||||
status: TeamMemberStatus
|
||||
availability: TeamMemberAvailability
|
||||
location: string
|
||||
timezone: string
|
||||
joined: string
|
||||
lastActive: string
|
||||
src: string
|
||||
initials: string
|
||||
}
|
||||
|
||||
export interface ProfileIdentity {
|
||||
name: string
|
||||
username: string
|
||||
email: string
|
||||
phone: string
|
||||
website: string
|
||||
bio: string
|
||||
}
|
||||
|
||||
export interface WorkspaceIdentity {
|
||||
name: string
|
||||
subdomain: string
|
||||
domainSuffix: string
|
||||
supportEmail: string
|
||||
billingOwner: string
|
||||
invoiceEmail: string
|
||||
costCenter: string
|
||||
}
|
||||
|
||||
export interface BrandColor {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export interface TimezoneGroup {
|
||||
value: string
|
||||
items: string[]
|
||||
}
|
||||
|
||||
export type TeamMemberStatus = "Active" | "Invited" | "Limited" | "Suspended"
|
||||
|
||||
export type TeamMemberAvailability = "online" | "away" | "busy" | "offline"
|
||||
|
||||
export type TeamMemberAccess = "Owner" | "Admin" | "Member"
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const PROFILE_IDENTITY: ProfileIdentity = {
|
||||
name: "Ava Chen",
|
||||
username: "avachen",
|
||||
email: "[email protected]",
|
||||
phone: "+17185550188",
|
||||
website: "ava.atlashq.app",
|
||||
bio: "Leading launch systems, product operations, and internal tooling across the Atlas workspace.",
|
||||
}
|
||||
|
||||
export const WORKSPACE_IDENTITY: WorkspaceIdentity = {
|
||||
name: "Atlas",
|
||||
subdomain: "hq",
|
||||
domainSuffix: ".atlashq.app",
|
||||
supportEmail: "[email protected]",
|
||||
billingOwner: "[email protected]",
|
||||
invoiceEmail: "[email protected]",
|
||||
costCenter: "RevOps / Growth",
|
||||
}
|
||||
|
||||
export const TEAM_MEMBERS: TeamMember[] = [
|
||||
{
|
||||
id: "ava",
|
||||
name: "Ava Chen",
|
||||
role: "Owner",
|
||||
department: "Product · Leadership",
|
||||
email: "[email protected]",
|
||||
access: "Owner",
|
||||
status: "Active",
|
||||
availability: "online",
|
||||
location: "Brooklyn, NY",
|
||||
timezone: "EST (UTC-5)",
|
||||
joined: "Jan 2024",
|
||||
lastActive: "Active now",
|
||||
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&h=80&dpr=2&q=80",
|
||||
initials: "AC",
|
||||
},
|
||||
{
|
||||
id: "milo",
|
||||
name: "Milo Harper",
|
||||
role: "Growth Ops",
|
||||
department: "Revenue · Growth",
|
||||
email: "[email protected]",
|
||||
access: "Admin",
|
||||
status: "Active",
|
||||
availability: "away",
|
||||
location: "San Francisco, CA",
|
||||
timezone: "PST (UTC-8)",
|
||||
joined: "Mar 2024",
|
||||
lastActive: "10 min ago",
|
||||
src: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=80&h=80&dpr=2&q=80",
|
||||
initials: "MH",
|
||||
},
|
||||
{
|
||||
id: "nina",
|
||||
name: "Nina Park",
|
||||
role: "Design Systems",
|
||||
department: "Design · Systems",
|
||||
email: "[email protected]",
|
||||
access: "Member",
|
||||
status: "Active",
|
||||
availability: "busy",
|
||||
location: "Toronto, CA",
|
||||
timezone: "EST (UTC-5)",
|
||||
joined: "Jun 2023",
|
||||
lastActive: "2 hours ago",
|
||||
src: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=80&h=80&dpr=2&q=80",
|
||||
initials: "NP",
|
||||
},
|
||||
{
|
||||
id: "omar",
|
||||
name: "Omar Reyes",
|
||||
role: "Customer Success",
|
||||
department: "Support · Success",
|
||||
email: "[email protected]",
|
||||
access: "Member",
|
||||
status: "Limited",
|
||||
availability: "offline",
|
||||
location: "Austin, TX",
|
||||
timezone: "CST (UTC-6)",
|
||||
joined: "Sep 2024",
|
||||
lastActive: "Today",
|
||||
src: "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=80&h=80&dpr=2&q=80",
|
||||
initials: "OR",
|
||||
},
|
||||
{
|
||||
id: "maya",
|
||||
name: "Maya Singh",
|
||||
role: "Revenue Ops",
|
||||
department: "Finance · Ops",
|
||||
email: "[email protected]",
|
||||
access: "Admin",
|
||||
status: "Invited",
|
||||
availability: "away",
|
||||
location: "Chicago, IL",
|
||||
timezone: "CST (UTC-6)",
|
||||
joined: "Invited Apr 2026",
|
||||
lastActive: "Yesterday",
|
||||
src: "https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=80&h=80&dpr=2&q=80",
|
||||
initials: "MS",
|
||||
},
|
||||
{
|
||||
id: "leo",
|
||||
name: "Leo Grant",
|
||||
role: "Platform Engineer",
|
||||
department: "Engineering · Platform",
|
||||
email: "[email protected]",
|
||||
access: "Member",
|
||||
status: "Suspended",
|
||||
availability: "offline",
|
||||
location: "Seattle, WA",
|
||||
timezone: "PST (UTC-8)",
|
||||
joined: "Nov 2023",
|
||||
lastActive: "3 days ago",
|
||||
src: "https://images.unsplash.com/photo-1504593811423-6dd665756598?w=80&h=80&dpr=2&q=80",
|
||||
initials: "LG",
|
||||
},
|
||||
]
|
||||
|
||||
export const TEAM_STATUS_ORDER: TeamMemberStatus[] = [
|
||||
"Active",
|
||||
"Invited",
|
||||
"Limited",
|
||||
"Suspended",
|
||||
]
|
||||
|
||||
export const WORKSPACE_SIGNALS: SummaryMetric[] = [
|
||||
{
|
||||
id: "domain",
|
||||
label: "Verified domain",
|
||||
value: "atlashq.app",
|
||||
detail: "Custom links, invites, and shared pages resolve here.",
|
||||
badge: {
|
||||
label: "Active",
|
||||
variant: "success-light",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "region",
|
||||
label: "Data region",
|
||||
value: "US East",
|
||||
detail: "Primary routing for customer and workspace data.",
|
||||
},
|
||||
{
|
||||
id: "theme",
|
||||
label: "Brand posture",
|
||||
value: "Teal accent",
|
||||
detail: "Applied to shared flows and workspace navigation.",
|
||||
},
|
||||
]
|
||||
|
||||
export const BILLING_SPEND_CARDS: BillingSpendCard[] = [
|
||||
{
|
||||
id: "spend",
|
||||
title: "Monthly spend",
|
||||
subtitle: "Business plan",
|
||||
value: "$149",
|
||||
detail: "Base workspace plan before seat growth or annual billing changes.",
|
||||
badge: {
|
||||
label: "Current",
|
||||
variant: "success-light",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "seats",
|
||||
title: "Seat usage",
|
||||
subtitle: "12 of 24 seats",
|
||||
value: "12",
|
||||
detail: "Half of the available seats are assigned across the workspace.",
|
||||
badge: {
|
||||
label: "12 open",
|
||||
variant: "info-light",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "invoice",
|
||||
title: "Next invoice",
|
||||
subtitle: "May 28, 2026",
|
||||
value: "$149",
|
||||
detail: "Approvers, reminders, and the PO rule are already attached.",
|
||||
badge: {
|
||||
label: "PO ready",
|
||||
variant: "warning-light",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const TIMEZONE_GROUPS: TimezoneGroup[] = [
|
||||
{
|
||||
value: "Americas",
|
||||
items: [
|
||||
"(GMT-5) New York",
|
||||
"(GMT-8) Los Angeles",
|
||||
"(GMT-6) Chicago",
|
||||
"(GMT-5) Toronto",
|
||||
"(GMT-8) Vancouver",
|
||||
"(GMT-3) Sao Paulo",
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "Europe",
|
||||
items: [
|
||||
"(GMT+0) London",
|
||||
"(GMT+1) Paris",
|
||||
"(GMT+1) Berlin",
|
||||
"(GMT+1) Rome",
|
||||
"(GMT+1) Madrid",
|
||||
"(GMT+1) Amsterdam",
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "Asia/Pacific",
|
||||
items: [
|
||||
"(GMT+9) Tokyo",
|
||||
"(GMT+8) Shanghai",
|
||||
"(GMT+8) Singapore",
|
||||
"(GMT+4) Dubai",
|
||||
"(GMT+11) Sydney",
|
||||
"(GMT+9) Seoul",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const ROLE_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
value: "staff-product-lead",
|
||||
label: "Staff Product Lead",
|
||||
description: "Owns product operations",
|
||||
},
|
||||
{
|
||||
value: "product-manager",
|
||||
label: "Product Manager",
|
||||
description: "Drives roadmap execution",
|
||||
},
|
||||
{
|
||||
value: "design-lead",
|
||||
label: "Design Lead",
|
||||
description: "Owns system quality",
|
||||
},
|
||||
]
|
||||
|
||||
export const LANGUAGE_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
value: "en-us",
|
||||
label: "English (US)",
|
||||
description: "Default product language",
|
||||
},
|
||||
{
|
||||
value: "en-uk",
|
||||
label: "English (UK)",
|
||||
description: "Common team alternative",
|
||||
},
|
||||
{
|
||||
value: "de",
|
||||
label: "German",
|
||||
description: "For regional ops",
|
||||
},
|
||||
]
|
||||
|
||||
export const LANDING_VIEW_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
value: "home",
|
||||
label: "Home overview",
|
||||
description: "Recent activity first",
|
||||
},
|
||||
{
|
||||
value: "pipeline",
|
||||
label: "Pipeline board",
|
||||
description: "Jump into execution",
|
||||
},
|
||||
{
|
||||
value: "inbox",
|
||||
label: "Priority inbox",
|
||||
description: "Focus on approvals",
|
||||
},
|
||||
]
|
||||
|
||||
export const DIGEST_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
value: "weekday-am",
|
||||
label: "Weekday mornings",
|
||||
description: "Sent before standup",
|
||||
},
|
||||
{
|
||||
value: "daily-pm",
|
||||
label: "Daily evenings",
|
||||
description: "Wrap the workday",
|
||||
},
|
||||
{
|
||||
value: "weekly",
|
||||
label: "Weekly summary",
|
||||
description: "Monday planning recap",
|
||||
},
|
||||
]
|
||||
|
||||
export const SEAT_LIMIT_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
value: "16",
|
||||
label: "16 seats",
|
||||
description: "Best for your current team size and smooth growth",
|
||||
},
|
||||
{
|
||||
value: "24",
|
||||
label: "24 seats",
|
||||
description: "Gives your team room for future growth",
|
||||
},
|
||||
{
|
||||
value: "40",
|
||||
label: "40 seats",
|
||||
description: "Supports the next hiring cycle with 16 open seats",
|
||||
},
|
||||
]
|
||||
|
||||
export const BRAND_COLORS: BrandColor[] = [
|
||||
{ id: "graphite", label: "Graphite", value: "#5B6170" },
|
||||
{ id: "cobalt", label: "Cobalt", value: "#5470F7" },
|
||||
{ id: "teal", label: "Teal", value: "#159E9A", active: true },
|
||||
{ id: "mint", label: "Mint", value: "#44BA84" },
|
||||
{ id: "orange", label: "Orange", value: "#EA7B18" },
|
||||
{ id: "rose", label: "Rose", value: "#E65A8B" },
|
||||
]
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from "react"
|
||||
import { useFileUpload } from "@/hooks/use-file-upload"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@cfdm/ui/components/avatar"
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { UserCircle, ImageIcon, XIcon, UploadIcon } from "lucide-react"
|
||||
|
||||
interface ImageUploadFieldProps {
|
||||
ariaLabel: string
|
||||
inputId?: string
|
||||
variant?: "avatar" | "image"
|
||||
defaultImage?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export function ImageUploadField({
|
||||
ariaLabel,
|
||||
inputId,
|
||||
variant = "image",
|
||||
defaultImage,
|
||||
alt = "Uploaded image",
|
||||
}: ImageUploadFieldProps) {
|
||||
const [removedCurrentImage, setRemovedCurrentImage] = useState(false)
|
||||
const [{ files }, { removeFile, openFileDialog, getInputProps }] =
|
||||
useFileUpload({
|
||||
accept: "image/*",
|
||||
})
|
||||
|
||||
const currentFile = files[0] ?? null
|
||||
const hasSavedImage = Boolean(defaultImage) && !removedCurrentImage
|
||||
const previewUrl =
|
||||
currentFile?.preview ?? (hasSavedImage ? (defaultImage ?? null) : null)
|
||||
const fileName = currentFile?.file.name
|
||||
const hasImage = Boolean(previewUrl)
|
||||
|
||||
const handleCancelUpload = () => {
|
||||
if (!currentFile) {
|
||||
return
|
||||
}
|
||||
|
||||
removeFile(currentFile.id)
|
||||
}
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
if (currentFile) {
|
||||
removeFile(currentFile.id)
|
||||
}
|
||||
|
||||
setRemovedCurrentImage(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-wrap items-center justify-start gap-2">
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
className={cn(
|
||||
"size-10 border",
|
||||
variant === "avatar"
|
||||
? "rounded-full"
|
||||
: "rounded-lg after:rounded-lg"
|
||||
)}
|
||||
>
|
||||
<AvatarImage
|
||||
src={previewUrl ?? undefined}
|
||||
alt={fileName ?? alt}
|
||||
className={cn(variant === "avatar" ? "rounded-full" : "rounded-lg")}
|
||||
/>
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground",
|
||||
variant === "avatar" ? "rounded-full" : "rounded-lg"
|
||||
)}
|
||||
>
|
||||
{variant === "avatar" ? (
|
||||
<UserCircle aria-hidden="true" className="size-4 opacity-60" />
|
||||
) : (
|
||||
<ImageIcon aria-hidden="true" className="size-4 opacity-60" />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{currentFile ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
onClick={handleCancelUpload}
|
||||
className="absolute -top-1 -right-1 size-4 rounded-full"
|
||||
aria-label={`Cancel ${currentFile.file.name}`}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="relative inline-flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openFileDialog}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<UploadIcon aria-hidden="true" />
|
||||
{hasImage ? "Change" : "Upload"}
|
||||
</Button>
|
||||
<input
|
||||
{...getInputProps({ id: inputId })}
|
||||
className="sr-only"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasImage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRemoveImage}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment } from "react"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@cfdm/ui/components/avatar"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@cfdm/ui/components/combobox"
|
||||
import { Field } from "@cfdm/ui/components/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@cfdm/ui/components/input-group"
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle,
|
||||
} from "@cfdm/ui/components/item"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@cfdm/ui/components/select"
|
||||
|
||||
import { TEAM_MEMBERS, TIMEZONE_GROUPS, type SelectOption } from "./data"
|
||||
|
||||
const BILLING_APPROVER_OPTIONS = TEAM_MEMBERS.map((member) => ({
|
||||
...member,
|
||||
position: member.role,
|
||||
}))
|
||||
|
||||
export function ProfileAdvancedSelectField({
|
||||
id,
|
||||
options,
|
||||
defaultValue,
|
||||
}: {
|
||||
id: string
|
||||
options: SelectOption[]
|
||||
defaultValue: SelectOption
|
||||
}) {
|
||||
return (
|
||||
<Field className="w-full">
|
||||
<Select defaultValue={defaultValue} items={options}>
|
||||
<SelectTrigger id={id} className="w-full [&_small]:hidden">
|
||||
<SelectValue>
|
||||
{(item: SelectOption | null) =>
|
||||
item ? (
|
||||
<span className="flex flex-col items-start gap-px">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<small className="text-muted-foreground text-sm">
|
||||
{item.description}
|
||||
</small>
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent align="end" className="w-(--anchor-width)">
|
||||
<SelectGroup>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option}
|
||||
className="[&_svg]:text-primary"
|
||||
>
|
||||
<span className="flex flex-col items-start gap-px">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<small className="text-muted-foreground text-sm">
|
||||
{option.description}
|
||||
</small>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileCompactSelectField({
|
||||
id,
|
||||
options,
|
||||
defaultValue,
|
||||
}: {
|
||||
id: string
|
||||
options: SelectOption[]
|
||||
defaultValue: SelectOption
|
||||
}) {
|
||||
return (
|
||||
<Select defaultValue={defaultValue} items={options}>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue>
|
||||
{(item: SelectOption | null) => item?.label ?? null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
{/* Content */}
|
||||
<SelectContent className="w-(--anchor-width)">
|
||||
<SelectGroup>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileTimezoneField({
|
||||
id,
|
||||
placeholder = "Select a timezone",
|
||||
}: {
|
||||
id?: string
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<Field className="w-full">
|
||||
<Combobox
|
||||
items={TIMEZONE_GROUPS}
|
||||
defaultValue={TIMEZONE_GROUPS[0]?.items[0]}
|
||||
>
|
||||
<ComboboxInput id={id} placeholder={placeholder} className="w-full" />
|
||||
<ComboboxContent className="w-(--anchor-width) min-w-(--anchor-width)">
|
||||
<ComboboxEmpty>No timezones found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(group) => (
|
||||
<ComboboxGroup key={group.value} items={group.items}>
|
||||
<ComboboxLabel>{group.value}</ComboboxLabel>
|
||||
<ComboboxCollection>
|
||||
{(item) => (
|
||||
<ComboboxItem key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxCollection>
|
||||
</ComboboxGroup>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export function BillingApproversCombobox() {
|
||||
const anchor = useComboboxAnchor()
|
||||
|
||||
return (
|
||||
<Field className="w-full">
|
||||
<Combobox
|
||||
multiple
|
||||
items={BILLING_APPROVER_OPTIONS}
|
||||
itemToStringValue={(
|
||||
member: (typeof BILLING_APPROVER_OPTIONS)[number]
|
||||
) => member.name}
|
||||
defaultValue={[
|
||||
BILLING_APPROVER_OPTIONS[0],
|
||||
BILLING_APPROVER_OPTIONS[1],
|
||||
BILLING_APPROVER_OPTIONS[4],
|
||||
]}
|
||||
>
|
||||
<ComboboxChips
|
||||
ref={anchor}
|
||||
className="w-full has-data-[slot=combobox-chip]:pl-1"
|
||||
>
|
||||
<ComboboxValue>
|
||||
{(selectedMembers: (typeof BILLING_APPROVER_OPTIONS)[number][]) => (
|
||||
<Fragment>
|
||||
{selectedMembers.map((member) => (
|
||||
<ComboboxChip
|
||||
key={member.id}
|
||||
showRemove={true}
|
||||
className="gap-1.5 rounded-full"
|
||||
>
|
||||
<Avatar className="size-4">
|
||||
<AvatarImage src={member.src} alt={member.name} />
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{member.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{member.name}
|
||||
</ComboboxChip>
|
||||
))}
|
||||
<ComboboxChipsInput placeholder="Add approvers..." />
|
||||
</Fragment>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent
|
||||
anchor={anchor}
|
||||
className="max-w-(--anchor-width) min-w-(--anchor-width)"
|
||||
>
|
||||
<ComboboxEmpty>No members found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(member) => (
|
||||
<ComboboxItem key={member.id} value={member}>
|
||||
<Item size="xs" className="p-0">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={member.src} alt={member.name} />
|
||||
<AvatarFallback>{member.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<ItemContent>
|
||||
<ItemTitle className="whitespace-nowrap">
|
||||
{member.name}
|
||||
</ItemTitle>
|
||||
<ItemDescription>{member.position}</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export function BillingAmountField({
|
||||
id,
|
||||
placeholder = "0.00",
|
||||
}: {
|
||||
id: string
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<Field className="w-full">
|
||||
{/* List */}
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<InputGroupText>$</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput id={id} type="number" placeholder={placeholder} />
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>USD</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import {
|
||||
BILLING_SPEND_CARDS,
|
||||
type BillingSpendCard,
|
||||
type SummaryMetric,
|
||||
} from "./data"
|
||||
import { CreditCardIcon, UsersIcon, CalendarCheckIcon } from "lucide-react"
|
||||
|
||||
export function ProfileSummaryFrame({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
items: SummaryMetric[]
|
||||
}) {
|
||||
return (
|
||||
<Frame spacing="sm">
|
||||
{/* Header */}
|
||||
<FrameHeader>
|
||||
<FrameTitle className="capitalize">{title}</FrameTitle>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
{/* Content */}
|
||||
<FramePanel className="space-y-3">
|
||||
<ProfileSummaryList items={items} />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileSummaryList({ items }: { items: SummaryMetric[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<div key={item.id} className="space-y-3">
|
||||
{index > 0 ? <Separator /> : null}
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground text-xs">{item.label}</p>
|
||||
<p className="mt-1 text-sm font-medium">{item.value}</p>
|
||||
{item.detail ? (
|
||||
<p className="text-muted-foreground mt-1 text-sm leading-5">
|
||||
{item.detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{item.badge ? (
|
||||
<Badge variant={item.badge.variant} size="sm">
|
||||
{item.badge.label}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getBillingSummaryIcon(id: BillingSpendCard["id"]): ReactNode {
|
||||
switch (id) {
|
||||
case "spend":
|
||||
return (
|
||||
<CreditCardIcon aria-hidden="true" className="size-3.5" />
|
||||
)
|
||||
case "seats":
|
||||
return (
|
||||
<UsersIcon aria-hidden="true" className="size-3.5" />
|
||||
)
|
||||
case "invoice":
|
||||
return (
|
||||
<CalendarCheckIcon aria-hidden="true" className="size-3.5" />
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function BillingSummaryFrame() {
|
||||
return (
|
||||
<Frame spacing="sm" className="@container">
|
||||
{/* Header */}
|
||||
<FrameHeader>
|
||||
<FrameTitle className="capitalize">Billing summary</FrameTitle>
|
||||
<FrameDescription>Plan, seats, and invoice.</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
{/* Content */}
|
||||
<FramePanel className="border-border grid auto-rows-fr grid-cols-1 overflow-hidden border-t p-0! md:grid-cols-3">
|
||||
{BILLING_SPEND_CARDS.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="border-border flex h-full min-w-0 flex-col gap-3 border-b p-5 last:border-b-0 md:border-b-0 md:border-l first:md:border-l-0"
|
||||
>
|
||||
<div className="space-y-2.5">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{card.title}</p>
|
||||
<p className="text-muted-foreground text-xs">{card.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xl font-semibold tracking-tight">
|
||||
{card.value}
|
||||
</span>
|
||||
<Badge variant={card.badge.variant} size="sm">
|
||||
{getBillingSummaryIcon(card.id)}
|
||||
{card.badge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground text-sm leading-5">
|
||||
{card.detail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="flex-row justify-end gap-2">
|
||||
<Button type="button" variant="outline">
|
||||
Download statement
|
||||
</Button>
|
||||
<Button type="button" variant="outline">
|
||||
Review invoices
|
||||
</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import { PhoneInput } from "@/components/reui/phone-input"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@cfdm/ui/components/field"
|
||||
import { Input } from "@cfdm/ui/components/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@cfdm/ui/components/input-group"
|
||||
import { Switch } from "@cfdm/ui/components/switch"
|
||||
import { Textarea } from "@cfdm/ui/components/textarea"
|
||||
|
||||
import {
|
||||
DIGEST_OPTIONS,
|
||||
LANDING_VIEW_OPTIONS,
|
||||
LANGUAGE_OPTIONS,
|
||||
PROFILE_IDENTITY,
|
||||
ROLE_OPTIONS,
|
||||
} from "./data"
|
||||
import { ImageUploadField } from "./image-upload-field"
|
||||
import {
|
||||
ProfileAdvancedSelectField,
|
||||
ProfileCompactSelectField,
|
||||
ProfileTimezoneField,
|
||||
} from "./profile-form-fields"
|
||||
import { SettingRow } from "./setting-row"
|
||||
|
||||
export function ProfileTabContent() {
|
||||
const [phoneNumber, setPhoneNumber] = useState(PROFILE_IDENTITY.phone)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Frame spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle className="capitalize">Profile details</FrameTitle>
|
||||
<FrameDescription>Personal account info.</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0">
|
||||
<FieldGroup className="gap-0">
|
||||
<SettingRow
|
||||
title="Profile photo"
|
||||
description="Shown in comments and mentions."
|
||||
labelFor="profile-photo"
|
||||
>
|
||||
<ImageUploadField
|
||||
ariaLabel="Upload profile photo"
|
||||
inputId="profile-photo"
|
||||
variant="avatar"
|
||||
defaultImage="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80"
|
||||
alt={PROFILE_IDENTITY.name}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Full name"
|
||||
description="Used across Atlas."
|
||||
labelFor="profile-name"
|
||||
>
|
||||
<Input id="profile-name" defaultValue={PROFILE_IDENTITY.name} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Email address"
|
||||
description="Primary sign-in email."
|
||||
labelFor="profile-email"
|
||||
titleAddon={
|
||||
<Badge variant="success-light" size="sm">
|
||||
Verified
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Input id="profile-email" defaultValue={PROFILE_IDENTITY.email} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Phone number"
|
||||
description="Recovery and urgent alerts."
|
||||
>
|
||||
<PhoneInput
|
||||
className="w-full"
|
||||
variant="sm"
|
||||
placeholder="Enter phone number"
|
||||
defaultCountry="US"
|
||||
value={phoneNumber}
|
||||
onChange={(value) => setPhoneNumber(value || "")}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Username"
|
||||
description="Used in mentions and links."
|
||||
labelFor="profile-username"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>@</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="profile-username"
|
||||
defaultValue={PROFILE_IDENTITY.username}
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Public details"
|
||||
description="Shown across Atlas."
|
||||
contentClassName="@md/field-group:max-w-[32rem]"
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Public details</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Public profile and workspace defaults.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="profile-role">Role</FieldLabel>
|
||||
<ProfileCompactSelectField
|
||||
id="profile-role"
|
||||
options={ROLE_OPTIONS}
|
||||
defaultValue={ROLE_OPTIONS[0]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="profile-timezone">
|
||||
Time zone
|
||||
</FieldLabel>
|
||||
<ProfileTimezoneField
|
||||
id="profile-timezone"
|
||||
placeholder="Select a timezone"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="profile-website">Website</FieldLabel>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>https://</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="profile-website"
|
||||
defaultValue={PROFILE_IDENTITY.website}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Bio"
|
||||
description="Short profile summary."
|
||||
labelFor="profile-bio"
|
||||
contentClassName="@md/field-group:w-full"
|
||||
last
|
||||
>
|
||||
<Textarea
|
||||
id="profile-bio"
|
||||
defaultValue={PROFILE_IDENTITY.bio}
|
||||
className="min-h-24 w-full resize-none"
|
||||
/>
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="flex-row justify-end gap-2">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button">Save changes</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
|
||||
<Frame spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle className="capitalize">Profile preferences</FrameTitle>
|
||||
<FrameDescription>Default workspace behavior.</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0">
|
||||
<FieldGroup className="gap-0">
|
||||
<SettingRow title="Language" compact>
|
||||
<ProfileAdvancedSelectField
|
||||
id="profile-language"
|
||||
options={LANGUAGE_OPTIONS}
|
||||
defaultValue={LANGUAGE_OPTIONS[0]}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow title="Landing view" compact>
|
||||
<ProfileAdvancedSelectField
|
||||
id="profile-landing-view"
|
||||
options={LANDING_VIEW_OPTIONS}
|
||||
defaultValue={LANDING_VIEW_OPTIONS[2]}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow title="Digest cadence" compact>
|
||||
<ProfileAdvancedSelectField
|
||||
id="profile-digest"
|
||||
options={DIGEST_OPTIONS}
|
||||
defaultValue={DIGEST_OPTIONS[2]}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Daily briefing"
|
||||
description="Morning recap before meetings."
|
||||
labelFor="profile-daily-briefing"
|
||||
compact
|
||||
last
|
||||
>
|
||||
<Switch id="profile-daily-briefing" defaultChecked />
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, type ComponentType, type ReactNode } from "react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@cfdm/ui/components/tabs"
|
||||
import { BillingTabContent } from "./billing-tab"
|
||||
import { ProfileTabContent } from "./profile-tab"
|
||||
import { TeamTabContent } from "./team-tab"
|
||||
import { WorkspaceTabContent } from "./workspace-tab"
|
||||
import { UserIcon, Building2Icon, UsersIcon, CreditCardIcon } from "lucide-react"
|
||||
|
||||
type ProfileTabValue = "profile" | "workspace" | "team" | "billing"
|
||||
|
||||
type ProfileTabConfig = {
|
||||
value: ProfileTabValue
|
||||
label: string
|
||||
icon: ReactNode
|
||||
component: ComponentType
|
||||
}
|
||||
|
||||
const PROFILE_TABS: ProfileTabConfig[] = [
|
||||
{
|
||||
value: "profile",
|
||||
label: "Profile",
|
||||
icon: (
|
||||
<UserIcon aria-hidden="true" />
|
||||
),
|
||||
component: ProfileTabContent,
|
||||
},
|
||||
{
|
||||
value: "workspace",
|
||||
label: "Workspace",
|
||||
icon: (
|
||||
<Building2Icon aria-hidden="true" />
|
||||
),
|
||||
component: WorkspaceTabContent,
|
||||
},
|
||||
{
|
||||
value: "team",
|
||||
label: "Team",
|
||||
icon: (
|
||||
<UsersIcon aria-hidden="true" />
|
||||
),
|
||||
component: TeamTabContent,
|
||||
},
|
||||
{
|
||||
value: "billing",
|
||||
label: "Billing",
|
||||
icon: (
|
||||
<CreditCardIcon aria-hidden="true" />
|
||||
),
|
||||
component: BillingTabContent,
|
||||
},
|
||||
]
|
||||
|
||||
export function Profile() {
|
||||
const isMobile = useIsMobile()
|
||||
const [activeTab, setActiveTab] = useState<ProfileTabValue>("profile")
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl space-y-8">
|
||||
{/* Header */}
|
||||
<header className="px-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Account Settings
|
||||
</h1>
|
||||
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||
Update your account, workspace, team, and billing.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as ProfileTabValue)}
|
||||
orientation={isMobile ? "horizontal" : "vertical"}
|
||||
className="w-full gap-5 lg:gap-8"
|
||||
>
|
||||
<SidebarRail isMobile={isMobile} activeValue={activeTab} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{PROFILE_TABS.map((tab) => {
|
||||
const TabComponent = tab.component
|
||||
|
||||
return (
|
||||
<TabsContent key={tab.value} value={tab.value} className="mt-0">
|
||||
<TabComponent />
|
||||
</TabsContent>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({
|
||||
isMobile,
|
||||
activeValue,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
activeValue: ProfileTabValue
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("min-w-0", isMobile ? "w-full" : "w-40 shrink-0")}>
|
||||
{isMobile ? (
|
||||
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
||||
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
|
||||
{PROFILE_TABS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
|
||||
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
) : (
|
||||
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
|
||||
{PROFILE_TABS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
|
||||
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from "@cfdm/ui/components/field"
|
||||
|
||||
interface SettingRowProps {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
compact?: boolean
|
||||
stacked?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
titleAddon?: ReactNode
|
||||
}
|
||||
|
||||
export function SettingRow({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
last,
|
||||
compact,
|
||||
stacked,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
titleAddon,
|
||||
}: SettingRowProps) {
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
orientation={stacked ? "vertical" : "responsive"}
|
||||
className="gap-4 px-5 py-4"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>
|
||||
<span className="capitalize">{title}</span>
|
||||
</FieldLabel>
|
||||
) : (
|
||||
<FieldTitle>
|
||||
<span className="capitalize">{title}</span>
|
||||
</FieldTitle>
|
||||
)}
|
||||
{titleAddon}
|
||||
</div>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription className="text-sm">
|
||||
{description}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FieldContent
|
||||
className={cn(
|
||||
"w-full min-w-0 @md/field-group:flex-1",
|
||||
stacked
|
||||
? "max-w-none"
|
||||
: compact
|
||||
? "@md/field-group:max-w-[17rem] @md/field-group:shrink-0"
|
||||
: "@md/field-group:max-w-[34rem]",
|
||||
contentClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full justify-start",
|
||||
stacked ? "justify-start" : "@md/field-group:justify-end"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { memo, useState } from "react"
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
import { Badge, type BadgeProps } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import { type ColumnDef, type Row } from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@cfdm/ui/components/alert-dialog"
|
||||
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 { type TeamMember, type TeamMemberStatus } from "./data"
|
||||
import { MoreHorizontalIcon, UserIcon, ShieldCheckIcon, MailIcon, RefreshCwIcon, CopyIcon, UserRoundXIcon } from "lucide-react"
|
||||
|
||||
const availabilityColor: Record<TeamMember["availability"], string> = {
|
||||
online: "bg-green-500",
|
||||
away: "bg-yellow-400",
|
||||
busy: "bg-red-500",
|
||||
offline: "bg-zinc-400 dark:bg-zinc-600",
|
||||
}
|
||||
|
||||
const accessBadgeVariant: Record<TeamMember["access"], BadgeProps["variant"]> =
|
||||
{
|
||||
Owner: "primary-light",
|
||||
Admin: "info-light",
|
||||
Member: "outline",
|
||||
}
|
||||
|
||||
export const TeamStatusBadge = memo(function TeamStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: TeamMemberStatus
|
||||
}) {
|
||||
if (status === "Active") {
|
||||
return <Badge variant="success-light">Active</Badge>
|
||||
}
|
||||
|
||||
if (status === "Invited") {
|
||||
return <Badge variant="info-light">Invited</Badge>
|
||||
}
|
||||
|
||||
if (status === "Limited") {
|
||||
return <Badge variant="warning-light">Limited</Badge>
|
||||
}
|
||||
|
||||
return <Badge variant="destructive-light">Suspended</Badge>
|
||||
})
|
||||
|
||||
const UserCell = memo(function UserCell({ row }: { row: Row<TeamMember> }) {
|
||||
const user = row.original
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={user.src} alt="" />
|
||||
<AvatarFallback>{user.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
|
||||
availabilityColor[user.availability]
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground line-clamp-1 text-sm font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<Badge variant={accessBadgeVariant[user.access]} size="sm">
|
||||
{user.access}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground line-clamp-1 text-sm"
|
||||
title={user.email}
|
||||
>
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function ActionsCell({ row }: { row: Row<TeamMember> }) {
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const [removeOpen, setRemoveOpen] = useState(false)
|
||||
|
||||
const handleCopyId = () => {
|
||||
copyToClipboard(row.original.id)
|
||||
toast.success("Member ID copied", { description: row.original.id })
|
||||
}
|
||||
|
||||
const handleRemoveConfirm = () => {
|
||||
setRemoveOpen(false)
|
||||
toast.message("Removal requested", {
|
||||
description: `${row.original.name}. Wire this to your user API.`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
aria-label={`Open ${row.original.name} actions`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-44">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Open member", {
|
||||
description:
|
||||
"Show the profile and recent workspace activity.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<UserIcon aria-hidden="true" />
|
||||
View member
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Change role", {
|
||||
description: "Open the role and seat management flow.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<ShieldCheckIcon aria-hidden="true" />
|
||||
Change role
|
||||
</DropdownMenuItem>
|
||||
{row.original.status === "Invited" ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.message("Invite resent", {
|
||||
description: `A fresh invite was sent to ${row.original.email}.`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<MailIcon aria-hidden="true" />
|
||||
Resend invite
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.message("Session reset", {
|
||||
description: `Require ${row.original.name} to sign in again.`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<RefreshCwIcon aria-hidden="true" />
|
||||
Reset session
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleCopyId}>
|
||||
<CopyIcon aria-hidden="true" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setRemoveOpen(true)}
|
||||
>
|
||||
<UserRoundXIcon aria-hidden="true" />
|
||||
Remove member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={removeOpen} onOpenChange={setRemoveOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove member?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes{" "}
|
||||
<span className="text-foreground font-medium">
|
||||
{row.original.name}
|
||||
</span>{" "}
|
||||
from the workspace. Connect your user API to persist changes.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={handleRemoveConfirm}
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const teamColumns: ColumnDef<TeamMember>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <UserCell row={row} />,
|
||||
size: 300,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Member",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
id: "role",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-foreground line-clamp-1 text-sm font-medium">
|
||||
{row.original.role}
|
||||
</span>
|
||||
<span className="text-muted-foreground line-clamp-1 text-sm">
|
||||
{row.original.department}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
size: 180,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Role",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} visibility={true} />
|
||||
),
|
||||
cell: ({ row }) => <TeamStatusBadge status={row.original.status} />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Status",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => <ActionsCell row={row} />,
|
||||
size: 50,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { Checkbox } from "@cfdm/ui/components/checkbox"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@cfdm/ui/components/input-group"
|
||||
import { Label } from "@cfdm/ui/components/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@cfdm/ui/components/popover"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import {
|
||||
TEAM_MEMBERS,
|
||||
TEAM_STATUS_ORDER,
|
||||
type TeamMember,
|
||||
type TeamMemberStatus,
|
||||
} from "./data"
|
||||
import { teamColumns, TeamStatusBadge } from "./team-columns"
|
||||
import { SearchIcon, XIcon, FilterIcon, UserPlusIcon } from "lucide-react"
|
||||
|
||||
const EMPTY_TEAM_STATUS_COUNTS: Record<TeamMemberStatus, number> = {
|
||||
Active: 0,
|
||||
Invited: 0,
|
||||
Limited: 0,
|
||||
Suspended: 0,
|
||||
}
|
||||
|
||||
function teamSearchBlob(user: TeamMember): string {
|
||||
const parts = [
|
||||
user.id,
|
||||
user.name,
|
||||
user.email,
|
||||
user.role,
|
||||
user.department,
|
||||
user.access,
|
||||
user.status,
|
||||
user.location,
|
||||
user.timezone,
|
||||
user.joined,
|
||||
user.lastActive,
|
||||
]
|
||||
|
||||
return parts.filter(Boolean).join(" ").toLowerCase()
|
||||
}
|
||||
|
||||
function getTeamStatusCounts(
|
||||
members: TeamMember[]
|
||||
): Record<TeamMemberStatus, number> {
|
||||
const counts = { ...EMPTY_TEAM_STATUS_COUNTS }
|
||||
|
||||
for (const member of members) {
|
||||
counts[member.status] += 1
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
type ToolbarProps = {
|
||||
searchQuery: string
|
||||
onSearchChange: (value: string) => void
|
||||
selectedStatuses: TeamMemberStatus[]
|
||||
onStatusChange: (checked: boolean, status: TeamMemberStatus) => void
|
||||
onClearFilters: () => void
|
||||
hasActiveFilters: boolean
|
||||
statusCounts: Record<TeamMemberStatus, number>
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
selectedStatuses,
|
||||
onStatusChange,
|
||||
onClearFilters,
|
||||
hasActiveFilters,
|
||||
statusCounts,
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* List */}
|
||||
<InputGroup className="w-full sm:w-60">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search team..."
|
||||
aria-label="Search team"
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
/>
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
aria-label="Clear search"
|
||||
size="icon-xs"
|
||||
onClick={() => onSearchChange("")}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label="Filter by member status"
|
||||
>
|
||||
<FilterIcon aria-hidden="true" />
|
||||
Status
|
||||
{selectedStatuses.length > 0 ? (
|
||||
<Badge size="sm" variant="info-light">
|
||||
{selectedStatuses.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="flex w-44 flex-col gap-2.5 p-3"
|
||||
>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Filter by status
|
||||
</span>
|
||||
{TEAM_STATUS_ORDER.map((status) => (
|
||||
<div key={status} className="flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
id={`team-status-${status.toLowerCase()}`}
|
||||
checked={selectedStatuses.includes(status)}
|
||||
onCheckedChange={(checked) =>
|
||||
onStatusChange(checked === true, status)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`team-status-${status.toLowerCase()}`}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-2 font-normal"
|
||||
>
|
||||
<TeamStatusBadge status={status} />
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{statusCounts[status] ?? 0}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{hasActiveFilters ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TeamDataGridView() {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<TeamMemberStatus[]>(
|
||||
[]
|
||||
)
|
||||
|
||||
const statusCounts = useMemo(() => getTeamStatusCounts(TEAM_MEMBERS), [])
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const normalizedSearchQuery = searchQuery.trim().toLowerCase()
|
||||
|
||||
return TEAM_MEMBERS.filter((user) => {
|
||||
const matchesStatus =
|
||||
!selectedStatuses.length || selectedStatuses.includes(user.status)
|
||||
const matchesSearch =
|
||||
normalizedSearchQuery.length === 0 ||
|
||||
teamSearchBlob(user).includes(normalizedSearchQuery)
|
||||
|
||||
return matchesStatus && matchesSearch
|
||||
})
|
||||
}, [searchQuery, selectedStatuses])
|
||||
|
||||
const hasActiveFilters =
|
||||
searchQuery.trim().length > 0 || selectedStatuses.length > 0
|
||||
|
||||
const handleStatusChange = (checked: boolean, status: TeamMemberStatus) => {
|
||||
setSelectedStatuses((current) =>
|
||||
checked ? [...current, status] : current.filter((item) => item !== status)
|
||||
)
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
pageIndex: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSelectedStatuses([])
|
||||
setSearchQuery("")
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
pageIndex: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value)
|
||||
setPagination((current) => ({
|
||||
...current,
|
||||
pageIndex: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
const table = useReactTable({
|
||||
columns: teamColumns,
|
||||
data: filteredData,
|
||||
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
|
||||
getRowId: (row) => row.id,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
},
|
||||
columnResizeMode: "onChange",
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage={
|
||||
filteredData.length === 0 ? "No members match your filters." : undefined
|
||||
}
|
||||
tableLayout={{
|
||||
columnsPinnable: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
columnsVisibility: false,
|
||||
headerSticky: false,
|
||||
dense: true,
|
||||
}}
|
||||
>
|
||||
<Frame variant="default" spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<FrameTitle id="team-heading" className="text-balance capitalize">
|
||||
Workspace Team
|
||||
</FrameTitle>
|
||||
<FrameDescription className="text-sm">
|
||||
Manage members and access.
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toast.info("Invite member", {
|
||||
description: "Connect your invite or directory flow.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
Invite member
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="bg-card p-0! shadow-none!">
|
||||
<div className="px-4 py-3">
|
||||
<Toolbar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
selectedStatuses={selectedStatuses}
|
||||
onStatusChange={handleStatusChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
statusCounts={statusCounts}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter>
|
||||
<DataGridPagination />
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TeamDataGridView } from "./team-data-grid"
|
||||
|
||||
export function TeamTabContent() {
|
||||
return <TeamDataGridView />
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { FieldGroup } from "@cfdm/ui/components/field"
|
||||
import { Input } from "@cfdm/ui/components/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@cfdm/ui/components/input-group"
|
||||
import { Switch } from "@cfdm/ui/components/switch"
|
||||
|
||||
import { BrandAccentPicker } from "./brand-accent-picker"
|
||||
import {
|
||||
LANDING_VIEW_OPTIONS,
|
||||
WORKSPACE_IDENTITY,
|
||||
WORKSPACE_SIGNALS,
|
||||
} from "./data"
|
||||
import { ImageUploadField } from "./image-upload-field"
|
||||
import { ProfileAdvancedSelectField } from "./profile-form-fields"
|
||||
import { ProfileSummaryFrame } from "./profile-summary-frames"
|
||||
import { SettingRow } from "./setting-row"
|
||||
|
||||
export function WorkspaceTabContent() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Frame spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle className="capitalize">Workspace details</FrameTitle>
|
||||
<FrameDescription>Brand and contact info.</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0!">
|
||||
<FieldGroup className="gap-0">
|
||||
<SettingRow
|
||||
title="Workspace logo"
|
||||
description="Used in navigation and shared links."
|
||||
labelFor="workspace-logo"
|
||||
>
|
||||
<ImageUploadField
|
||||
ariaLabel="Upload workspace logo"
|
||||
inputId="workspace-logo"
|
||||
defaultImage="https://images.unsplash.com/photo-1557683316-973673baf926?w=96&h=96&fit=crop&dpr=2&q=80"
|
||||
alt={WORKSPACE_IDENTITY.name}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow title="Workspace name" labelFor="workspace-name">
|
||||
<Input
|
||||
id="workspace-name"
|
||||
defaultValue={WORKSPACE_IDENTITY.name}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Workspace URL"
|
||||
description="Link for teammates and clients."
|
||||
labelFor="workspace-subdomain"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupInput
|
||||
id="workspace-subdomain"
|
||||
defaultValue={WORKSPACE_IDENTITY.subdomain}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>
|
||||
{WORKSPACE_IDENTITY.domainSuffix}
|
||||
</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Support email"
|
||||
description="Shown on help surfaces."
|
||||
labelFor="workspace-support-email"
|
||||
>
|
||||
<Input
|
||||
id="workspace-support-email"
|
||||
defaultValue={WORKSPACE_IDENTITY.supportEmail}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Accent color"
|
||||
description="Used for highlights and links. Select one accent."
|
||||
compact
|
||||
last
|
||||
>
|
||||
<BrandAccentPicker />
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="flex-row justify-end gap-2">
|
||||
<Button type="button" variant="outline">
|
||||
Preview brand
|
||||
</Button>
|
||||
<Button type="button">Save workspace</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
|
||||
<Frame spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle className="capitalize">Workspace preferences</FrameTitle>
|
||||
<FrameDescription>Sharing and automation defaults.</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0!">
|
||||
<FieldGroup className="gap-0">
|
||||
<SettingRow title="Default landing view" compact>
|
||||
<ProfileAdvancedSelectField
|
||||
id="workspace-landing-view"
|
||||
options={LANDING_VIEW_OPTIONS}
|
||||
defaultValue={LANDING_VIEW_OPTIONS[1]}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="External sharing"
|
||||
description="Create client-safe links."
|
||||
labelFor="workspace-external-sharing"
|
||||
compact
|
||||
>
|
||||
<Switch id="workspace-external-sharing" defaultChecked />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="AI project recaps"
|
||||
description="Short recaps as work changes."
|
||||
labelFor="workspace-ai-recaps"
|
||||
compact
|
||||
last
|
||||
>
|
||||
<Switch id="workspace-ai-recaps" defaultChecked />
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<ProfileSummaryFrame
|
||||
title="Workspace summary"
|
||||
description="Brand, routing, and region."
|
||||
items={WORKSPACE_SIGNALS}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Profile } from "./components/profile"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12">
|
||||
<Profile />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { BadgeProps } from "@/components/reui/badge"
|
||||
|
||||
import { ClaudeAiIcon } from "@cfdm/ui/components/svgs/claudeAiIcon"
|
||||
import { CursorDark } from "@cfdm/ui/components/svgs/cursorDark"
|
||||
import { CursorLight } from "@cfdm/ui/components/svgs/cursorLight"
|
||||
import { GoogleCalendar } from "@cfdm/ui/components/svgs/googleCalendar"
|
||||
import { ResendIconBlack } from "@cfdm/ui/components/svgs/resendIconBlack"
|
||||
import { ResendIconWhite } from "@cfdm/ui/components/svgs/resendIconWhite"
|
||||
import { Slack } from "@cfdm/ui/components/svgs/slack"
|
||||
import { Stripe } from "@cfdm/ui/components/svgs/stripe"
|
||||
import { Supabase } from "@cfdm/ui/components/svgs/supabase"
|
||||
|
||||
export type IntegrationStatus = "connected" | "available" | "preview"
|
||||
|
||||
export interface IntegrationStatusMeta {
|
||||
label: string
|
||||
variant: BadgeProps["variant"]
|
||||
}
|
||||
|
||||
export interface IntegrationApp {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
logo: ReactNode
|
||||
status: IntegrationStatus
|
||||
actionLabel: string
|
||||
}
|
||||
|
||||
export const INTEGRATION_STATUS_META: Record<
|
||||
IntegrationStatus,
|
||||
IntegrationStatusMeta
|
||||
> = {
|
||||
connected: {
|
||||
label: "Connected",
|
||||
variant: "success-light",
|
||||
},
|
||||
available: {
|
||||
label: "Available",
|
||||
variant: "secondary",
|
||||
},
|
||||
preview: {
|
||||
label: "Preview",
|
||||
variant: "info-light",
|
||||
},
|
||||
}
|
||||
|
||||
const logoClassName = "block size-5"
|
||||
|
||||
function ThemeLogo({ light, dark }: { light: ReactNode; dark: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-5 items-center justify-center leading-none dark:hidden"
|
||||
>
|
||||
{light}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="hidden size-5 items-center justify-center leading-none dark:flex"
|
||||
>
|
||||
{dark}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CURSOR_LOGO = (
|
||||
<ThemeLogo
|
||||
light={<CursorLight className={logoClassName} />}
|
||||
dark={<CursorDark className={logoClassName} />}
|
||||
/>
|
||||
)
|
||||
|
||||
const RESEND_LOGO = (
|
||||
<ThemeLogo
|
||||
light={<ResendIconBlack className={logoClassName} />}
|
||||
dark={<ResendIconWhite className={logoClassName} />}
|
||||
/>
|
||||
)
|
||||
|
||||
export const INTEGRATION_APPS: IntegrationApp[] = [
|
||||
{
|
||||
id: "supabase",
|
||||
name: "Supabase",
|
||||
description:
|
||||
"Sync workspace records, owners, and lifecycle events from your product database.",
|
||||
logo: <Supabase className={logoClassName} aria-hidden="true" />,
|
||||
status: "connected",
|
||||
actionLabel: "Configure",
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
description:
|
||||
"Route approvals, launch notes, and incident updates into selected team channels.",
|
||||
logo: <Slack className={logoClassName} aria-hidden="true" />,
|
||||
status: "connected",
|
||||
actionLabel: "Configure",
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
name: "Stripe",
|
||||
description:
|
||||
"Mirror subscription, invoice, and renewal signals into account reviews.",
|
||||
logo: <Stripe className={logoClassName} aria-hidden="true" />,
|
||||
status: "connected",
|
||||
actionLabel: "Configure",
|
||||
},
|
||||
{
|
||||
id: "google-calendar",
|
||||
name: "Google Calendar",
|
||||
description:
|
||||
"Create planning reviews from milestones, customer dates, and release windows.",
|
||||
logo: <GoogleCalendar className={logoClassName} aria-hidden="true" />,
|
||||
status: "available",
|
||||
actionLabel: "Install",
|
||||
},
|
||||
{
|
||||
id: "resend",
|
||||
name: "Resend",
|
||||
description:
|
||||
"Send branded transactional updates when workflows change status or ownership.",
|
||||
logo: RESEND_LOGO,
|
||||
status: "available",
|
||||
actionLabel: "Install",
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
name: "Cursor",
|
||||
description:
|
||||
"Attach implementation context and AI coding notes to active product tasks.",
|
||||
logo: CURSOR_LOGO,
|
||||
status: "preview",
|
||||
actionLabel: "Configure",
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
name: "Claude",
|
||||
description:
|
||||
"Draft summaries, support notes, and rollout briefs from approved workspace data.",
|
||||
logo: <ClaudeAiIcon className={logoClassName} aria-hidden="true" />,
|
||||
status: "preview",
|
||||
actionLabel: "Install",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from "@cfdm/ui/components/item"
|
||||
|
||||
import {
|
||||
INTEGRATION_APPS,
|
||||
INTEGRATION_STATUS_META,
|
||||
type IntegrationApp,
|
||||
} from "./data"
|
||||
|
||||
function IntegrationLogo({ app }: { app: IntegrationApp }) {
|
||||
return (
|
||||
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:block [&_svg]:size-5">
|
||||
<ItemMedia variant="icon" className="size-auto translate-y-0! self-center!">
|
||||
{app.logo}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function IntegrationRow({ app }: { app: IntegrationApp }) {
|
||||
const status = INTEGRATION_STATUS_META[app.status]
|
||||
|
||||
return (
|
||||
<Item role="listitem" className="items-center px-3.5 py-2.5 sm:px-4">
|
||||
{/* Media */}
|
||||
<ItemMedia className="translate-y-0! self-center!">
|
||||
<IntegrationLogo app={app} />
|
||||
</ItemMedia>
|
||||
|
||||
{/* Content */}
|
||||
<ItemContent className="min-w-0 gap-0">
|
||||
<ItemTitle className="w-full min-w-0 gap-2">
|
||||
<span className="truncate">{app.name}</span>
|
||||
<Badge variant={status.variant}>{status.label}</Badge>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-1">
|
||||
{app.description}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
|
||||
{/* Actions */}
|
||||
<ItemActions className="ml-auto shrink-0 justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
{app.actionLabel}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
export function IntegrationSettings() {
|
||||
return (
|
||||
<div className="flex w-full max-w-4xl flex-col gap-5">
|
||||
{/* Section */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-semibold">Connected Apps</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Manage source systems for automation, scheduling, support, and AI
|
||||
workflows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="button" size="sm" className="w-full sm:w-auto">
|
||||
Build Connector
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader className="sr-only">
|
||||
<FrameTitle>Integration Apps</FrameTitle>
|
||||
<FrameDescription>
|
||||
Available workspace apps and configuration actions.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0!">
|
||||
<ItemGroup className="gap-0">
|
||||
{INTEGRATION_APPS.map((app, index) => (
|
||||
<div key={app.id}>
|
||||
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||
<IntegrationRow app={app} />
|
||||
</div>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IntegrationSettings } from "./components/integration-settings"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Integration settings
|
||||
</h1>
|
||||
<IntegrationSettings />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface DragContextProviderProps {
|
||||
children: ReactNode
|
||||
overlay?: ReactNode
|
||||
onDragStart: (event: DragStartEvent) => void
|
||||
onDragEnd: (event: DragEndEvent) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const collisionDetection: CollisionDetection = (args) => {
|
||||
const pointerCollisions = pointerWithin(args)
|
||||
if (pointerCollisions.length > 0) {
|
||||
return pointerCollisions
|
||||
}
|
||||
|
||||
const intersectionCollisions = rectIntersection(args)
|
||||
if (intersectionCollisions.length > 0) {
|
||||
return intersectionCollisions
|
||||
}
|
||||
|
||||
return closestCenter(args)
|
||||
}
|
||||
|
||||
export function DragContextProvider({
|
||||
children,
|
||||
overlay,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
disabled = false,
|
||||
}: DragContextProviderProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
)
|
||||
|
||||
if (disabled) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetection}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{children}
|
||||
<DragOverlay dropAnimation={null}>{overlay}</DragOverlay>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
AppCard,
|
||||
AppCardContent,
|
||||
AppCardDescription,
|
||||
AppCardHeader,
|
||||
AppCardTitle,
|
||||
} from '@/components/app-card'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@cfdm/ui/components/tabs'
|
||||
|
||||
interface ChartCardProps {
|
||||
title: string
|
||||
description?: string
|
||||
chart: ReactNode
|
||||
table?: ReactNode
|
||||
chartTabLabel?: string
|
||||
tableTabLabel?: string
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
description,
|
||||
chart,
|
||||
table,
|
||||
chartTabLabel = 'График',
|
||||
tableTabLabel = 'Таблица',
|
||||
action,
|
||||
className,
|
||||
}: ChartCardProps) {
|
||||
const hasTabs = !!table
|
||||
return (
|
||||
<AppCard className={className}>
|
||||
<AppCardHeader>
|
||||
<AppCardTitle className="text-base">{title}</AppCardTitle>
|
||||
{description && <AppCardDescription>{description}</AppCardDescription>}
|
||||
{action}
|
||||
</AppCardHeader>
|
||||
<AppCardContent className="pb-6">
|
||||
{hasTabs ? (
|
||||
<Tabs defaultValue="chart" className="w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="chart">{chartTabLabel}</TabsTrigger>
|
||||
<TabsTrigger value="table">{tableTabLabel}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="chart" className="mt-0">
|
||||
{chart}
|
||||
</TabsContent>
|
||||
<TabsContent value="table" className="mt-0">
|
||||
{table}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
chart
|
||||
)}
|
||||
</AppCardContent>
|
||||
</AppCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { GlobeIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { Certificate } from '@/lib/schemas'
|
||||
import { formatDate, formatRelative } from '@/lib/format'
|
||||
|
||||
export const CERT_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'active', label: 'Активен' },
|
||||
{ id: 'warning', label: 'Предупреждение' },
|
||||
{ id: 'expired', label: 'Истёк' },
|
||||
] as const
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['active', 'ok', 'synced'])
|
||||
const EXPIRED_STATUSES = new Set(['expired', 'error', 'conflict'])
|
||||
|
||||
export function certTabFilter(item: Certificate, tabId: string) {
|
||||
if (tabId === 'active') return ACTIVE_STATUSES.has(item.status)
|
||||
if (tabId === 'warning') return item.status === 'warning'
|
||||
if (tabId === 'expired') return EXPIRED_STATUSES.has(item.status)
|
||||
return true
|
||||
}
|
||||
|
||||
export function createDefaultCertFilters() {
|
||||
return [createFilter('hostname', 'contains', [''])]
|
||||
}
|
||||
|
||||
export function useCertFilterFields() {
|
||||
return useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'hostname',
|
||||
label: 'Хост',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по хосту…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
export function certFilterFieldValue(item: Certificate, field: string) {
|
||||
if (field === 'hostname') {
|
||||
return `${item.hostname} ${item.status}`.toLowerCase()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function useCertificateColumns() {
|
||||
return useMemo<ColumnDef<Certificate>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'hostname',
|
||||
accessorKey: 'hostname',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Хост" icon={<GlobeIcon className="size-3.5" />} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="truncate font-medium">{row.original.hostname}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'expires_at',
|
||||
accessorKey: 'expires_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Истекает" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDate(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'relative',
|
||||
header: 'Срок',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatRelative(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'last_checked_at',
|
||||
accessorKey: 'last_checked_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Проверка" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDate(row.original.last_checked_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DnsRecord, IpHealthStatus } from '@/lib/schemas'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
|
||||
export const DNS_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'proxied', label: 'Proxied' },
|
||||
{ id: 'dns_only', label: 'DNS only' },
|
||||
] as const
|
||||
|
||||
const DNS_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX'] as const
|
||||
|
||||
export function dnsTabFilter(item: DnsRecord, tabId: string) {
|
||||
if (tabId === 'proxied') return item.proxied === true
|
||||
if (tabId === 'dns_only') return item.proxied !== true
|
||||
return true
|
||||
}
|
||||
|
||||
export function createDefaultDnsFilters() {
|
||||
return [createFilter('name', 'contains', [''])]
|
||||
}
|
||||
|
||||
export function useDnsFilterFields() {
|
||||
return useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Имя',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
{
|
||||
key: 'record_type',
|
||||
label: 'Тип',
|
||||
type: 'select',
|
||||
className: 'w-[120px]',
|
||||
options: DNS_TYPES.map((type) => ({ label: type, value: type })),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
export function dnsFilterFieldValue(item: DnsRecord, field: string) {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
return `${item.name} ${item.content}`.toLowerCase()
|
||||
case 'record_type':
|
||||
return item.record_type
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const healthDotClass: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'bg-success',
|
||||
degraded: 'bg-warning',
|
||||
down: 'bg-destructive',
|
||||
unknown: 'bg-muted-foreground/40',
|
||||
}
|
||||
|
||||
const healthLabel: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'OK',
|
||||
degraded: 'Деград.',
|
||||
down: 'Down',
|
||||
unknown: '—',
|
||||
}
|
||||
|
||||
function IpHealthDot({ health }: { health: IpHealthStatus }) {
|
||||
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
|
||||
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
|
||||
if (health.last_checked_at) tooltipParts.push(`Проверка: ${formatDate(health.last_checked_at)}`)
|
||||
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-2 shrink-0 cursor-default rounded-full',
|
||||
healthDotClass[health.status],
|
||||
)}
|
||||
aria-label={`Health: ${healthLabel[health.status]}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDnsColumns({
|
||||
onDelete,
|
||||
isDeleting,
|
||||
healthByIp,
|
||||
}: {
|
||||
onDelete: (recordId: number) => void
|
||||
isDeleting?: boolean
|
||||
healthByIp?: Record<string, IpHealthStatus>
|
||||
}) {
|
||||
return useMemo<ColumnDef<DnsRecord>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'record_type',
|
||||
accessorKey: 'record_type',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Тип" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline">{row.original.record_type}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Имя" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
accessorKey: 'content',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Значение" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const health = healthByIp?.[row.original.content]
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{health ? <IpHealthDot health={health} /> : null}
|
||||
<span className="truncate font-mono text-sm">{row.original.content}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'proxied',
|
||||
header: 'Прокси',
|
||||
cell: ({ row }) =>
|
||||
row.original.proxied ? (
|
||||
<StatusBadge status="active" label="Proxied" />
|
||||
) : (
|
||||
<Badge variant="outline">DNS only</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ttl',
|
||||
accessorKey: 'ttl',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="TTL" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">{row.original.ttl}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="icon" className="size-8" />}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">Действия</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[healthByIp, isDeleting, onDelete],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DomainListItem } from '@/lib/schemas'
|
||||
import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
|
||||
export const DOMAIN_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'with_group', label: 'С группой' },
|
||||
{ id: 'without_group', label: 'Без группы' },
|
||||
] as const
|
||||
|
||||
export function domainTabFilter(item: DomainListItem, tabId: string) {
|
||||
if (tabId === 'with_group') return item.group_id != null
|
||||
if (tabId === 'without_group') return item.group_id == null
|
||||
return true
|
||||
}
|
||||
|
||||
export function createDefaultDomainFilters() {
|
||||
return [createFilter('zone_name', 'contains', [''])]
|
||||
}
|
||||
|
||||
export function useDomainFilterFields(
|
||||
groupOptions: { value: string; label: string }[],
|
||||
) {
|
||||
return useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'zone_name',
|
||||
label: 'Зона',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
{
|
||||
key: 'group_id',
|
||||
label: 'Группа',
|
||||
type: 'select',
|
||||
searchable: true,
|
||||
className: 'w-[168px]',
|
||||
options: groupOptions,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, groupOptions),
|
||||
},
|
||||
],
|
||||
[groupOptions],
|
||||
)
|
||||
}
|
||||
|
||||
export function domainFilterFieldValue(item: DomainListItem, field: string) {
|
||||
switch (field) {
|
||||
case 'zone_name':
|
||||
return `${item.zone_name} ${item.group_name ?? ''}`.toLowerCase()
|
||||
case 'group_id':
|
||||
return item.group_id != null ? String(item.group_id) : 'none'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function useDomainColumns({
|
||||
onRequestDelete,
|
||||
isDeleting,
|
||||
}: {
|
||||
onRequestDelete: (domain: DomainListItem) => void
|
||||
isDeleting?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<DomainListItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'zone_name',
|
||||
accessorKey: 'zone_name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Зона" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{row.original.zone_name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Группа" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original
|
||||
if (domain.group_id && domain.group_name) {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId: String(domain.group_id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{domain.group_name}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return <Badge variant="outline">Без группы</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'service_count',
|
||||
accessorKey: 'service_count',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервисы" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 tabular-nums"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/services" search={{ domainId: row.original.id }} />
|
||||
}
|
||||
>
|
||||
{row.original.service_count}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'last_synced_at',
|
||||
accessorKey: 'last_synced_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Синхронизация" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{row.original.last_synced_at ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="icon" className="size-8" />}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">Действия</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
search={{ host: undefined }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
onClick={() => onRequestDelete(row.original)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[isDeleting, onRequestDelete],
|
||||
)
|
||||
|
||||
return { columns }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { GlobeIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DomainListItem } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
export function createDefaultGroupDomainFilters() {
|
||||
return [createFilter('zone_name', 'contains', [''])]
|
||||
}
|
||||
|
||||
export function useGroupDomainFilterFields() {
|
||||
return useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'zone_name',
|
||||
label: 'Зона',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
export function groupDomainFilterFieldValue(item: DomainListItem, field: string) {
|
||||
if (field === 'zone_name') return item.zone_name.toLowerCase()
|
||||
return ''
|
||||
}
|
||||
|
||||
export function useGroupDomainColumns() {
|
||||
return useMemo<ColumnDef<DomainListItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'zone_name',
|
||||
accessorKey: 'zone_name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Зона" icon={<GlobeIcon className="size-3.5" />} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.zone_name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'service_count',
|
||||
accessorKey: 'service_count',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервисы" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 tabular-nums"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/services" search={{ domainId: row.original.id }} />
|
||||
}
|
||||
>
|
||||
{row.original.service_count}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
|
||||
import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
|
||||
export const SUBDOMAIN_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'active', label: 'Активные' },
|
||||
] as const
|
||||
|
||||
export function subdomainTabFilter(item: SubdomainTableRow, tabId: string) {
|
||||
if (tabId === 'active') return item.subdomain.enabled
|
||||
return true
|
||||
}
|
||||
|
||||
export function createDefaultSubdomainFilters() {
|
||||
return [createFilter('fqdn', 'contains', [''])]
|
||||
}
|
||||
|
||||
export function useSubdomainFilterFields() {
|
||||
return useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'fqdn',
|
||||
label: 'Поддомен',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
export function subdomainFilterFieldValue(item: SubdomainTableRow, field: string) {
|
||||
if (field === 'fqdn') {
|
||||
return `${item.subdomain.fqdn} ${formatSubdomainServiceLinks(item.serviceLinks)}`.toLowerCase()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function useSubdomainColumns({
|
||||
domainId,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleEnabled,
|
||||
isDeleting,
|
||||
isToggling,
|
||||
}: {
|
||||
domainId: string
|
||||
onEdit: (row: SubdomainTableRow) => void
|
||||
onDelete: (row: SubdomainTableRow) => void
|
||||
onToggleEnabled: (row: SubdomainTableRow) => void
|
||||
isDeleting?: boolean
|
||||
isToggling?: boolean
|
||||
}) {
|
||||
return useMemo<ColumnDef<SubdomainTableRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'fqdn',
|
||||
accessorFn: (row) => row.subdomain.fqdn,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Поддомен" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="truncate font-mono">{row.original.subdomain.fqdn}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.subdomain.enabled ? 'success' : 'outline'}>
|
||||
{row.original.subdomain.enabled ? 'Активен' : 'Неактивен'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'service',
|
||||
header: 'Группа / Сервис',
|
||||
accessorFn: (row) => formatSubdomainServiceLinks(row.serviceLinks),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">
|
||||
{formatSubdomainServiceLinks(row.original.serviceLinks)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'created_at',
|
||||
accessorFn: (row) => row.subdomain.created_at,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Создан" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDate(row.original.subdomain.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="icon" className="size-8" />}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">Действия</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={isToggling}
|
||||
onClick={() => onToggleEnabled(row.original)}
|
||||
>
|
||||
{row.original.subdomain.enabled ? 'Деактивировать' : 'Активировать'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId }}
|
||||
search={{ host: row.original.subdomain.name }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
onClick={() => onDelete(row.original)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[domainId, isDeleting, isToggling, onDelete, onEdit, onToggleEnabled],
|
||||
)
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
import { useState, useEffect, type ReactNode } from 'react'
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type RowSelectionState,
|
||||
type VisibilityState,
|
||||
type OnChangeFn,
|
||||
} from '@tanstack/react-table'
|
||||
import { InboxIcon, Columns3Icon } from 'lucide-react'
|
||||
|
||||
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
} from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
|
||||
import { EmptyState } from './empty-state'
|
||||
import type { DataGridColumn } from './data-grid-types'
|
||||
|
||||
const PAGINATION_LABELS = {
|
||||
rowsPerPageLabel: 'Строк на странице',
|
||||
info: '{from}–{to} из {count}',
|
||||
previousPageLabel: 'Предыдущая страница',
|
||||
nextPageLabel: 'Следующая страница',
|
||||
pageLabel: 'Страница {page}',
|
||||
previousPagesLabel: 'Предыдущие страницы',
|
||||
nextPagesLabel: 'Следующие страницы',
|
||||
} as const
|
||||
|
||||
function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
|
||||
if (headerTitle) return headerTitle
|
||||
if (typeof header === 'string') return header
|
||||
return ''
|
||||
}
|
||||
|
||||
function loadStoredColumnVisibility(key: string): VisibilityState | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return undefined
|
||||
return JSON.parse(raw) as VisibilityState
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export { loadStoredColumnVisibility }
|
||||
|
||||
export interface DataGridColumnVisibilityOption {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export function dataGridColumnVisibilityOptions<T>(
|
||||
cols: DataGridColumn<T>[],
|
||||
): DataGridColumnVisibilityOption[] {
|
||||
return cols
|
||||
.filter((c) => c.enableHiding !== false)
|
||||
.map((c) => ({
|
||||
id: c.key,
|
||||
label: c.headerTitle ?? (typeof c.header === 'string' ? c.header : c.key),
|
||||
}))
|
||||
}
|
||||
|
||||
export interface DataGridCardProps<TData extends object> {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
data: TData[]
|
||||
/** Ключ строки — функция, возвращающая уникальный id. */
|
||||
rowId?: (row: TData, index: number) => string
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyAction?: ReactNode
|
||||
onRowClick?: (row: TData) => void
|
||||
/** Включить пагинацию. По умолчанию true. */
|
||||
pagination?: boolean
|
||||
/** Размер страницы. По умолчанию 10. */
|
||||
pageSize?: number
|
||||
/** Footer-контент (итоги). */
|
||||
footerContent?: ReactNode
|
||||
/** Плотный layout. */
|
||||
dense?: boolean
|
||||
/** Колонка действий закреплена справа. */
|
||||
pinLastColumn?: boolean
|
||||
/** Сортировка по умолчанию. */
|
||||
initialSorting?: SortingState
|
||||
/** Включить виртуализацию строк (для тяжёлых таблиц). Требует height. */
|
||||
virtualization?: boolean
|
||||
/** Высота viewport для виртуализации (px). По умолчанию 480. */
|
||||
height?: number
|
||||
/** Включить выбор строк (чекбоксы). */
|
||||
enableRowSelection?: boolean
|
||||
/** Callback при изменении выбора. */
|
||||
onRowSelectionChange?: (selectedIds: string[]) => void
|
||||
/** Показать picker видимости колонок. */
|
||||
enableColumnVisibility?: boolean
|
||||
/** Управляемая видимость колонок (для внешнего UI, напр. тулбар «Вид»). */
|
||||
columnVisibility?: VisibilityState
|
||||
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
|
||||
/** Показать встроенную кнопку «Колонки». По умолчанию true при enableColumnVisibility. */
|
||||
columnVisibilityTrigger?: boolean
|
||||
/** Ключ localStorage для сохранения видимости колонок. */
|
||||
columnVisibilityStorageKey?: string
|
||||
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
|
||||
initialColumnVisibility?: VisibilityState
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DataGridSectionHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
if (!title && !description && !actions) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
{title ? <h3 className="text-sm font-medium">{title}</h3> : null}
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridPaginationBar() {
|
||||
return (
|
||||
<div className="border-t border-border px-4 py-2.5">
|
||||
<DataGridPagination {...PAGINATION_LABELS} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridCardBody<TData extends object>({
|
||||
table,
|
||||
data,
|
||||
emptyTitle,
|
||||
onRowClick,
|
||||
dense,
|
||||
virtualization,
|
||||
height,
|
||||
footerContent,
|
||||
showPagination,
|
||||
enableColumnVisibility,
|
||||
}: {
|
||||
table: ReturnType<typeof useReactTable<TData>>
|
||||
data: TData[]
|
||||
emptyTitle: string
|
||||
onRowClick?: (row: TData) => void
|
||||
dense: boolean
|
||||
virtualization: boolean
|
||||
height: number
|
||||
footerContent?: ReactNode
|
||||
showPagination: boolean
|
||||
enableColumnVisibility: boolean
|
||||
}) {
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
onRowClick={onRowClick}
|
||||
emptyMessage={emptyTitle}
|
||||
tableLayout={{
|
||||
dense,
|
||||
stripped: true,
|
||||
rowBorder: true,
|
||||
headerSticky: true,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
width: 'auto',
|
||||
columnsVisibility: enableColumnVisibility,
|
||||
columnsResizable: false,
|
||||
columnsPinnable: false,
|
||||
columnsMovable: false,
|
||||
rowsDraggable: false,
|
||||
rowsPinnable: false,
|
||||
}}
|
||||
tableClassNames={{
|
||||
header: 'text-xs font-medium text-muted-foreground',
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
{virtualization ? (
|
||||
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||||
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
||||
</DataGridScrollArea>
|
||||
) : (
|
||||
<DataGridTable footerContent={footerContent} />
|
||||
)}
|
||||
</DataGridContainer>
|
||||
{showPagination ? <DataGridPaginationBar /> : null}
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataGridCard<TData extends object>({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
columns,
|
||||
data,
|
||||
rowId,
|
||||
emptyTitle = 'Нет записей',
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
onRowClick,
|
||||
pagination,
|
||||
pageSize = 10,
|
||||
footerContent,
|
||||
dense = true,
|
||||
pinLastColumn = false,
|
||||
initialSorting,
|
||||
virtualization = false,
|
||||
height = 480,
|
||||
enableRowSelection = false,
|
||||
onRowSelectionChange,
|
||||
enableColumnVisibility = false,
|
||||
columnVisibility: columnVisibilityProp,
|
||||
onColumnVisibilityChange,
|
||||
columnVisibilityTrigger,
|
||||
columnVisibilityStorageKey,
|
||||
initialColumnVisibility,
|
||||
className,
|
||||
}: DataGridCardProps<TData>) {
|
||||
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() => {
|
||||
const stored = columnVisibilityStorageKey
|
||||
? loadStoredColumnVisibility(columnVisibilityStorageKey)
|
||||
: undefined
|
||||
return { ...initialColumnVisibility, ...stored }
|
||||
})
|
||||
|
||||
const isColumnVisibilityControlled = columnVisibilityProp !== undefined
|
||||
const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility
|
||||
const setColumnVisibility: OnChangeFn<VisibilityState> = isColumnVisibilityControlled
|
||||
? (onColumnVisibilityChange ?? (() => undefined))
|
||||
: setInternalColumnVisibility
|
||||
|
||||
useEffect(() => {
|
||||
if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return
|
||||
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
|
||||
}, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled])
|
||||
|
||||
const selectColumn: ColumnDef<TData, unknown> = {
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Выбрать все"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Выбрать строку"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: { cellClassName: 'w-10' },
|
||||
}
|
||||
|
||||
const tableColumns = enableRowSelection ? [selectColumn, ...columns] : columns
|
||||
|
||||
const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : ''
|
||||
|
||||
const showPagination = pagination ?? true
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
data,
|
||||
columns: tableColumns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
...(enableRowSelection ? { rowSelection } : {}),
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: enableRowSelection
|
||||
? (updater) => {
|
||||
setRowSelection((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater
|
||||
if (onRowSelectionChange && rowId) {
|
||||
const ids = Object.keys(next).filter((k) => next[k])
|
||||
onRowSelectionChange(ids)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
: undefined,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined,
|
||||
initialState: {
|
||||
...(showPagination ? { pagination: { pageIndex: 0, pageSize } } : {}),
|
||||
...(pinLastColumn && lastColId ? { columnPinning: { right: [lastColId] } } : {}),
|
||||
},
|
||||
getRowId: rowId
|
||||
? (row, index) => rowId(row, index)
|
||||
: undefined,
|
||||
enableColumnPinning: pinLastColumn,
|
||||
enableRowSelection,
|
||||
enableHiding: enableColumnVisibility,
|
||||
})
|
||||
|
||||
const showColumnVisibilityTrigger =
|
||||
enableColumnVisibility && (columnVisibilityTrigger ?? true)
|
||||
|
||||
const columnVisibilityAction = showColumnVisibilityTrigger ? (
|
||||
<DataGridColumnVisibility
|
||||
table={table}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm">
|
||||
<Columns3Icon data-icon="inline-start" />
|
||||
Колонки
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
|
||||
const headerActions = actions ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{columnVisibilityAction}
|
||||
{actions}
|
||||
</div>
|
||||
) : (
|
||||
columnVisibilityAction
|
||||
)
|
||||
|
||||
const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger)
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-3', className)}>
|
||||
{hasHeader ? (
|
||||
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||
) : null}
|
||||
<EmptyState icon={InboxIcon} title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const gridBody = (
|
||||
<DataGridCardBody
|
||||
table={table}
|
||||
data={data}
|
||||
emptyTitle={emptyTitle}
|
||||
onRowClick={onRowClick}
|
||||
dense={dense}
|
||||
virtualization={virtualization}
|
||||
height={height}
|
||||
footerContent={footerContent}
|
||||
showPagination={showPagination}
|
||||
enableColumnVisibility={enableColumnVisibility}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-3', className)}>
|
||||
{hasHeader ? (
|
||||
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||
) : null}
|
||||
{gridBody}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Хелпер для конвертации DataGridColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
|
||||
export function columnDefFromDataGrid<T>(
|
||||
cols: DataGridColumn<T>[],
|
||||
): ColumnDef<T, unknown>[] {
|
||||
return cols.map((c) => {
|
||||
const title = resolveHeaderTitle(c.header, c.headerTitle)
|
||||
const Icon = c.icon
|
||||
const sortable = c.sortable ?? Boolean(Icon)
|
||||
|
||||
return {
|
||||
id: c.key,
|
||||
...(sortable
|
||||
? {
|
||||
accessorFn: c.sortValue
|
||||
? (row: T) => c.sortValue!(row)
|
||||
: (row: T) => (row as Record<string, unknown>)[c.key] as string | number,
|
||||
}
|
||||
: {}),
|
||||
header: Icon
|
||||
? ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
column={column}
|
||||
title={title}
|
||||
icon={<Icon />}
|
||||
/>
|
||||
)
|
||||
: () => c.header,
|
||||
cell: ({ row }) => c.cell(row.original, row.index),
|
||||
enableSorting: sortable,
|
||||
enableHiding: c.enableHiding ?? true,
|
||||
meta: {
|
||||
headerTitle: title || undefined,
|
||||
cellClassName: c.className,
|
||||
headerClassName: c.headerClassName,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** @deprecated Используйте columnDefFromDataGrid */
|
||||
export const columnDefFromDataTable = columnDefFromDataGrid
|
||||
|
||||
/** re-export flexRender для удобства использования в колонках. */
|
||||
export { flexRender }
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface DataGridColumn<T> {
|
||||
key: string
|
||||
header: ReactNode
|
||||
cell: (row: T, index: number) => ReactNode
|
||||
icon?: LucideIcon
|
||||
sortable?: boolean
|
||||
sortValue?: (row: T) => string | number
|
||||
headerTitle?: string
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
enableHiding?: boolean
|
||||
}
|
||||
|
||||
/** @deprecated Используйте DataGridColumn */
|
||||
export type DataTableColumn<T> = DataGridColumn<T>
|
||||
|
||||
/** Унифицированные классы колонок для DataGridCard. */
|
||||
export const COL = {
|
||||
num: 'w-28 text-right tabular-nums',
|
||||
date: 'w-32 text-right tabular-nums text-muted-foreground',
|
||||
actions: 'w-24 text-right',
|
||||
} as const
|
||||
@@ -1,188 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
DataGridCard,
|
||||
columnDefFromDataGrid,
|
||||
} from '@/components/data-grid-card'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { ListFiltersBar } from '@/components/list-filters-bar'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DnsRecord, IpHealthStatus } from '@/lib/schemas'
|
||||
import { formatDate } from '@/lib/format'
|
||||
|
||||
interface DnsRecordsDataGridProps {
|
||||
records: DnsRecord[]
|
||||
onDelete: (recordId: number) => void
|
||||
isDeleting?: boolean
|
||||
healthByIp?: Record<string, IpHealthStatus>
|
||||
search?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
hideSearch?: boolean
|
||||
}
|
||||
|
||||
const healthDotClass: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'bg-success',
|
||||
degraded: 'bg-warning',
|
||||
down: 'bg-destructive',
|
||||
unknown: 'bg-muted-foreground/40',
|
||||
}
|
||||
|
||||
const healthLabel: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'OK',
|
||||
degraded: 'Деград.',
|
||||
down: 'Down',
|
||||
unknown: '—',
|
||||
}
|
||||
|
||||
function IpHealthDot({ health }: { health: IpHealthStatus }) {
|
||||
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
|
||||
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
|
||||
if (health.last_checked_at) tooltipParts.push(`Проверка: ${formatDate(health.last_checked_at)}`)
|
||||
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-2 shrink-0 cursor-default rounded-full',
|
||||
healthDotClass[health.status],
|
||||
)}
|
||||
aria-label={`Health: ${healthLabel[health.status]}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function DnsRecordsDataGrid({
|
||||
records,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
healthByIp,
|
||||
search: controlledSearch,
|
||||
onSearchChange,
|
||||
hideSearch = false,
|
||||
}: DnsRecordsDataGridProps) {
|
||||
const [internalSearch, setInternalSearch] = useState('')
|
||||
const search = controlledSearch ?? internalSearch
|
||||
const setSearch = onSearchChange ?? setInternalSearch
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
const query = search.trim().toLowerCase()
|
||||
if (!query) return records
|
||||
return records.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(query) ||
|
||||
r.content.toLowerCase().includes(query) ||
|
||||
r.record_type.toLowerCase().includes(query),
|
||||
)
|
||||
}, [records, search])
|
||||
|
||||
const columns = useMemo<DataGridColumn<DnsRecord>[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'record_type',
|
||||
header: 'Тип',
|
||||
sortable: true,
|
||||
sortValue: (row) => row.record_type,
|
||||
cell: (row) => <Badge variant="outline">{row.record_type}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Имя',
|
||||
sortable: true,
|
||||
sortValue: (row) => row.name,
|
||||
cell: (row) => <span className="font-medium">{row.name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
header: 'Значение',
|
||||
sortable: true,
|
||||
sortValue: (row) => row.content,
|
||||
cell: (row) => {
|
||||
const health = healthByIp?.[row.content]
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{health ? <IpHealthDot health={health} /> : null}
|
||||
<span className="truncate font-mono text-sm">{row.content}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ttl',
|
||||
header: 'TTL',
|
||||
sortable: true,
|
||||
sortValue: (row) => row.ttl,
|
||||
className: 'tabular-nums',
|
||||
cell: (row) => row.ttl,
|
||||
},
|
||||
{
|
||||
key: 'sync_status',
|
||||
header: 'Синхронизация',
|
||||
cell: (row) => <StatusBadge status={row.sync_status} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
enableHiding: false,
|
||||
className: 'text-right',
|
||||
cell: (row) => (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="destructive" size="sm" disabled={isDeleting}>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить DNS-запись?"
|
||||
description={`Запись ${row.name} (${row.record_type}) будет удалена из зоны.`}
|
||||
onConfirm={() => onDelete(row.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[healthByIp, isDeleting, onDelete],
|
||||
)
|
||||
|
||||
return (
|
||||
<DataGridCard
|
||||
title="DNS-записи"
|
||||
description="Записи в зоне"
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={filteredRecords}
|
||||
rowId={(row) => String(row.id)}
|
||||
emptyTitle="DNS-записи не найдены"
|
||||
emptyDescription="Создайте запись или измените фильтры"
|
||||
pinLastColumn
|
||||
actions={
|
||||
hideSearch ? undefined : (
|
||||
<ListFiltersBar
|
||||
search={{
|
||||
value: search,
|
||||
onChange: setSearch,
|
||||
placeholder: 'Поиск по имени или значению…',
|
||||
}}
|
||||
shown={filteredRecords.length}
|
||||
total={records.length}
|
||||
showReset={Boolean(search.trim())}
|
||||
onReset={() => setSearch('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
import { AppButton } from '@/components/app-button'
|
||||
|
||||
interface DomainActionsBarProps {
|
||||
onCreateSubdomain: () => void
|
||||
}
|
||||
|
||||
export function DomainActionsBar({ onCreateSubdomain }: DomainActionsBarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AppButton onClick={onCreateSubdomain}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Создать поддомен
|
||||
</AppButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,9 +37,9 @@ interface DomainBindingsCardProps {
|
||||
}
|
||||
|
||||
const healthDotClass: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'bg-emerald-500',
|
||||
degraded: 'bg-amber-500',
|
||||
down: 'bg-rose-500',
|
||||
up: 'bg-success',
|
||||
degraded: 'bg-warning',
|
||||
down: 'bg-destructive',
|
||||
unknown: 'bg-muted-foreground/40',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { Domain } from '@/lib/schemas'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
|
||||
interface DomainHeaderProps {
|
||||
domain: Domain
|
||||
onCertMonitoringChange?: (value: CertMonitoring) => void
|
||||
isCertMonitoringSaving?: boolean
|
||||
}
|
||||
|
||||
export function DomainHeader({
|
||||
domain,
|
||||
onCertMonitoringChange,
|
||||
isCertMonitoringSaving,
|
||||
}: DomainHeaderProps) {
|
||||
const certMonitoringItems = certMonitoringOptions.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{domain.zone_name}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Домен: <span className="font-mono text-foreground">{domain.zone_name}</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Статус зоны:</span>
|
||||
<StatusBadge status={domain.status} />
|
||||
</div>
|
||||
{onCertMonitoringChange && (
|
||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Мониторинг SSL (apex):
|
||||
</span>
|
||||
<Select
|
||||
items={certMonitoringItems}
|
||||
value={domain.cert_monitoring}
|
||||
onValueChange={(value) =>
|
||||
onCertMonitoringChange((value ?? 'auto') as CertMonitoring)
|
||||
}
|
||||
disabled={isCertMonitoringSaving}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-56">
|
||||
<SelectValue>
|
||||
{certMonitoringLabel(domain.cert_monitoring)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{certMonitoringOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { GlobeIcon, MoreHorizontalIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
DataGridCard,
|
||||
columnDefFromDataGrid,
|
||||
} from '@/components/data-grid-card'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { DomainsFiltersToolbar } from '@/components/domains-filters-toolbar'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DomainListItem } from '@/lib/schemas'
|
||||
import { useState } from 'react'
|
||||
|
||||
export type DomainTableRow = DomainListItem
|
||||
|
||||
interface GroupFilterItem {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface DomainsDataGridProps {
|
||||
data: DomainTableRow[]
|
||||
totalCount: number
|
||||
groupFilterItems: GroupFilterItem[]
|
||||
groupFilterValue: string
|
||||
onGroupFilterChange: (value: string | null) => void
|
||||
search: string
|
||||
onSearchChange: (value: string) => void
|
||||
onDelete: (domain: DomainTableRow) => void
|
||||
isDeleting?: boolean
|
||||
emptyAction?: React.ReactNode
|
||||
}
|
||||
|
||||
export function DomainsDataGrid({
|
||||
data,
|
||||
totalCount,
|
||||
groupFilterItems,
|
||||
groupFilterValue,
|
||||
onGroupFilterChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
emptyAction,
|
||||
}: DomainsDataGridProps) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<DomainTableRow | null>(null)
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const query = search.trim().toLowerCase()
|
||||
if (!query) return data
|
||||
return data.filter(
|
||||
(d) =>
|
||||
d.zone_name.toLowerCase().includes(query) ||
|
||||
(d.group_name ?? '').toLowerCase().includes(query),
|
||||
)
|
||||
}, [data, search])
|
||||
|
||||
const columns = useMemo<DataGridColumn<DomainTableRow>[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'zone_name',
|
||||
header: 'Зона',
|
||||
icon: GlobeIcon,
|
||||
sortable: true,
|
||||
sortValue: (row) => row.zone_name,
|
||||
cell: (row) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/domains/$domainId" params={{ domainId: String(row.id) }} />
|
||||
}
|
||||
>
|
||||
{row.zone_name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'group',
|
||||
header: 'Группа',
|
||||
sortable: true,
|
||||
sortValue: (row) => row.group_name ?? 'Без группы',
|
||||
cell: (row) => {
|
||||
if (row.group_id && row.group_name) {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId: String(row.group_id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{row.group_name}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return <Badge variant="outline">Без группы</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'service_count',
|
||||
header: 'Сервисы',
|
||||
sortable: true,
|
||||
sortValue: (row) => row.service_count,
|
||||
className: 'tabular-nums',
|
||||
cell: (row) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 tabular-nums"
|
||||
nativeButton={false}
|
||||
render={<Link to="/services" search={{ domainId: row.id }} />}
|
||||
>
|
||||
{row.service_count}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Статус',
|
||||
cell: (row) => <StatusBadge status={row.status} />,
|
||||
},
|
||||
{
|
||||
key: 'last_synced_at',
|
||||
header: 'Синхронизация',
|
||||
sortable: true,
|
||||
sortValue: (row) => row.last_synced_at ?? '',
|
||||
className: 'text-muted-foreground tabular-nums',
|
||||
cell: (row) => row.last_synced_at ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
enableHiding: false,
|
||||
className: 'text-right',
|
||||
cell: (row) => (
|
||||
<div className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="icon" className="size-8" />}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">Действия</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link to="/domains/$domainId" params={{ domainId: String(row.id) }} />
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId: String(row.id) }}
|
||||
search={{ host: undefined }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[isDeleting],
|
||||
)
|
||||
|
||||
const groupSelect = (
|
||||
<Select
|
||||
items={[{ label: 'Все группы', value: 'all' }, ...groupFilterItems]}
|
||||
value={groupFilterValue || 'all'}
|
||||
onValueChange={(value) => {
|
||||
if (!value || value === 'all') {
|
||||
onGroupFilterChange(null)
|
||||
return
|
||||
}
|
||||
onGroupFilterChange(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-56">
|
||||
<SelectValue placeholder="Все группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все группы</SelectItem>
|
||||
{groupFilterItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
const hasActiveFilters = Boolean(groupFilterValue) || Boolean(search.trim())
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
title="Список доменов"
|
||||
description="Импортированные зоны Cloudflare"
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={filteredData}
|
||||
rowId={(row) => String(row.id)}
|
||||
emptyTitle="Домены не найдены"
|
||||
emptyDescription="Импортируйте зону из Cloudflare или измените фильтры"
|
||||
emptyAction={emptyAction}
|
||||
pinLastColumn
|
||||
actions={
|
||||
<DomainsFiltersToolbar
|
||||
search={search}
|
||||
onSearchChange={onSearchChange}
|
||||
groupSelect={groupSelect}
|
||||
shownCount={filteredData.length}
|
||||
totalCount={totalCount}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
onReset={() => {
|
||||
onSearchChange('')
|
||||
onGroupFilterChange(null)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null)
|
||||
}}
|
||||
title="Удалить зону?"
|
||||
description={
|
||||
deleteTarget
|
||||
? `Зона «${deleteTarget.zone_name}» будет удалена из менеджера вместе с DNS-записями и привязками. Зона в Cloudflare не затрагивается.`
|
||||
: ''
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (!deleteTarget) return
|
||||
onDelete(deleteTarget)
|
||||
setDeleteTarget(null)
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { ListFiltersBar } from '@/components/list-filters-bar'
|
||||
import {
|
||||
Filters,
|
||||
createFilter,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
|
||||
const STATUS_FIELD: FilterFieldConfig = {
|
||||
key: 'status',
|
||||
label: 'Статус',
|
||||
type: 'multiselect',
|
||||
options: [
|
||||
{ value: 'active', label: 'Активен' },
|
||||
{ value: 'synced', label: 'Синхронизировано' },
|
||||
{ value: 'pending_push', label: 'Ожидает отправки' },
|
||||
{ value: 'error', label: 'Ошибка' },
|
||||
],
|
||||
}
|
||||
|
||||
interface DomainsFiltersToolbarProps {
|
||||
search: string
|
||||
onSearchChange: (value: string) => void
|
||||
groupSelect: ReactNode
|
||||
statusFilters?: Filter[]
|
||||
onStatusFiltersChange?: (filters: Filter[]) => void
|
||||
shownCount: number
|
||||
totalCount: number
|
||||
hasActiveFilters: boolean
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export function DomainsFiltersToolbar({
|
||||
search,
|
||||
onSearchChange,
|
||||
groupSelect,
|
||||
statusFilters = [],
|
||||
onStatusFiltersChange,
|
||||
shownCount,
|
||||
totalCount,
|
||||
hasActiveFilters,
|
||||
onReset,
|
||||
}: DomainsFiltersToolbarProps) {
|
||||
return (
|
||||
<ListFiltersBar
|
||||
search={{
|
||||
value: search,
|
||||
onChange: onSearchChange,
|
||||
placeholder: 'Поиск по зоне или группе…',
|
||||
}}
|
||||
controls={
|
||||
<>
|
||||
{groupSelect}
|
||||
{onStatusFiltersChange ? (
|
||||
<Filters
|
||||
filters={statusFilters}
|
||||
fields={[STATUS_FIELD]}
|
||||
onChange={onStatusFiltersChange}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
shown={shownCount}
|
||||
total={totalCount}
|
||||
showReset={hasActiveFilters}
|
||||
onReset={onReset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function createStatusFilter(value: string): Filter {
|
||||
return createFilter('status', 'is', [value])
|
||||
}
|
||||
|
||||
export function applyStatusFilters<T extends { status: string }>(
|
||||
rows: T[],
|
||||
filters: Filter[],
|
||||
): T[] {
|
||||
const statusFilter = filters.find((f) => f.field === 'status')
|
||||
if (!statusFilter?.values?.length) return rows
|
||||
const allowed = new Set(statusFilter.values.map(String))
|
||||
return rows.filter((row) => allowed.has(row.status))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { InboxIcon, type LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@cfdm/ui/components/empty'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon
|
||||
icon?: LucideIcon
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
@@ -17,7 +17,7 @@ interface EmptyStateProps {
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
icon: Icon = InboxIcon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ShieldCheckIcon } from 'lucide-react'
|
||||
import type { Certificate } from '@/lib/schemas'
|
||||
import { formatRelative } from '@/lib/format'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
AppCard,
|
||||
AppCardContent,
|
||||
AppCardDescription,
|
||||
AppCardHeader,
|
||||
AppCardTitle,
|
||||
} from '@/components/app-card'
|
||||
import {
|
||||
AppItem,
|
||||
AppItemContent,
|
||||
AppItemDescription,
|
||||
AppItemGroup,
|
||||
AppItemTitle,
|
||||
} from '@/components/app-item'
|
||||
import { ScrollArea } from '@cfdm/ui/components/scroll-area'
|
||||
|
||||
const WARN_DAYS = 14
|
||||
const DAY_MS = 1000 * 60 * 60 * 24
|
||||
|
||||
interface ExpiringCertsCardProps {
|
||||
certificates: Certificate[]
|
||||
limit?: number
|
||||
}
|
||||
|
||||
interface UpcomingEntry {
|
||||
cert: Certificate
|
||||
ts: number
|
||||
expired: boolean
|
||||
}
|
||||
|
||||
function selectUpcoming(
|
||||
certificates: Certificate[],
|
||||
limit: number,
|
||||
now: number,
|
||||
): UpcomingEntry[] {
|
||||
return certificates
|
||||
.filter((c) => c.expires_at)
|
||||
.map((c) => {
|
||||
const ts = new Date(c.expires_at as string).getTime()
|
||||
return { cert: c, ts, expired: ts < now }
|
||||
})
|
||||
.filter((entry) => Number.isNaN(entry.ts) === false)
|
||||
.sort((a, b) => a.ts - b.ts)
|
||||
.filter((entry) => (entry.ts - now) / DAY_MS <= WARN_DAYS)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
export function ExpiringCertsCard({ certificates, limit = 6 }: ExpiringCertsCardProps) {
|
||||
const [now] = useState(() => Date.now())
|
||||
|
||||
const upcoming = useMemo(
|
||||
() => selectUpcoming(certificates, limit, now),
|
||||
[certificates, limit, now],
|
||||
)
|
||||
|
||||
return (
|
||||
<AppCard>
|
||||
<AppCardHeader>
|
||||
<AppCardTitle className="text-base">Истекающие сертификаты</AppCardTitle>
|
||||
<AppCardDescription>
|
||||
Хосты с истечением срока в течение {WARN_DAYS} дней
|
||||
</AppCardDescription>
|
||||
</AppCardHeader>
|
||||
<AppCardContent>
|
||||
{upcoming.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ShieldCheckIcon}
|
||||
title="Все сертификаты в норме"
|
||||
description="Ближайшие истечения в пределах двух недель не обнаружены"
|
||||
/>
|
||||
) : (
|
||||
<ScrollArea className="h-72">
|
||||
<AppItemGroup className="gap-2 pr-3">
|
||||
{upcoming.map((entry) => (
|
||||
<AppItem key={entry.cert.id} variant="outline" size="sm">
|
||||
<AppItemContent className="flex flex-row items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<AppItemTitle className="truncate font-medium">
|
||||
{entry.cert.hostname}
|
||||
</AppItemTitle>
|
||||
<AppItemDescription className="tabular-nums">
|
||||
{formatRelative(entry.cert.expires_at)}
|
||||
</AppItemDescription>
|
||||
</div>
|
||||
<StatusBadge status={entry.expired ? 'expired' : entry.cert.status} />
|
||||
</AppItemContent>
|
||||
</AppItem>
|
||||
))}
|
||||
</AppItemGroup>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</AppCardContent>
|
||||
</AppCard>
|
||||
)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { FolderTreeIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||
import type { BoardColumn } from '@/components/groups-board/types'
|
||||
import type { Group } from '@/lib/schemas'
|
||||
import { AppAccordionTrigger } from '@/components/app-accordion'
|
||||
import { AppBadge } from '@/components/app-badge'
|
||||
import { AppButton } from '@/components/app-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
|
||||
interface DomainGroupHeaderProps {
|
||||
column: BoardColumn
|
||||
isOpen?: boolean
|
||||
isDragging?: boolean
|
||||
dragDisabled?: boolean
|
||||
onEditGroup?: (group: Group) => void
|
||||
onDeleteGroup?: (group: Group) => void
|
||||
}
|
||||
|
||||
export function DomainGroupHeader({
|
||||
column,
|
||||
isOpen = true,
|
||||
isDragging = false,
|
||||
dragDisabled = false,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
}: DomainGroupHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-1">
|
||||
<AppAccordionTrigger className="min-h-10 flex-1 items-center gap-2 rounded-md py-2 hover:bg-muted/40 hover:no-underline">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{column.groupId !== null ? (
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-background/80 text-muted-foreground">
|
||||
<FolderTreeIcon />
|
||||
</span>
|
||||
) : null}
|
||||
<span className="font-medium">{column.title}</span>
|
||||
<AppBadge variant="secondary">{column.items.length}</AppBadge>
|
||||
{column.slug ? (
|
||||
<AppBadge variant="outline" className="font-mono">
|
||||
{column.slug}
|
||||
</AppBadge>
|
||||
) : null}
|
||||
{!isOpen && isDragging && !dragDisabled ? (
|
||||
<AppBadge variant="default" className="font-normal">
|
||||
Отпустите для переноса
|
||||
</AppBadge>
|
||||
) : null}
|
||||
</div>
|
||||
</AppAccordionTrigger>
|
||||
|
||||
{column.groupId !== null && column.group ? (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1 pr-1"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<AppButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Действия для группы ${column.title}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onEditGroup ? (
|
||||
<DropdownMenuItem onClick={() => onEditGroup(column.group!)}>
|
||||
<PencilIcon data-icon="inline-start" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onDeleteGroup ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDeleteGroup(column.group!)}
|
||||
>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
Удалить группу
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { DomainGroupHeader } from '@/components/groups-board/domain-group-header'
|
||||
import { DomainRow } from '@/components/groups-board/domain-row'
|
||||
import type { BoardColumn } from '@/components/groups-board/types'
|
||||
import type { Group } from '@/lib/schemas'
|
||||
import {
|
||||
AppAccordionContent,
|
||||
AppAccordionItem,
|
||||
} from '@/components/app-accordion'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
import { AppItemGroup, AppItemSeparator } from '@/components/app-item'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface DomainGroupItemProps {
|
||||
column: BoardColumn
|
||||
isOpen?: boolean
|
||||
isDragging?: boolean
|
||||
onExpandColumn?: (columnId: string) => void
|
||||
onEditGroup?: (group: Group) => void
|
||||
onDeleteGroup?: (group: Group) => void
|
||||
dragDisabled?: boolean
|
||||
serviceLabelsByDomain?: Map<number, string[]>
|
||||
}
|
||||
|
||||
export function DomainGroupItem({
|
||||
column,
|
||||
isOpen = true,
|
||||
isDragging = false,
|
||||
onExpandColumn,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
dragDisabled = false,
|
||||
serviceLabelsByDomain,
|
||||
}: DomainGroupItemProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: column.id,
|
||||
disabled: dragDisabled,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isOver && isDragging && !dragDisabled) {
|
||||
onExpandColumn?.(column.id)
|
||||
}
|
||||
}, [isOver, isDragging, dragDisabled, column.id, onExpandColumn])
|
||||
|
||||
return (
|
||||
<AppAccordionItem
|
||||
value={column.id}
|
||||
className={cn(
|
||||
'not-last:border-b-0 overflow-hidden rounded-lg border border-border transition-colors',
|
||||
!isOpen && 'bg-muted/20',
|
||||
isOpen && 'bg-muted/30',
|
||||
isOver && !dragDisabled && 'ring-2 ring-primary/30',
|
||||
)}
|
||||
>
|
||||
<DomainGroupHeader
|
||||
column={column}
|
||||
isOpen={isOpen}
|
||||
isDragging={isDragging}
|
||||
dragDisabled={dragDisabled}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
/>
|
||||
|
||||
<AppAccordionContent className="px-1 pb-2">
|
||||
<div ref={setNodeRef}>
|
||||
{column.items.length > 0 ? (
|
||||
<AppItemGroup className="gap-0 py-1">
|
||||
{column.items.map((domain, index) => (
|
||||
<div key={domain.id}>
|
||||
{index > 0 ? <AppItemSeparator className="my-0" /> : null}
|
||||
<DomainRow
|
||||
domain={domain}
|
||||
serviceLabels={serviceLabelsByDomain?.get(domain.id)}
|
||||
dragDisabled={dragDisabled}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</AppItemGroup>
|
||||
) : (
|
||||
<Empty
|
||||
className={cn(
|
||||
'border border-dashed py-2',
|
||||
isOver && !dragDisabled && 'border-primary bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle className="text-sm">Нет доменов в группе</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Перетащите домен сюда
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</AppAccordionContent>
|
||||
</AppAccordionItem>
|
||||
)
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
GripVerticalIcon,
|
||||
MoreHorizontalIcon,
|
||||
ServerIcon,
|
||||
} from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DomainListItem } from '@/lib/schemas'
|
||||
import { AppBadge } from '@/components/app-badge'
|
||||
import { AppButton } from '@/components/app-button'
|
||||
import { AppCard, AppCardContent } from '@/components/app-card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export interface DomainRowProps {
|
||||
domain: DomainListItem
|
||||
serviceLabels?: string[]
|
||||
dragDisabled?: boolean
|
||||
overlay?: boolean
|
||||
}
|
||||
|
||||
function DomainServiceList({
|
||||
labels,
|
||||
serviceCount,
|
||||
}: {
|
||||
labels: string[]
|
||||
serviceCount: number
|
||||
}) {
|
||||
if (labels.length === 0) {
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{serviceCount > 0 ? `${serviceCount} сервис(ов)` : 'Нет сервисов'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (labels.length === 1) {
|
||||
return (
|
||||
<AppBadge variant="secondary" className="max-w-full truncate font-normal">
|
||||
{labels[0]}
|
||||
</AppBadge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppCard size="sm" className="w-full shadow-none">
|
||||
<AppCardContent className="flex flex-col gap-1 py-0">
|
||||
{labels.map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<ServerIcon className="size-3 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</AppCardContent>
|
||||
</AppCard>
|
||||
)
|
||||
}
|
||||
|
||||
export const DomainRow = memo(function DomainRow({
|
||||
domain,
|
||||
serviceLabels = [],
|
||||
dragDisabled = false,
|
||||
overlay = false,
|
||||
}: DomainRowProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: String(domain.id),
|
||||
disabled: dragDisabled || overlay,
|
||||
})
|
||||
|
||||
const style = transform
|
||||
? { transform: CSS.Translate.toString(transform) }
|
||||
: undefined
|
||||
|
||||
const hasServiceList = serviceLabels.length > 1
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlay ? undefined : setNodeRef}
|
||||
style={overlay ? undefined : style}
|
||||
className={cn(
|
||||
'flex gap-3 rounded-md px-3 transition-colors hover:bg-muted/50',
|
||||
hasServiceList ? 'items-start py-2' : 'h-10 items-center',
|
||||
(isDragging || overlay) && 'opacity-90 shadow-md',
|
||||
isDragging && !overlay && 'z-10',
|
||||
)}
|
||||
>
|
||||
{!dragDisabled && !overlay ? (
|
||||
<AppButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
'touch-none shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing',
|
||||
hasServiceList && 'mt-0.5',
|
||||
)}
|
||||
aria-label={`Перетащить ${domain.zone_name}`}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<GripVerticalIcon />
|
||||
</AppButton>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 shrink-0',
|
||||
hasServiceList ? 'w-28 pt-0.5' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-sm font-medium">{domain.zone_name}</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<DomainServiceList
|
||||
labels={serviceLabels}
|
||||
serviceCount={domain.service_count}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-2',
|
||||
hasServiceList && 'self-center',
|
||||
)}
|
||||
>
|
||||
<StatusBadge status={domain.status} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<AppButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Действия для ${domain.zone_name}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(domain.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ExternalLinkIcon data-icon="inline-start" />
|
||||
Обзор
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link to="/services" search={{ domainId: domain.id }} />
|
||||
}
|
||||
>
|
||||
<ServerIcon data-icon="inline-start" />
|
||||
Сервисы
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
|
||||
export function GroupsBoardSkeleton() {
|
||||
return (
|
||||
<div className="grid w-full grid-cols-1 gap-2 lg:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, groupIndex) => (
|
||||
<div
|
||||
key={groupIndex}
|
||||
className="flex flex-col gap-2 rounded-lg border border-border px-2 py-2"
|
||||
>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 3 }).map((__, rowIndex) => (
|
||||
<Skeleton key={rowIndex} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { DragContextProvider } from '@/components/board/drag-context-provider'
|
||||
import { DomainGroupItem } from '@/components/groups-board/domain-group-item'
|
||||
import { DomainRow } from '@/components/groups-board/domain-row'
|
||||
import type { BoardState } from '@/components/groups-board/types'
|
||||
import type { DomainListItem, Group } from '@/lib/schemas'
|
||||
import { AppAccordion } from '@/components/app-accordion'
|
||||
|
||||
interface GroupsBoardProps {
|
||||
board: BoardState
|
||||
activeDomain?: DomainListItem
|
||||
dragDisabled?: boolean
|
||||
onDragStart: Parameters<typeof DragContextProvider>[0]['onDragStart']
|
||||
onDragEnd: Parameters<typeof DragContextProvider>[0]['onDragEnd']
|
||||
onEditGroup?: (group: Group) => void
|
||||
onDeleteGroup?: (group: Group) => void
|
||||
serviceLabelsByDomain?: Map<number, string[]>
|
||||
}
|
||||
|
||||
export function GroupsBoard({
|
||||
board,
|
||||
activeDomain,
|
||||
dragDisabled = false,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
serviceLabelsByDomain,
|
||||
}: GroupsBoardProps) {
|
||||
const columnIds = useMemo(
|
||||
() => board.columns.map((column) => column.id),
|
||||
[board.columns],
|
||||
)
|
||||
const columnIdsKey = columnIds.join(',')
|
||||
|
||||
const [openColumns, setOpenColumns] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setOpenColumns((prev) => {
|
||||
const preserved = prev.filter((id) => columnIds.includes(id))
|
||||
const added = columnIds.filter((id) => !preserved.includes(id))
|
||||
if (preserved.length === 0 && added.length > 0) {
|
||||
return columnIds
|
||||
}
|
||||
return [...preserved, ...added]
|
||||
})
|
||||
}, [columnIdsKey, columnIds])
|
||||
|
||||
const isDragging = activeDomain != null
|
||||
|
||||
function handleExpandColumn(columnId: string) {
|
||||
setOpenColumns((prev) =>
|
||||
prev.includes(columnId) ? prev : [...prev, columnId],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DragContextProvider
|
||||
disabled={dragDisabled}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
overlay={
|
||||
activeDomain ? (
|
||||
<DomainRow
|
||||
domain={activeDomain}
|
||||
serviceLabels={serviceLabelsByDomain?.get(activeDomain.id)}
|
||||
dragDisabled
|
||||
overlay
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<AppAccordion
|
||||
multiple
|
||||
value={openColumns}
|
||||
onValueChange={setOpenColumns}
|
||||
className="grid w-full grid-cols-1 gap-2 lg:grid-cols-2"
|
||||
>
|
||||
{board.columns.map((column) => (
|
||||
<DomainGroupItem
|
||||
key={column.id}
|
||||
column={column}
|
||||
isOpen={openColumns.includes(column.id)}
|
||||
isDragging={isDragging}
|
||||
onExpandColumn={handleExpandColumn}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
dragDisabled={dragDisabled}
|
||||
serviceLabelsByDomain={serviceLabelsByDomain}
|
||||
/>
|
||||
))}
|
||||
</AppAccordion>
|
||||
</DragContextProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DomainListItem } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemFooter,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface DomainKanbanCardProps {
|
||||
domain: DomainListItem
|
||||
serviceLabels?: string[]
|
||||
isOverlay?: boolean
|
||||
}
|
||||
|
||||
export function DomainKanbanCard({
|
||||
domain,
|
||||
serviceLabels = [],
|
||||
isOverlay,
|
||||
}: DomainKanbanCardProps) {
|
||||
return (
|
||||
<Item
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'bg-card hover:bg-muted/20 items-stretch gap-3 transition-colors',
|
||||
isOverlay && 'shadow-lg',
|
||||
)}
|
||||
>
|
||||
<ItemHeader className="min-w-0 items-start gap-2">
|
||||
<ItemContent className="min-w-0 gap-1">
|
||||
<ItemDescription className="text-muted-foreground text-xs leading-4 font-medium tabular-nums">
|
||||
#{domain.id}
|
||||
</ItemDescription>
|
||||
<ItemTitle className="line-clamp-2 text-[0.9375rem] leading-5 font-medium">
|
||||
{domain.zone_name}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions className="shrink-0">
|
||||
<StatusBadge status={domain.status} />
|
||||
</ItemActions>
|
||||
</ItemHeader>
|
||||
|
||||
<ItemContent className="min-w-0 gap-2">
|
||||
{serviceLabels.length > 0 ? (
|
||||
serviceLabels.slice(0, 2).map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
className="text-muted-foreground flex min-w-0 items-center gap-2 text-sm"
|
||||
>
|
||||
<ServerIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<ServerIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{domain.service_count > 0
|
||||
? `${domain.service_count} сервис(ов)`
|
||||
: 'Нет сервисов'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</ItemContent>
|
||||
|
||||
<ItemFooter className="min-w-0 justify-between gap-2">
|
||||
<Badge variant="outline" size="sm">
|
||||
{domain.group_name ?? 'Без группы'}
|
||||
</Badge>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Действия для ${domain.zone_name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(domain.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
render={<Link to="/services" search={{ domainId: domain.id }} />}
|
||||
>
|
||||
Сервисы
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ItemFooter>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { serviceDisplayFqdn } from '@/lib/service-utils'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemFooter,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface ServiceKanbanCardProps {
|
||||
service: ServiceView
|
||||
isOverlay?: boolean
|
||||
isToggling?: boolean
|
||||
dragDisabled?: boolean
|
||||
onToggle: (serviceId: number, enabled: boolean) => void
|
||||
onEdit: (service: ServiceView) => void
|
||||
onDelete: (service: ServiceView) => void
|
||||
}
|
||||
|
||||
export function ServiceKanbanCard({
|
||||
service,
|
||||
isOverlay,
|
||||
isToggling,
|
||||
dragDisabled,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ServiceKanbanCardProps) {
|
||||
const fqdn = serviceDisplayFqdn(service)
|
||||
|
||||
return (
|
||||
<Item
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'bg-card hover:bg-muted/20 items-stretch gap-3 transition-colors',
|
||||
isOverlay && 'shadow-lg',
|
||||
)}
|
||||
>
|
||||
<ItemHeader className="min-w-0 items-start gap-2">
|
||||
<ItemContent className="min-w-0 gap-1">
|
||||
<ItemDescription className="text-muted-foreground text-xs leading-4 font-medium tabular-nums">
|
||||
#{service.id}
|
||||
</ItemDescription>
|
||||
<ItemTitle className="line-clamp-2 text-[0.9375rem] leading-5 font-medium">
|
||||
{service.name}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions className="shrink-0">
|
||||
<Switch
|
||||
checked={service.enabled ?? false}
|
||||
disabled={isToggling || dragDisabled}
|
||||
onCheckedChange={(checked) => onToggle(service.id, checked)}
|
||||
aria-label={`Включить ${service.name}`}
|
||||
/>
|
||||
</ItemActions>
|
||||
</ItemHeader>
|
||||
|
||||
<ItemContent className="min-w-0 gap-2">
|
||||
{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 ? 'Вкл' : 'Выкл'} />
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline" size="sm">
|
||||
{service.slug}
|
||||
</Badge>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Действия для ${service.name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(service)}>
|
||||
<PencilIcon className="size-4" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDelete(service)}>
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ItemFooter>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { AppSidebar } from '@/components/app-sidebar'
|
||||
import { SiteHeader } from '@/components/layout/site-header'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar'
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<SidebarProvider
|
||||
className={cn(
|
||||
'[--sidebar:color-mix(in_oklab,var(--color-sidebar)_60%,transparent)]',
|
||||
'[--sidebar-border:transparent]',
|
||||
'[--sidebar-accent:color-mix(in_oklab,var(--color-primary)_5%,transparent)]',
|
||||
'[--sidebar-accent-foreground:var(--color-primary)]',
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
||||
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from '@cfdm/ui/components/breadcrumb'
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||
|
||||
export interface RouteBreadcrumbLoaderData {
|
||||
@@ -22,6 +21,7 @@ const routeTitles: Record<string, string> = {
|
||||
'/groups': 'Группы доменов',
|
||||
'/services': 'Сервисы',
|
||||
'/certificates': 'Сертификаты',
|
||||
'/settings/integrations': 'Интеграции',
|
||||
}
|
||||
|
||||
function getBreadcrumbs(
|
||||
@@ -56,6 +56,15 @@ function getBreadcrumbs(
|
||||
]
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/settings')) {
|
||||
return [
|
||||
{ label: 'Настройки', href: '/settings/integrations' },
|
||||
...(pathname === '/settings/integrations'
|
||||
? [{ label: 'Интеграции', href: pathname }]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
const title = routeTitles[pathname]
|
||||
if (title) {
|
||||
return [{ label: title, href: pathname }]
|
||||
@@ -84,9 +93,8 @@ export function SiteHeader() {
|
||||
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{crumbs.map((crumb, index) => {
|
||||
@@ -112,7 +120,7 @@ export function SiteHeader() {
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="text-muted-foreground ml-auto flex items-center gap-2">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { SearchIcon, XIcon } from 'lucide-react'
|
||||
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export interface FilterChip {
|
||||
id: string
|
||||
label: string
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
interface ListFiltersSearchProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder: string
|
||||
className?: string
|
||||
name?: string
|
||||
autoComplete?: string
|
||||
spellCheck?: boolean
|
||||
}
|
||||
|
||||
export function ListFiltersSearch({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
name,
|
||||
autoComplete = 'off',
|
||||
spellCheck = false,
|
||||
}: ListFiltersSearchProps) {
|
||||
return (
|
||||
<div className={cn('relative w-full', className)}>
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="pl-8"
|
||||
autoComplete={autoComplete}
|
||||
name={name}
|
||||
spellCheck={spellCheck}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FilterActiveChips({ chips }: { chips: FilterChip[] }) {
|
||||
if (chips.length === 0) return null
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{chips.map((chip) => (
|
||||
<Badge key={chip.id} variant="secondary" className="gap-1 pr-1 font-normal">
|
||||
<span className="max-w-[12rem] truncate">{chip.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={chip.onRemove}
|
||||
className="rounded-sm p-0.5 hover:bg-muted"
|
||||
aria-label={`Убрать фильтр: ${chip.label}`}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FilterResultsCount({
|
||||
shown,
|
||||
total,
|
||||
suffix,
|
||||
}: {
|
||||
shown: number
|
||||
total: number
|
||||
suffix?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Показано {shown} из {total}
|
||||
</span>
|
||||
{suffix ? <span className="text-muted-foreground/80">{suffix}</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FilterResetButton({ onClick, visible }: { onClick: () => void; visible: boolean }) {
|
||||
if (!visible) return null
|
||||
return (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClick}>
|
||||
<XIcon data-icon="inline-start" />
|
||||
Сбросить
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilterToggleChipProps {
|
||||
label: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function FilterToggleChip({ label, active, onClick }: FilterToggleChipProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={active ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className={cn(active && 'border-primary/40')}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListFiltersBarProps {
|
||||
search?: ListFiltersSearchProps
|
||||
controls?: ReactNode
|
||||
chips?: FilterChip[]
|
||||
shown?: number
|
||||
total?: number
|
||||
resultsSuffix?: ReactNode
|
||||
onReset?: () => void
|
||||
showReset?: boolean
|
||||
toggles?: ReactNode
|
||||
}
|
||||
|
||||
export function ListFiltersBar({
|
||||
search,
|
||||
controls,
|
||||
chips,
|
||||
shown,
|
||||
total,
|
||||
resultsSuffix,
|
||||
onReset,
|
||||
showReset,
|
||||
toggles,
|
||||
}: ListFiltersBarProps) {
|
||||
const hasMeta =
|
||||
chips?.length ||
|
||||
(shown != null && total != null) ||
|
||||
resultsSuffix ||
|
||||
(showReset && onReset)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{search ? <ListFiltersSearch {...search} /> : null}
|
||||
{controls || toggles ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{controls}
|
||||
{toggles}
|
||||
{onReset ? <FilterResetButton onClick={onReset} visible={Boolean(showReset)} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{hasMeta ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{chips?.length ? <FilterActiveChips chips={chips} /> : null}
|
||||
{shown != null && total != null ? (
|
||||
<FilterResultsCount shown={shown} total={total} suffix={resultsSuffix} />
|
||||
) : resultsSuffix ? (
|
||||
<div className="text-xs text-muted-foreground">{resultsSuffix}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export interface DetailMetricCard {
|
||||
id: string
|
||||
icon: ReactNode
|
||||
label: string
|
||||
description: string
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
interface DetailPanelProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DetailPanelRoot({ children, className }: DetailPanelProps) {
|
||||
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||
}
|
||||
|
||||
interface DetailPanelHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
function DetailPanelHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
}: DetailPanelHeaderProps) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 flex-col gap-px">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
{description ? (
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</FrameHeader>
|
||||
{children ? <FramePanel className="space-y-4">{children}</FramePanel> : null}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
|
||||
return (
|
||||
<div className="@container w-full">
|
||||
<div className="grid gap-4 @2xl:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<Frame key={card.id} spacing="sm">
|
||||
<FrameHeader className="px-1! py-1!">
|
||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||
{card.icon}
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{card.description}
|
||||
</p>
|
||||
{card.footer}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailPanelSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title?: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
{title ? (
|
||||
<header className="px-1">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
) : null}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export const DetailPanel = Object.assign(DetailPanelRoot, {
|
||||
Header: DetailPanelHeader,
|
||||
Metrics: DetailPanelMetrics,
|
||||
Section: DetailPanelSection,
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
|
||||
export function getActiveFilters(filters: Filter[]) {
|
||||
return filters.filter((filter) => {
|
||||
const { values } = filter
|
||||
if (!values || values.length === 0) return false
|
||||
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => value === null || value === undefined)) {
|
||||
return false
|
||||
}
|
||||
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function applyFiltersToData<T>(
|
||||
data: T[],
|
||||
filters: Filter[],
|
||||
getFieldValue: (item: T, field: string) => unknown,
|
||||
): T[] {
|
||||
const active = getActiveFilters(filters)
|
||||
let result = [...data]
|
||||
|
||||
for (const filter of active) {
|
||||
const { field, operator, values } = filter
|
||||
result = result.filter((item) => {
|
||||
const raw = getFieldValue(item, field)
|
||||
const fieldValue = raw != null ? raw : ''
|
||||
|
||||
switch (operator) {
|
||||
case 'is':
|
||||
return values.includes(fieldValue)
|
||||
case 'is_not':
|
||||
return !values.includes(fieldValue)
|
||||
case 'is_any_of':
|
||||
return values.some((value) => fieldValue === value)
|
||||
case 'is_not_any_of':
|
||||
return !values.some((value) => fieldValue === value)
|
||||
case 'contains': {
|
||||
const tokens = values
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean)
|
||||
if (tokens.length === 0) return true
|
||||
return tokens.some((token) =>
|
||||
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
|
||||
)
|
||||
}
|
||||
case 'not_contains':
|
||||
return !values.some((value) =>
|
||||
String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
|
||||
)
|
||||
case 'starts_with':
|
||||
return values.some((value) =>
|
||||
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
|
||||
)
|
||||
case 'ends_with':
|
||||
return values.some((value) =>
|
||||
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
|
||||
)
|
||||
case 'empty':
|
||||
return fieldValue === '' || fieldValue == null
|
||||
case 'not_empty':
|
||||
return fieldValue !== '' && fieldValue != null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function renderSelectedCount(values: unknown[]) {
|
||||
if (values.length === 0) return 'Выберите…'
|
||||
if (values.length > 1) return `${values.length} выбрано`
|
||||
return null
|
||||
}
|
||||
|
||||
export function renderSingleSelectedLabel(
|
||||
values: unknown[],
|
||||
options: { value: string; label: string }[],
|
||||
) {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
const option = options.find((item) => item.value === values[0])
|
||||
return option?.label ?? String(values[0])
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||
export { OpsDashboard, OpsDashboardHintLink, type OpsKpiCard } from './ops-dashboard'
|
||||
export { KanbanBoard, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useMemo, type ComponentProps, type ReactNode } from 'react'
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Kanban,
|
||||
KanbanBoard as KanbanBoardPrimitive,
|
||||
KanbanColumn,
|
||||
KanbanColumnContent,
|
||||
KanbanColumnHandle,
|
||||
KanbanItem,
|
||||
KanbanItemHandle,
|
||||
KanbanOverlay,
|
||||
} from '@/components/reui/kanban'
|
||||
import { ScrollArea as ScrollAreaPrimitive } from '@base-ui/react/scroll-area'
|
||||
import { GripVerticalIcon, PlusIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
export interface KanbanColumnConfig {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
dotClassName?: string
|
||||
addLabel?: string
|
||||
onAdd?: () => void
|
||||
}
|
||||
|
||||
export interface KanbanBoardProps<T extends { id: string | number }> {
|
||||
title: string
|
||||
description?: string
|
||||
columns: KanbanColumnConfig[]
|
||||
value: Record<string, T[]>
|
||||
onValueChange: (value: Record<string, T[]>) => void
|
||||
renderCard: (item: T, ctx: { isOverlay?: boolean }) => ReactNode
|
||||
toolbarActions?: ReactNode
|
||||
emptyColumnAction?: (column: KanbanColumnConfig) => ReactNode
|
||||
}
|
||||
|
||||
function BoardScrollArea({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className="relative w-full min-w-0 pb-3"
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 w-full rounded-lg transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
<ScrollAreaPrimitive.Content
|
||||
data-slot="scroll-area-content"
|
||||
className="w-max min-w-full"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Content>
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation="horizontal"
|
||||
orientation="horizontal"
|
||||
className="flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-foreground/15 relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const COLUMN_HEADER_ACTION_BUTTON_CLASSNAME =
|
||||
'text-muted-foreground hover:border-border! hover:bg-background! hover:text-foreground border border-transparent bg-transparent'
|
||||
|
||||
interface KanbanColumnViewProps<T extends { id: string | number }>
|
||||
extends Omit<ComponentProps<typeof KanbanColumn>, 'value' | 'children'> {
|
||||
column: KanbanColumnConfig
|
||||
items: T[]
|
||||
renderCard: (item: T, ctx: { isOverlay?: boolean }) => ReactNode
|
||||
isOverlay?: boolean
|
||||
emptyColumnAction?: (column: KanbanColumnConfig) => ReactNode
|
||||
}
|
||||
|
||||
function KanbanColumnView<T extends { id: string | number }>({
|
||||
column,
|
||||
items,
|
||||
renderCard,
|
||||
isOverlay,
|
||||
emptyColumnAction,
|
||||
...props
|
||||
}: KanbanColumnViewProps<T>) {
|
||||
return (
|
||||
<KanbanColumn
|
||||
value={column.id}
|
||||
className="w-[calc(100vw-3rem)] max-w-[19rem] shrink-0 sm:w-[19rem]"
|
||||
{...props}
|
||||
>
|
||||
<Frame
|
||||
spacing="sm"
|
||||
className={cn('group/column', isOverlay && 'shadow-lg')}
|
||||
aria-label={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 }) => (
|
||||
<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}
|
||||
</FrameHeader>
|
||||
|
||||
<KanbanColumnContent value={column.id} className="gap-2 p-0.5">
|
||||
{items.map((item) => (
|
||||
<KanbanItem key={String(item.id)} value={String(item.id)}>
|
||||
<KanbanItemHandle className="block">
|
||||
{renderCard(item, { isOverlay: false })}
|
||||
</KanbanItemHandle>
|
||||
</KanbanItem>
|
||||
))}
|
||||
{items.length === 0
|
||||
? (emptyColumnAction?.(column) ?? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-muted-foreground hover:text-foreground bg-background/70 h-20 w-full border-dashed text-sm"
|
||||
aria-label={column.addLabel ?? 'Пусто'}
|
||||
disabled={!column.onAdd}
|
||||
onClick={column.onAdd}
|
||||
>
|
||||
{column.addLabel ?? 'Пусто'}
|
||||
</Button>
|
||||
))
|
||||
: null}
|
||||
</KanbanColumnContent>
|
||||
</Frame>
|
||||
</KanbanColumn>
|
||||
)
|
||||
}
|
||||
|
||||
function findItem<T extends { id: string | number }>(
|
||||
columns: Record<string, T[]>,
|
||||
itemId: string,
|
||||
): T | null {
|
||||
for (const items of Object.values(columns)) {
|
||||
const found = items.find((item) => String(item.id) === itemId)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function KanbanBoard<T extends { id: string | number }>({
|
||||
title,
|
||||
description,
|
||||
columns: columnConfigs,
|
||||
value,
|
||||
onValueChange,
|
||||
renderCard,
|
||||
toolbarActions,
|
||||
emptyColumnAction,
|
||||
}: KanbanBoardProps<T>) {
|
||||
const columnById = useMemo(
|
||||
() => new Map(columnConfigs.map((c) => [c.id, c])),
|
||||
[columnConfigs],
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="mx-auto flex w-full flex-col gap-4">
|
||||
<header className="px-1 py-1" aria-label={title}>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-lg leading-7 font-semibold">{title}</h2>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 max-w-[52ch] text-sm leading-5">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{toolbarActions ? (
|
||||
<div
|
||||
className="flex w-full flex-wrap items-center gap-2 lg:w-auto lg:justify-end"
|
||||
role="group"
|
||||
>
|
||||
{toolbarActions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Kanban
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
getItemValue={(item) => String(item.id)}
|
||||
className="w-full"
|
||||
>
|
||||
<BoardScrollArea>
|
||||
<KanbanBoardPrimitive className="grid min-w-max auto-cols-[19rem] grid-flow-col grid-cols-none items-start gap-3 p-1">
|
||||
{Object.entries(value).map(([columnId, items]) => {
|
||||
const column = columnById.get(columnId)
|
||||
if (!column) return null
|
||||
return (
|
||||
<KanbanColumnView
|
||||
key={columnId}
|
||||
column={column}
|
||||
items={items}
|
||||
renderCard={renderCard}
|
||||
emptyColumnAction={emptyColumnAction}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</KanbanBoardPrimitive>
|
||||
</BoardScrollArea>
|
||||
|
||||
<KanbanOverlay>
|
||||
{({ value: overlayValue, variant }) => {
|
||||
if (variant === 'column') {
|
||||
const column = columnById.get(String(overlayValue))
|
||||
if (!column) return null
|
||||
return (
|
||||
<KanbanColumnView
|
||||
column={column}
|
||||
items={value[column.id] ?? []}
|
||||
renderCard={renderCard}
|
||||
isOverlay
|
||||
/>
|
||||
)
|
||||
}
|
||||
const item = findItem(value, String(overlayValue))
|
||||
return item ? renderCard(item, { isOverlay: true }) : null
|
||||
}}
|
||||
</KanbanOverlay>
|
||||
</Kanban>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
|
||||
export interface OpsKpiCard {
|
||||
id: string
|
||||
typeLabel: string
|
||||
title: string
|
||||
metricLabel: string
|
||||
value: string | number
|
||||
hint?: ReactNode
|
||||
icon: ReactNode
|
||||
iconBg?: string
|
||||
}
|
||||
|
||||
interface OpsDashboardProps {
|
||||
title?: string
|
||||
description?: string
|
||||
kpiCards: OpsKpiCard[]
|
||||
charts: ReactNode
|
||||
queue: ReactNode
|
||||
}
|
||||
|
||||
function KpiCardItem({ card }: { card: OpsKpiCard }) {
|
||||
return (
|
||||
<FramePanel>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Item
|
||||
className={cn(
|
||||
'p-0',
|
||||
'border-background flex size-10 items-center justify-center border-2 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white',
|
||||
card.iconBg ?? 'bg-chart-1',
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.typeLabel}
|
||||
</p>
|
||||
<h3 className="truncate text-sm leading-tight font-medium">{card.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-1.5">
|
||||
<p className="text-muted-foreground text-sm leading-tight">
|
||||
{card.metricLabel}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xl font-medium tracking-tight tabular-nums">
|
||||
{card.value}
|
||||
</span>
|
||||
{card.hint}
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpsDashboard({
|
||||
title = 'Панель управления',
|
||||
description = 'Обзор доменов, групп, сервисов и сертификатов Cloudflare',
|
||||
kpiCards,
|
||||
charts,
|
||||
queue,
|
||||
}: OpsDashboardProps) {
|
||||
return (
|
||||
<div className="text-foreground @container mx-auto flex w-full flex-col gap-4 md:gap-6">
|
||||
<header className="px-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section aria-label="Ключевые метрики">
|
||||
<Frame className="@container w-full">
|
||||
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
|
||||
{kpiCards.map((card) => (
|
||||
<KpiCardItem key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Аналитика"
|
||||
className="grid min-w-0 items-stretch gap-4 @5xl:grid-cols-2"
|
||||
>
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label="Требуют внимания">
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Требуют внимания</FrameTitle>
|
||||
<FrameDescription>
|
||||
Истекающие сертификаты и домены без группы
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>{queue}</FramePanel>
|
||||
</Frame>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpsDashboardHintLink({
|
||||
to,
|
||||
search,
|
||||
children,
|
||||
}: {
|
||||
to: string
|
||||
search?: Record<string, unknown>
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
search={search}
|
||||
className="text-primary text-sm font-medium hover:underline"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user