Examples
Whole screens for municipal services, built only from rapidUI components. Copy the source into your app.
Case management
A case officer’s overview: navigation, workload at a glance, a weekly chart and the case list.
View source components/examples/case-management.tsx
'use client'
import {
AlertTriangleIcon,
BellIcon,
Building2Icon,
ChevronsUpDownIcon,
CircleCheckIcon,
ClockIcon,
FileTextIcon,
FolderOpenIcon,
GavelIcon,
HomeIcon,
HourglassIcon,
InboxIcon,
LandmarkIcon,
LeafIcon,
ListFilterIcon,
LogOutIcon,
MoreHorizontalIcon,
SearchIcon,
SearchXIcon,
SettingsIcon,
UserIcon,
UserPlusIcon,
UsersIcon,
XIcon,
} from 'lucide-react'
import * as React from 'react'
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import {
type Organization,
OrganizationSwitcher,
OrganizationSwitcherContent,
OrganizationSwitcherItem,
OrganizationSwitcherLabel,
OrganizationSwitcherList,
OrganizationSwitcherTrigger,
OrganizationSwitcherValue,
} from '@/components/ui/organization-switcher'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
} from '@/components/ui/sidebar'
import {
StatCard,
StatCardFooter,
StatCardIcon,
StatCardLabel,
StatCardTrend,
StatCardUnit,
StatCardValue,
} from '@/components/ui/stat-card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
/* -------------------------------------------------------------------------------------------------
* Data
* -----------------------------------------------------------------------------------------------*/
/** The example is frozen on this date so due-date warnings always look the same. */
const TODAY = new Date(2026, 8, 29)
type OfficerId = 'sofia' | 'mikko' | 'omar' | 'ingrid' | 'anna'
/** The signed-in case officer */
const ME: OfficerId = 'sofia'
const OFFICERS: Record<OfficerId, { name: string; initials: string; unit: string; team: boolean }> = {
sofia: { name: 'Sofia Berg', initials: 'SB', unit: 'Building permits', team: true },
mikko: { name: 'Mikko Virtanen', initials: 'MV', unit: 'Building permits', team: true },
omar: { name: 'Omar Haddad', initials: 'OH', unit: 'Building permits', team: true },
ingrid: { name: 'Ingrid Hansen', initials: 'IH', unit: 'Environment and health', team: false },
anna: { name: 'Anna Lindqvist', initials: 'AL', unit: 'Environment and health', team: false },
}
const OFFICER_IDS = Object.keys(OFFICERS) as OfficerId[]
type Status = 'new' | 'in-progress' | 'waiting' | 'decided'
const STATUS: Record<
Status,
{
label: string
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
variant: 'default' | 'secondary' | 'outline'
className?: string
}
> = {
new: { label: 'Received', icon: InboxIcon, variant: 'default', className: 'bg-primary/10 text-primary' },
'in-progress': { label: 'In progress', icon: ClockIcon, variant: 'secondary' },
waiting: { label: 'Waiting for resident', icon: HourglassIcon, variant: 'outline' },
decided: { label: 'Decided', icon: CircleCheckIcon, variant: 'default' },
}
const STATUS_IDS = Object.keys(STATUS) as Status[]
const statusFilterItems: Record<string, string> = {
all: 'All statuses',
...Object.fromEntries(STATUS_IDS.map((id) => [id, STATUS[id].label])),
}
type Case = {
id: string
title: string
type: string
applicant: string
address: string
status: Status
assignee: OfficerId
received: string
due: string
}
const INITIAL_CASES: Case[] = [
{
id: '2026-04812',
title: 'Carport',
type: 'Building permit',
applicant: 'Erik Johansson',
address: 'Storgatan 14',
status: 'in-progress',
assignee: 'sofia',
received: '2026-08-31',
due: '2026-09-30',
},
{
id: '2026-04877',
title: 'Extension of single-family house',
type: 'Building permit',
applicant: 'Karin Holm',
address: 'Björkvägen 7',
status: 'waiting',
assignee: 'sofia',
received: '2026-09-08',
due: '2026-10-14',
},
{
id: '2026-04901',
title: 'Change of use, office to housing',
type: 'Building permit',
applicant: 'Fastighets AB Ekdalen',
address: 'Kungsgatan 3',
status: 'new',
assignee: 'sofia',
received: '2026-09-24',
due: '2026-10-27',
},
{
id: '2026-04655',
title: 'Demolition of barn',
type: 'Demolition notice',
applicant: 'Lars Nilsson',
address: 'Lillgården 2',
status: 'in-progress',
assignee: 'sofia',
received: '2026-08-12',
due: '2026-09-26',
},
{
id: '2026-04930',
title: 'Illuminated sign on facade',
type: 'Sign permit',
applicant: 'Café Kanelen',
address: 'Torget 1',
status: 'new',
assignee: 'sofia',
received: '2026-09-25',
due: '2026-10-02',
},
{
id: '2026-04720',
title: 'Solar panels on listed building',
type: 'Building permit',
applicant: 'Maria Ek',
address: 'Kyrkogatan 9',
status: 'decided',
assignee: 'sofia',
received: '2026-08-20',
due: '2026-09-22',
},
{
id: '2026-04788',
title: 'Attefall house',
type: 'Notification',
applicant: 'Johan Lund',
address: 'Ängsvägen 21',
status: 'in-progress',
assignee: 'mikko',
received: '2026-09-02',
due: '2026-10-01',
},
{
id: '2026-04811',
title: 'Garage conversion',
type: 'Building permit',
applicant: 'Nora Svensson',
address: 'Tallstigen 5',
status: 'waiting',
assignee: 'mikko',
received: '2026-09-04',
due: '2026-10-09',
},
{
id: '2026-04699',
title: 'Balcony glazing',
type: 'Building permit',
applicant: 'BRF Kungsgatan 3',
address: 'Kungsgatan 3',
status: 'in-progress',
assignee: 'omar',
received: '2026-08-17',
due: '2026-09-28',
},
{
id: '2026-04945',
title: 'New detached house',
type: 'Building permit',
applicant: 'Peter Dahl',
address: 'Ekdalen 4:12',
status: 'new',
assignee: 'omar',
received: '2026-09-28',
due: '2026-11-10',
},
{
id: '2026-04602',
title: 'Private sewage system',
type: 'Environmental permit',
applicant: 'Anders Berglund',
address: 'Sjövik 1:7',
status: 'waiting',
assignee: 'ingrid',
received: '2026-08-05',
due: '2026-10-05',
},
{
id: '2026-04760',
title: 'Food business registration',
type: 'Food registration',
applicant: 'Pizzeria Roma',
address: 'Storgatan 22',
status: 'in-progress',
assignee: 'ingrid',
received: '2026-09-01',
due: '2026-10-03',
},
{
id: '2026-04588',
title: 'Noise complaint, fans',
type: 'Complaint',
applicant: 'Lena Åberg',
address: 'Industrivägen 8',
status: 'decided',
assignee: 'anna',
received: '2026-07-30',
due: '2026-09-18',
},
{
id: '2026-04915',
title: 'Ground-source heat pump',
type: 'Notification',
applicant: 'Olof Strand',
address: 'Fjällgatan 11',
status: 'new',
assignee: 'anna',
received: '2026-09-26',
due: '2026-10-20',
},
]
const weeklyData = [
{ week: 'Week 30', received: 18, decided: 15 },
{ week: 'Week 31', received: 22, decided: 17 },
{ week: 'Week 32', received: 27, decided: 19 },
{ week: 'Week 33', received: 31, decided: 24 },
{ week: 'Week 34', received: 29, decided: 28 },
{ week: 'Week 35', received: 34, decided: 26 },
{ week: 'Week 36', received: 30, decided: 31 },
{ week: 'Week 37', received: 28, decided: 33 },
{ week: 'Week 38', received: 32, decided: 29 },
{ week: 'Week 39', received: 26, decided: 30 },
]
const chartConfig = {
received: { label: 'Received', color: 'var(--chart-1)' },
decided: { label: 'Decided', color: 'var(--chart-2)' },
} satisfies ChartConfig
// A flat list: the departments are sub-organisations of the municipality
const ORGANIZATIONS: Organization[] = [
{ id: 'ekdalen', name: 'Ekdalen Municipality', description: 'All departments', logo: <LandmarkIcon /> },
{ id: 'building-environment', name: 'Building and Environment', parentId: 'ekdalen' },
{ id: 'social-services', name: 'Social Services', parentId: 'ekdalen' },
{ id: 'education', name: 'Education', parentId: 'ekdalen' },
]
type TabValue = 'mine' | 'team' | 'all'
type NavId = 'overview' | 'mine' | 'team' | 'inbox' | 'decisions' | 'overdue' | 'waiting' | 'received'
/* -------------------------------------------------------------------------------------------------
* Helpers
* -----------------------------------------------------------------------------------------------*/
function parseDate(value: string) {
const [year, month, day] = value.split('-').map(Number)
return new Date(year, month - 1, day)
}
function daysUntil(value: string) {
return Math.round((parseDate(value).getTime() - TODAY.getTime()) / 86_400_000)
}
const dateFormat = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short' })
function formatDate(value: string) {
return dateFormat.format(parseDate(value))
}
function isOpen(item: Case) {
return item.status !== 'decided'
}
function isOverdue(item: Case) {
return isOpen(item) && daysUntil(item.due) < 0
}
function inTab(item: Case, tab: TabValue) {
if (tab === 'mine') return item.assignee === ME
if (tab === 'team') return OFFICERS[item.assignee].team
return true
}
/* -------------------------------------------------------------------------------------------------
* Small pieces
* -----------------------------------------------------------------------------------------------*/
function StatusBadge({ status }: { status: Status }) {
const config = STATUS[status]
const Icon = config.icon
return (
<Badge variant={config.variant} className={config.className}>
<Icon data-icon="inline-start" aria-hidden="true" />
{config.label}
</Badge>
)
}
function DueDate({ item }: { item: Case }) {
const days = daysUntil(item.due)
const date = formatDate(item.due)
if (!isOpen(item)) {
return <span className="text-muted-foreground">{date}</span>
}
if (days < 0) {
return (
<span className="inline-flex items-center gap-1.5 font-medium text-destructive">
<AlertTriangleIcon className="size-4" aria-hidden="true" />
{date}
<span className="font-normal">· {Math.abs(days) === 1 ? '1 day overdue' : `${Math.abs(days)} days overdue`}</span>
</span>
)
}
if (days <= 3) {
return (
<span className="inline-flex items-center gap-1.5">
<ClockIcon className="size-4 text-muted-foreground" aria-hidden="true" />
{date}
<span className="text-muted-foreground">
· {days === 0 ? 'due today' : days === 1 ? 'tomorrow' : `in ${days} days`}
</span>
</span>
)
}
return <span>{date}</span>
}
function OfficerAvatar({ id, size = 'sm' }: { id: OfficerId; size?: 'sm' | 'default' }) {
return (
<Avatar size={size}>
<AvatarFallback>{OFFICERS[id].initials}</AvatarFallback>
</Avatar>
)
}
function Stat({
label,
value,
unit,
trend,
goodDirection,
trendLabel,
icon: Icon,
}: {
label: string
value: React.ReactNode
unit?: string
trend: 'up' | 'down'
goodDirection: 'up' | 'down' | 'none'
trendLabel: string
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
}) {
return (
<StatCard>
<StatCardLabel>{label}</StatCardLabel>
<StatCardIcon>
<Icon />
</StatCardIcon>
<StatCardValue>
{value}
{unit ? <StatCardUnit>{unit}</StatCardUnit> : null}
</StatCardValue>
<StatCardFooter>
<StatCardTrend direction={trend} goodDirection={goodDirection}>
{trendLabel}
</StatCardTrend>
</StatCardFooter>
</StatCard>
)
}
/* -------------------------------------------------------------------------------------------------
* Sidebar
* -----------------------------------------------------------------------------------------------*/
function CaseSidebar({
organizationId,
onOrganizationChange,
activeNav,
onNavigate,
counts,
}: {
organizationId: string
onOrganizationChange: (value: string) => void
activeNav: NavId
onNavigate: (id: NavId) => void
counts: Record<'mine' | 'team' | 'overdue' | 'waiting' | 'received', number>
}) {
const workspace: { id: NavId; title: string; icon: React.ComponentType; badge?: number }[] = [
{ id: 'overview', title: 'Overview', icon: HomeIcon },
{ id: 'mine', title: 'My cases', icon: FolderOpenIcon, badge: counts.mine },
{ id: 'team', title: 'Team cases', icon: UsersIcon, badge: counts.team },
{ id: 'inbox', title: 'Inbox', icon: InboxIcon, badge: 4 },
{ id: 'decisions', title: 'Decisions', icon: GavelIcon },
]
const views: { id: NavId; title: string; icon: React.ComponentType; badge: number }[] = [
{ id: 'overdue', title: 'Overdue', icon: AlertTriangleIcon, badge: counts.overdue },
{ id: 'waiting', title: 'Waiting for resident', icon: HourglassIcon, badge: counts.waiting },
{ id: 'received', title: 'Received, not started', icon: InboxIcon, badge: counts.received },
]
return (
// On a real page, drop className: the sidebar is fixed to the window. Here it stays inside the example frame.
<Sidebar collapsible="icon" className="absolute h-full">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<OrganizationSwitcher items={ORGANIZATIONS} value={organizationId} onValueChange={onOrganizationChange}>
<OrganizationSwitcherTrigger aria-label="Organisation" render={<SidebarMenuButton size="lg" />}>
<OrganizationSwitcherValue />
</OrganizationSwitcherTrigger>
<OrganizationSwitcherContent>
<OrganizationSwitcherLabel>Switch department</OrganizationSwitcherLabel>
<OrganizationSwitcherList>
{(organization) => (
<OrganizationSwitcherItem
key={organization.id}
value={organization.id}
level={organization.depth}
path={organization.path}
description={organization.description}
>
{organization.name}
</OrganizationSwitcherItem>
)}
</OrganizationSwitcherList>
</OrganizationSwitcherContent>
</OrganizationSwitcher>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<nav aria-label="Case management">
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{workspace.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
tooltip={item.title}
isActive={activeNav === item.id}
aria-current={activeNav === item.id ? 'page' : undefined}
onClick={() => onNavigate(item.id)}
>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
{item.badge ? <SidebarMenuBadge>{item.badge}</SidebarMenuBadge> : null}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Saved views</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{views.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
tooltip={item.title}
isActive={activeNav === item.id}
aria-current={activeNav === item.id ? 'page' : undefined}
onClick={() => onNavigate(item.id)}
>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
<SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Registers</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{[
{ title: 'Building permits', icon: Building2Icon },
{ title: 'Environment and health', icon: LeafIcon },
{ title: 'Documents', icon: FileTextIcon },
].map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton tooltip={item.title}>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</nav>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger render={<SidebarMenuButton size="lg" aria-label="Sofia Berg, account menu" />}>
<Avatar className="rounded-lg after:rounded-lg">
<AvatarFallback className="rounded-lg">SB</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left leading-tight">
<span className="truncate font-medium">Sofia Berg</span>
<span className="truncate text-xs text-muted-foreground">Case officer, Building permits</span>
</div>
<ChevronsUpDownIcon className="ml-auto" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="w-60">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground">sofia.berg@ekdalen.se</DropdownMenuLabel>
<DropdownMenuItem>
<UserIcon aria-hidden="true" />
Profile
</DropdownMenuItem>
<DropdownMenuItem>
<BellIcon aria-hidden="true" />
Notifications
</DropdownMenuItem>
<DropdownMenuItem>
<SettingsIcon aria-hidden="true" />
Settings
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOutIcon aria-hidden="true" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}
/* -------------------------------------------------------------------------------------------------
* Case table
* -----------------------------------------------------------------------------------------------*/
function CaseTable({
rows,
caption,
onOpen,
onAssign,
onStatusChange,
onClearFilters,
}: {
rows: Case[]
caption: string
onOpen: (id: string) => void
onAssign: (id: string, officer: OfficerId) => void
onStatusChange: (id: string, status: Status) => void
onClearFilters: () => void
}) {
if (rows.length === 0) {
return (
<Empty className="rounded-lg border border-dashed py-10">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>No cases match</EmptyTitle>
<EmptyDescription>Try another status or clear the filters.</EmptyDescription>
</EmptyHeader>
<Button variant="outline" onClick={onClearFilters}>
Clear filters
</Button>
</Empty>
)
}
return (
<div className="overflow-hidden rounded-lg border">
<Table>
<caption className="sr-only">{caption}</caption>
<TableHeader>
<TableRow>
<TableHead className="pl-4">Case</TableHead>
<TableHead className="hidden @4xl/main:table-cell">Applicant</TableHead>
<TableHead>Status</TableHead>
<TableHead>Assignee</TableHead>
<TableHead>Due</TableHead>
<TableHead className="w-12 pr-4">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((item) => (
<TableRow key={item.id}>
<TableCell className="pl-4">
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => onOpen(item.id)}
className="w-fit rounded-sm text-left font-medium tabular-nums underline-offset-4 outline-none hover:text-primary hover:underline focus-visible:ring-3 focus-visible:ring-ring"
>
{item.id}
</button>
<span className="text-muted-foreground">
{item.title}, {item.address}
</span>
</div>
</TableCell>
<TableCell className="hidden @4xl/main:table-cell">
<div className="flex flex-col gap-0.5">
<span>{item.applicant}</span>
<span className="text-muted-foreground">{item.type}</span>
</div>
</TableCell>
<TableCell>
<StatusBadge status={item.status} />
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<OfficerAvatar id={item.assignee} />
<span>
{OFFICERS[item.assignee].name}
{item.assignee === ME ? <span className="text-muted-foreground"> (you)</span> : null}
</span>
</div>
</TableCell>
<TableCell>
<DueDate item={item} />
</TableCell>
<TableCell className="pr-4 text-right">
<RowActions item={item} onOpen={onOpen} onAssign={onAssign} onStatusChange={onStatusChange} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
function RowActions({
item,
onOpen,
onAssign,
onStatusChange,
}: {
item: Case
onOpen: (id: string) => void
onAssign: (id: string, officer: OfficerId) => void
onStatusChange: (id: string, status: Status) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" aria-label={`Actions for case ${item.id}`} />}>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground">Case {item.id}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onOpen(item.id)}>
<FolderOpenIcon aria-hidden="true" />
Open
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<UserPlusIcon aria-hidden="true" />
Assign
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<DropdownMenuRadioGroup value={item.assignee} onValueChange={(value) => onAssign(item.id, value as OfficerId)}>
<DropdownMenuLabel className="text-muted-foreground">Case officer</DropdownMenuLabel>
{OFFICER_IDS.map((id) => (
<DropdownMenuRadioItem key={id} value={id}>
{OFFICERS[id].name}
{id === ME ? ' (you)' : ''}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ListFilterIcon aria-hidden="true" />
Change status
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<DropdownMenuRadioGroup value={item.status} onValueChange={(value) => onStatusChange(item.id, value as Status)}>
<DropdownMenuLabel className="text-muted-foreground">Status</DropdownMenuLabel>
{STATUS_IDS.map((id) => (
<DropdownMenuRadioItem key={id} value={id}>
{STATUS[id].label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
/* -------------------------------------------------------------------------------------------------
* Screen
* -----------------------------------------------------------------------------------------------*/
const TAB_LABELS: Record<TabValue, string> = { mine: 'Mine', team: 'Team', all: 'All' }
export default function CaseManagementExample() {
const id = React.useId()
const [cases, setCases] = React.useState(INITIAL_CASES)
const [organizationId, setOrganizationId] = React.useState('building-environment')
const [activeNav, setActiveNav] = React.useState<NavId>('mine')
const [tab, setTab] = React.useState<TabValue>('mine')
const [status, setStatus] = React.useState('all')
const [overdueOnly, setOverdueOnly] = React.useState(false)
const [assigneeFilter, setAssigneeFilter] = React.useState<OfficerId | null>(null)
const [searchOpen, setSearchOpen] = React.useState(false)
// The id is kept after closing so the sheet keeps its content while it animates out
const [openCaseId, setOpenCaseId] = React.useState<string | null>(null)
const [sheetOpen, setSheetOpen] = React.useState(false)
const [announcement, setAnnouncement] = React.useState('')
// ⌘K / Ctrl K opens the search. The docs site has its own ⌘K, so this one only reacts while focus is in the
// example (or in its open search). In an app, drop the check and listen on `document` without capture.
const rootRef = React.useRef<HTMLDivElement>(null)
const searchOpenRef = React.useRef(searchOpen)
React.useEffect(() => {
searchOpenRef.current = searchOpen
}, [searchOpen])
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey)) return
const inside = event.target instanceof Node && rootRef.current?.contains(event.target)
if (!inside && !searchOpenRef.current) return
event.preventDefault()
event.stopPropagation()
setSearchOpen((current) => !current)
}
document.addEventListener('keydown', onKeyDown, true)
return () => document.removeEventListener('keydown', onKeyDown, true)
}, [])
const matchesFilters = (item: Case) =>
(status === 'all' || item.status === status) &&
(!overdueOnly || isOverdue(item)) &&
(!assigneeFilter || item.assignee === assigneeFilter)
const rowsFor = (value: TabValue) =>
cases
.filter((item) => inTab(item, value) && matchesFilters(item))
.sort((a, b) => Number(!isOpen(a)) - Number(!isOpen(b)) || a.due.localeCompare(b.due))
const mine = cases.filter((item) => item.assignee === ME)
const mineOpen = mine.filter(isOpen)
const teamOpen = cases.filter((item) => OFFICERS[item.assignee].team && isOpen(item))
const dueThisWeek = mineOpen.filter((item) => daysUntil(item.due) <= 5)
const overdueMine = mineOpen.filter(isOverdue)
const counts = {
mine: mineOpen.length,
team: teamOpen.length,
overdue: cases.filter(isOverdue).length,
waiting: teamOpen.filter((item) => item.status === 'waiting').length,
received: teamOpen.filter((item) => item.status === 'new').length,
}
const openCase = cases.find((item) => item.id === openCaseId) ?? null
function showCase(caseId: string) {
setOpenCaseId(caseId)
setSheetOpen(true)
}
function clearFilters() {
setStatus('all')
setOverdueOnly(false)
setAssigneeFilter(null)
}
function navigate(target: NavId) {
setActiveNav(target)
clearFilters()
if (target === 'mine' || target === 'overview') setTab('mine')
if (target === 'team') setTab('team')
if (target === 'overdue') {
setTab('all')
setOverdueOnly(true)
}
if (target === 'waiting') {
setTab('team')
setStatus('waiting')
}
if (target === 'received') {
setTab('team')
setStatus('new')
}
}
function changeTab(value: TabValue) {
setTab(value)
setActiveNav(value === 'all' ? 'overview' : value)
}
function assign(caseId: string, officer: OfficerId) {
setCases((current) => current.map((item) => (item.id === caseId ? { ...item, assignee: officer } : item)))
setAnnouncement(`Case ${caseId} assigned to ${OFFICERS[officer].name}.`)
}
function changeStatus(caseId: string, next: Status) {
setCases((current) => current.map((item) => (item.id === caseId ? { ...item, status: next } : item)))
setAnnouncement(`Case ${caseId} is now ${STATUS[next].label.toLowerCase()}.`)
}
const activeFilterCount = (status !== 'all' ? 1 : 0) + (overdueOnly ? 1 : 0) + (assigneeFilter ? 1 : 0)
return (
<SidebarProvider className="relative min-h-[760px] overflow-hidden bg-background" ref={rootRef}>
<CaseSidebar
organizationId={organizationId}
onOrganizationChange={setOrganizationId}
activeNav={activeNav}
onNavigate={navigate}
counts={counts}
/>
{/* In an app, use SidebarInset here: it renders <main>, and the docs page already has one. */}
<div className="@container/main relative flex w-full min-w-0 flex-1 flex-col bg-background">
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" aria-label="Toggle navigation" />
<Separator orientation="vertical" className="mr-1 h-4" />
<Breadcrumb className="hidden min-w-0 @xl/main:block">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" onClick={(event) => event.preventDefault()}>
{ORGANIZATIONS.find((organization) => organization.id === organizationId)?.name}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Case overview</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
className="justify-start text-muted-foreground @2xl/main:w-72"
onClick={() => setSearchOpen(true)}
>
<SearchIcon data-icon="inline-start" aria-hidden="true" />
<span>Search cases and people</span>
<KbdGroup className="ml-auto hidden @2xl/main:inline-flex" aria-hidden="true">
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
</Button>
<Button variant="ghost" size="icon" aria-label="Notifications, 3 unread" className="relative">
<BellIcon aria-hidden="true" />
<span className="absolute top-2 right-2 size-2 rounded-full bg-primary ring-2 ring-background" />
</Button>
</div>
</header>
<div className="flex flex-1 flex-col gap-6 p-4 @3xl/main:p-6">
<div className="flex flex-col gap-1">
<h3 className="font-heading text-xl font-semibold">Good morning, Sofia</h3>
<p className="text-muted-foreground">
Tuesday 29 September 2026 · {mineOpen.length} open cases, {overdueMine.length} overdue
</p>
</div>
<section aria-label="Workload" className="grid grid-cols-2 gap-4 @4xl/main:grid-cols-4">
<Stat
label="Open cases"
value={mineOpen.length}
icon={FolderOpenIcon}
trend="up"
goodDirection="down"
trendLabel="2 more than last week"
/>
<Stat
label="Due this week"
value={dueThisWeek.length}
icon={ClockIcon}
trend="up"
goodDirection="down"
trendLabel={`${overdueMine.length} already overdue`}
/>
<Stat
label="Waiting for resident"
value={mine.filter((item) => item.status === 'waiting').length}
icon={HourglassIcon}
trend="down"
goodDirection="down"
trendLabel="1 fewer than last week"
/>
<Stat
label="Average handling time"
value={34}
unit="days"
icon={GavelIcon}
trend="down"
goodDirection="down"
trendLabel="3 days faster than August"
/>
</section>
<Card>
<CardHeader>
<CardTitle as="h4">Received and decided cases</CardTitle>
<CardDescription>Building and Environment, per week, weeks 30–39 2026</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer
config={chartConfig}
className="aspect-auto h-56 w-full"
role="img"
aria-label="Bar chart of received and decided cases per week. Decisions have outpaced new cases in three of the last four weeks; in week 39, 26 cases were received and 30 decided."
>
<BarChart accessibilityLayer data={weeklyData} margin={{ left: -16 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="week"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) => value.replace('Week ', 'W')}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={8} width={40} />
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator="dashed" />} />
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
<Bar dataKey="received" fill="var(--color-received)" radius={4} />
<Bar dataKey="decided" fill="var(--color-decided)" radius={4} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
<section aria-labelledby={`${id}-cases`} className="flex flex-col gap-4">
<h4 id={`${id}-cases`} className="font-heading text-base font-medium">
Cases
</h4>
<Tabs value={tab} onValueChange={(value) => changeTab(value as TabValue)} className="gap-4">
<div className="flex flex-col gap-4 @3xl/main:flex-row @3xl/main:items-end @3xl/main:justify-between">
<TabsList aria-label="Whose cases">
{(Object.keys(TAB_LABELS) as TabValue[]).map((value) => (
<TabsTrigger key={value} value={value}>
{TAB_LABELS[value]}
<Badge className="tabular-nums">{rowsFor(value).length}</Badge>
</TabsTrigger>
))}
</TabsList>
<Select
value={status}
onValueChange={(value) => {
setStatus(String(value ?? 'all'))
}}
items={statusFilterItems}
>
<SelectTrigger aria-label="Filter by status" className="w-full @3xl/main:w-56">
<ListFilterIcon aria-hidden="true" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All statuses</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
{STATUS_IDS.map((value) => (
<SelectItem key={value} value={value}>
{STATUS[value].label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{overdueOnly || assigneeFilter ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">Also filtered by</span>
{overdueOnly ? (
<Button variant="outline" size="sm" onClick={() => setOverdueOnly(false)} aria-label="Remove filter: overdue">
Overdue
<XIcon data-icon="inline-end" aria-hidden="true" />
</Button>
) : null}
{assigneeFilter ? (
<Button
variant="outline"
size="sm"
onClick={() => setAssigneeFilter(null)}
aria-label={`Remove filter: assignee ${OFFICERS[assigneeFilter].name}`}
>
Assignee: {OFFICERS[assigneeFilter].name}
<XIcon data-icon="inline-end" aria-hidden="true" />
</Button>
) : null}
{activeFilterCount > 1 ? (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Clear all
</Button>
) : null}
</div>
) : null}
{(Object.keys(TAB_LABELS) as TabValue[]).map((value) => (
<TabsContent key={value} value={value}>
<CaseTable
rows={rowsFor(value)}
caption={`${TAB_LABELS[value]} cases, open cases first, by due date`}
onOpen={showCase}
onAssign={assign}
onStatusChange={changeStatus}
onClearFilters={clearFilters}
/>
</TabsContent>
))}
</Tabs>
</section>
<p className="sr-only" aria-live="polite">
{announcement}
</p>
</div>
</div>
<CommandDialog
className="sm:max-w-lg"
open={searchOpen}
onOpenChange={setSearchOpen}
title="Search cases and people"
description="Find a case by number, address or applicant, or a colleague"
>
<Command>
<CommandInput placeholder="Case number, address or name…" aria-label="Search cases and people" />
<CommandList>
<CommandEmpty>No cases or people found.</CommandEmpty>
<CommandGroup heading="Cases">
{cases.map((item) => (
<CommandItem
key={item.id}
value={`${item.id} ${item.title} ${item.address} ${item.applicant}`}
onSelect={() => {
setSearchOpen(false)
showCase(item.id)
}}
>
<FileTextIcon aria-hidden="true" />
<span className="shrink-0 tabular-nums">{item.id}</span>
<span className="truncate text-muted-foreground">
{item.title}, {item.address}
</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="People">
{OFFICER_IDS.map((officer) => (
<CommandItem
key={officer}
value={`${OFFICERS[officer].name} ${OFFICERS[officer].unit}`}
onSelect={() => {
setSearchOpen(false)
clearFilters()
setAssigneeFilter(officer)
setTab('all')
setActiveNav('overview')
}}
>
<OfficerAvatar id={officer} />
<span>{OFFICERS[officer].name}</span>
<span className="truncate text-muted-foreground">{OFFICERS[officer].unit}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent>
{openCase ? (
<>
<SheetHeader className="pr-12">
<SheetTitle className="tabular-nums">Case {openCase.id}</SheetTitle>
<SheetDescription>
{openCase.title}, {openCase.address}
</SheetDescription>
</SheetHeader>
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-3 px-4">
<dt className="text-muted-foreground">Status</dt>
<dd>
<StatusBadge status={openCase.status} />
</dd>
<dt className="text-muted-foreground">Type</dt>
<dd>{openCase.type}</dd>
<dt className="text-muted-foreground">Applicant</dt>
<dd>{openCase.applicant}</dd>
<dt className="text-muted-foreground">Property</dt>
<dd>{openCase.address}</dd>
<dt className="text-muted-foreground">Received</dt>
<dd>{formatDate(openCase.received)} 2026</dd>
<dt className="text-muted-foreground">Due</dt>
<dd>
<DueDate item={openCase} />
</dd>
<dt className="text-muted-foreground">Case officer</dt>
<dd className="flex items-center gap-2">
<OfficerAvatar id={openCase.assignee} />
{OFFICERS[openCase.assignee].name}
</dd>
</dl>
<SheetFooter className="mt-auto flex-row justify-end gap-2 p-4">
{openCase.assignee !== ME ? (
<Button variant="outline" onClick={() => assign(openCase.id, ME)}>
Assign to me
</Button>
) : null}
<Button onClick={() => setSheetOpen(false)}>Go to case</Button>
</SheetFooter>
</>
) : null}
</SheetContent>
</Sheet>
</SidebarProvider>
)
}