Examples
Whole screens for municipal services, built only from rapidUI components. Copy the source into your app.
Case detail
One case: documents, messages with the resident, history and the decision.
2026-04812In progress
Extension of single-family home, Björken 4:12
- Handler
- SBSofia Berg
- Due
- 8 Jun 2026(in 35 days)
View source components/examples/case-detail.tsx
'use client'
import {
ArrowDownLeftIcon,
ArrowUpRightIcon,
BuildingIcon,
CalendarClockIcon,
CheckCheckIcon,
CheckCircle2Icon,
CheckIcon,
CircleDotIcon,
CircleIcon,
ClockIcon,
FileTextIcon,
GavelIcon,
HomeIcon,
ImageIcon,
LinkIcon,
MailIcon,
MessageSquareIcon,
MoreHorizontalIcon,
PauseCircleIcon,
PhoneIcon,
PlayCircleIcon,
SendIcon,
UserCogIcon,
UserIcon,
XCircleIcon,
} from 'lucide-react'
import * as React from 'react'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from '@/components/ui/attachment'
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 { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { DateTimePicker } from '@/components/ui/datetime-picker'
import {
DescriptionList,
DescriptionListDetails,
DescriptionListItem,
DescriptionListTerm,
} from '@/components/ui/description-list'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from '@/components/ui/field'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item'
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
MessageHeader,
} from '@/components/ui/message'
import { Progress, ProgressLabel, ProgressValue } from '@/components/ui/progress'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { SubTabs, SubTabsContent, SubTabsList, SubTabsTrigger } from '@/components/ui/sub-tabs'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
// ——— Data ———————————————————————————————————————————————————————————————————————————————————————————
type Status =
| 'in-progress'
| 'awaiting-supplement'
| 'on-hold'
| 'granted'
| 'granted-conditions'
| 'refused'
| 'closed'
const statusMeta: Record<Status, { label: string; icon: React.ElementType; className: string }> = {
'in-progress': { label: 'In progress', icon: CircleDotIcon, className: 'bg-primary/10 text-primary' },
'awaiting-supplement': { label: 'Awaiting supplement', icon: ClockIcon, className: 'bg-muted text-foreground' },
'on-hold': { label: 'On hold', icon: PauseCircleIcon, className: 'bg-muted text-foreground' },
granted: { label: 'Granted', icon: CheckCircle2Icon, className: 'bg-primary/10 text-primary' },
'granted-conditions': {
label: 'Granted with conditions',
icon: CheckCircle2Icon,
className: 'bg-primary/10 text-primary',
},
refused: { label: 'Refused', icon: XCircleIcon, className: 'bg-destructive/10 text-destructive' },
closed: { label: 'Closed', icon: XCircleIcon, className: 'bg-muted text-muted-foreground' },
}
type Officer = { id: string; name: string; initials: string; role: string; phone: string; email: string }
const officers: Officer[] = [
{
id: 'sofia',
name: 'Sofia Berg',
initials: 'SB',
role: 'Building permit officer',
phone: '021-39 14 22',
email: 'sofia.berg@kommun.example',
},
{
id: 'mikko',
name: 'Mikko Virtanen',
initials: 'MV',
role: 'Building inspector',
phone: '021-39 14 37',
email: 'mikko.virtanen@kommun.example',
},
{
id: 'omar',
name: 'Omar Haddad',
initials: 'OH',
role: 'Senior building permit officer',
phone: '021-39 14 05',
email: 'omar.haddad@kommun.example',
},
]
const officerItems = Object.fromEntries(officers.map((o) => [o.id, o.name]))
const facts = [
{ term: 'Applicant', value: 'Anna Lindqvist' },
{ term: 'Property', value: 'Björken 4:12, Storgatan 14' },
{ term: 'Case type', value: 'Building permit – extension' },
{ term: 'Received', value: '16 Mar 2026' },
{ term: 'Complete since', value: '30 Mar 2026' },
{ term: 'Fee', value: '24 680 kr' },
{ term: 'Detailed plan', value: 'DP 1284 (2011)' },
{ term: 'Extension area', value: '38 m² gross floor area' },
]
const steps = ['Registered', 'Completeness check', 'Neighbour consultation', 'Assessment', 'Decision']
const neighbours = [
{
id: 'n1',
property: 'Björken 4:11',
name: 'Ingrid Hansen',
verdict: 'Objection',
date: '22 Apr 2026',
text:
'The extension will shade our terrace in the afternoon. We ask that the roof height be lowered or the extension moved 2 metres further from the boundary.',
},
{
id: 'n2',
property: 'Björken 4:13',
name: 'Omar Haddad',
verdict: 'Comments',
date: '20 Apr 2026',
text:
'No objection to the extension itself, but we would like construction traffic to use Kungsgatan and not the shared driveway.',
},
{
id: 'n3',
property: 'Storgatan 16',
name: 'Mikko Virtanen',
verdict: 'No objection',
date: '18 Apr 2026',
text: 'We have seen the drawings and have no objections.',
},
]
type Direction = 'incoming' | 'outgoing'
type Doc = {
id: string
title: string
direction: Direction
type: string
date: string
by: string
files: { name: string; meta: string; kind: 'pdf' | 'image' }[]
}
const initialDocuments: Doc[] = [
{
id: 'd1',
title: 'Application for building permit',
direction: 'incoming',
type: 'Application',
date: '16 Mar 2026',
by: 'Anna Lindqvist',
files: [
{ name: 'application.pdf', meta: 'PDF · 412 kB', kind: 'pdf' },
{ name: 'site-plan.pdf', meta: 'PDF · 1.2 MB', kind: 'pdf' },
],
},
{
id: 'd2',
title: 'Request for supplementary documents',
direction: 'outgoing',
type: 'Letter',
date: '19 Mar 2026',
by: 'Sofia Berg',
files: [{ name: 'request-supplement.pdf', meta: 'PDF · 88 kB', kind: 'pdf' }],
},
{
id: 'd3',
title: 'Facade and section drawings',
direction: 'incoming',
type: 'Supplement',
date: '30 Mar 2026',
by: 'Anna Lindqvist',
files: [
{ name: 'facades-rev-b.pdf', meta: 'PDF · 2.4 MB', kind: 'pdf' },
{ name: 'photo-garden.jpg', meta: 'JPG · 3.1 MB', kind: 'image' },
],
},
{
id: 'd4',
title: 'Neighbour consultation letter',
direction: 'outgoing',
type: 'Consultation',
date: '6 Apr 2026',
by: 'Sofia Berg',
files: [{ name: 'consultation.pdf', meta: 'PDF · 156 kB', kind: 'pdf' }],
},
{
id: 'd5',
title: 'Neighbour statement – Björken 4:11',
direction: 'incoming',
type: 'Statement',
date: '22 Apr 2026',
by: 'Ingrid Hansen',
files: [{ name: 'statement-4-11.pdf', meta: 'PDF · 64 kB', kind: 'pdf' }],
},
]
type ChatMessage = {
id: string
from: 'applicant' | 'officer'
text: string
time: string
status?: 'Read' | 'Delivered'
files?: { name: string; meta: string }[]
}
const initialMessages: ChatMessage[] = [
{
id: 'm1',
from: 'applicant',
text: 'Hi! I have uploaded the revised facade drawings you asked for. Is anything else missing?',
time: '30 Mar, 08:14',
files: [{ name: 'facades-rev-b.pdf', meta: 'PDF · 2.4 MB' }],
},
{
id: 'm2',
from: 'officer',
text:
'Thank you, Anna. The application is now complete. Next we will send it to your neighbours for consultation – they have three weeks to reply.',
time: '30 Mar, 13:02',
status: 'Read',
},
{
id: 'm3',
from: 'applicant',
text: 'Great. I heard that the neighbour at 4:11 is worried about shade. Can I send a sun study?',
time: '24 Apr, 19:40',
},
{
id: 'm4',
from: 'officer',
text: 'Yes, please do. A sun study for 20 March and 21 June would help us assess the objection.',
time: '27 Apr, 09:15',
status: 'Read',
},
]
type HistoryEvent = { id: string; icon: React.ElementType; title: string; who: string; when: string }
const initialHistory: HistoryEvent[] = [
{
id: 'h7',
icon: MessageSquareIcon,
title: 'Message sent to applicant',
who: 'Sofia Berg',
when: '27 Apr 2026, 09:15',
},
{
id: 'h6',
icon: ArrowDownLeftIcon,
title: 'Neighbour statement registered (Björken 4:11)',
who: 'Registry',
when: '22 Apr 2026, 10:31',
},
{
id: 'h5',
icon: ArrowUpRightIcon,
title: 'Neighbour consultation sent to 3 properties',
who: 'Sofia Berg',
when: '6 Apr 2026, 14:20',
},
{ id: 'h4', icon: CheckIcon, title: 'Application marked complete', who: 'Sofia Berg', when: '30 Mar 2026, 13:00' },
{ id: 'h3', icon: ArrowUpRightIcon, title: 'Supplement requested', who: 'Sofia Berg', when: '19 Mar 2026, 11:47' },
{ id: 'h2', icon: UserCogIcon, title: 'Case assigned to Sofia Berg', who: 'Omar Haddad', when: '17 Mar 2026, 08:30' },
{ id: 'h1', icon: FileTextIcon, title: 'Case registered via e-service', who: 'System', when: '16 Mar 2026, 21:05' },
]
const relatedCases = [
{ id: '2025-11207', title: 'Pre-application advice, Björken 4:12', status: 'Closed' },
{ id: '2026-04390', title: 'Tree felling permit, Björken 4:11', status: 'In progress' },
{ id: '2024-02918', title: 'Garage, Björken 4:12', status: 'Granted' },
]
const deadlines = [
{ label: 'Supplement from applicant', date: '14 May 2026', note: 'Sun study' },
{ label: 'Statutory decision deadline', date: '8 Jun 2026', note: '10 weeks from complete' },
{ label: 'Appeal period ends', date: '3 weeks after notice', note: 'After decision' },
]
const decisionTypes = [
{ value: 'granted', label: 'Granted', description: 'The permit is granted as applied for.' },
{
value: 'granted-conditions',
label: 'Granted with conditions',
description: 'The permit is granted, subject to the conditions below.',
},
{ value: 'refused', label: 'Refused', description: 'The application is refused. State the reasons below.' },
] as const
type DecisionType = (typeof decisionTypes)[number]['value']
function nowLabel() {
return new Date().toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function formatDate(value: string) {
return new Date(`${value}T00:00:00`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
}
// ——— Small pieces ————————————————————————————————————————————————————————————————————————————————
function StatusBadge({ status }: { status: Status }) {
const meta = statusMeta[status]
const Icon = meta.icon
return (
<Badge className={meta.className}>
<Icon aria-hidden="true" />
{meta.label}
</Badge>
)
}
function HandlerCard({ officer }: { officer: Officer }) {
return (
<HoverCard>
<HoverCardTrigger
delay={200}
closeDelay={100}
href={`#officer-${officer.id}`}
onClick={(event) => event.preventDefault()}
className="inline-flex items-center gap-2 rounded-md font-medium text-foreground underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring"
>
<Avatar size="sm">
<AvatarFallback>{officer.initials}</AvatarFallback>
</Avatar>
{officer.name}
</HoverCardTrigger>
<HoverCardContent className="w-72 p-0" align="start">
<div className="flex gap-3 p-4">
<Avatar size="lg">
<AvatarFallback>{officer.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col gap-0.5">
<p className="font-medium">{officer.name}</p>
<p className="text-muted-foreground">{officer.role}</p>
</div>
</div>
<Separator />
<DescriptionList className="gap-y-2 p-4">
<DescriptionListItem className="flex-row items-center gap-2">
<DescriptionListTerm className="flex shrink-0">
<BuildingIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">Department</span>
</DescriptionListTerm>
<DescriptionListDetails>Planning and Building Department</DescriptionListDetails>
</DescriptionListItem>
<DescriptionListItem className="flex-row items-center gap-2">
<DescriptionListTerm className="flex shrink-0">
<PhoneIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">Phone</span>
</DescriptionListTerm>
<DescriptionListDetails>{officer.phone}</DescriptionListDetails>
</DescriptionListItem>
<DescriptionListItem className="flex-row items-center gap-2">
<DescriptionListTerm className="flex shrink-0">
<MailIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">Email</span>
</DescriptionListTerm>
<DescriptionListDetails className="truncate">{officer.email}</DescriptionListDetails>
</DescriptionListItem>
</DescriptionList>
</HoverCardContent>
</HoverCard>
)
}
function FileChip({ name, meta, kind = 'pdf' }: { name: string; meta: string; kind?: 'pdf' | 'image' }) {
return (
<Attachment size="xs">
<AttachmentMedia>{kind === 'image' ? <ImageIcon /> : <FileTextIcon />}</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{name}</AttachmentTitle>
<AttachmentDescription>{meta}</AttachmentDescription>
</AttachmentContent>
</Attachment>
)
}
// ——— Tabs ————————————————————————————————————————————————————————————————————————————————————————
function OverviewTab({ stepIndex }: { stepIndex: number }) {
const done = Math.min(stepIndex, steps.length)
return (
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle as="h4">Key facts</CardTitle>
</CardHeader>
<CardContent>
<DescriptionList columns={2}>
{facts.map((fact) => (
<DescriptionListItem key={fact.term} className="gap-0.5">
<DescriptionListTerm>{fact.term}</DescriptionListTerm>
<DescriptionListDetails className="font-medium">{fact.value}</DescriptionListDetails>
</DescriptionListItem>
))}
</DescriptionList>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle as="h4">Handling</CardTitle>
<CardDescription>
{done >= steps.length ? 'All steps are complete.' : `Current step: ${steps[done]}`}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Progress value={(done / steps.length) * 100}>
<ProgressLabel>Handling steps</ProgressLabel>
<ProgressValue>{() => `${done} of ${steps.length} done`}</ProgressValue>
</Progress>
<ol className="grid gap-2 @xl:grid-cols-5">
{steps.map((step, index) => {
const state = index < done ? 'Done' : index === done ? 'Current' : 'Upcoming'
return (
<li
key={step}
aria-current={state === 'Current' ? 'step' : undefined}
className="flex items-center gap-2 rounded-lg border px-3 py-2 data-[state=Current]:border-primary data-[state=Current]:bg-primary/5 @xl:flex-col @xl:items-start @xl:gap-1"
data-state={state}
>
{state === 'Done' ? (
<CheckCircle2Icon className="size-4 shrink-0 text-primary" aria-hidden="true" />
) : state === 'Current' ? (
<CircleDotIcon className="size-4 shrink-0 text-primary" aria-hidden="true" />
) : (
<CircleIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
)}
<span className="font-medium">{step}</span>
<span className="ml-auto text-muted-foreground @xl:ml-0">{state}</span>
</li>
)
})}
</ol>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle as="h4">Neighbour consultation</CardTitle>
<CardDescription>3 of 3 responses received · reply deadline was 27 Apr 2026</CardDescription>
</CardHeader>
<CardContent>
<Accordion defaultValue={['n1']}>
{neighbours.map((n) => (
<AccordionItem key={n.id} value={n.id}>
<AccordionTrigger>
<span className="flex flex-1 flex-wrap items-center gap-x-2 gap-y-1">
<span>
{n.property} · {n.name}
</span>
<Badge
variant={n.verdict === 'Objection' ? 'destructive' : n.verdict === 'Comments' ? 'secondary' : 'outline'}
>
{n.verdict}
</Badge>
</span>
</AccordionTrigger>
<AccordionContent>
<p className="text-muted-foreground">Received {n.date}</p>
<p className="mt-1">{n.text}</p>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
</div>
)
}
function DocumentsTab({ documents }: { documents: Doc[] }) {
const [filter, setFilter] = React.useState<'all' | Direction>('all')
const counts = {
all: documents.length,
incoming: documents.filter((d) => d.direction === 'incoming').length,
outgoing: documents.filter((d) => d.direction === 'outgoing').length,
}
const visible = filter === 'all' ? documents : documents.filter((d) => d.direction === filter)
return (
<SubTabs value={filter} onValueChange={(value) => setFilter(value as 'all' | Direction)}>
<div className="flex flex-wrap items-center justify-between gap-2">
<SubTabsList aria-label="Filter documents">
<SubTabsTrigger value="all">All ({counts.all})</SubTabsTrigger>
<SubTabsTrigger value="incoming">Incoming ({counts.incoming})</SubTabsTrigger>
<SubTabsTrigger value="outgoing">Outgoing ({counts.outgoing})</SubTabsTrigger>
</SubTabsList>
<p className="text-muted-foreground">Diary 2026-04812 · {documents.length} registered</p>
</div>
{(['all', 'incoming', 'outgoing'] as const).map((key) => (
<SubTabsContent key={key} value={key} className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-4">Document</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Type</TableHead>
<TableHead className="pr-4">Registered</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((doc) => (
<TableRow key={doc.id}>
<TableCell className="min-w-72 py-3 pl-4 align-top whitespace-normal">
<div className="flex flex-col gap-2">
<span className="font-medium">{doc.title}</span>
<AttachmentGroup className="flex-wrap gap-2 overflow-visible py-0">
{doc.files.map((file) => (
<FileChip key={file.name} {...file} />
))}
</AttachmentGroup>
</div>
</TableCell>
<TableCell className="py-3 align-top">
<span className="inline-flex items-center gap-1.5">
{doc.direction === 'incoming' ? (
<ArrowDownLeftIcon className="size-4 text-muted-foreground" aria-hidden="true" />
) : (
<ArrowUpRightIcon className="size-4 text-muted-foreground" aria-hidden="true" />
)}
{doc.direction === 'incoming' ? 'Incoming' : 'Outgoing'}
</span>
</TableCell>
<TableCell className="py-3 align-top">
<Badge variant="outline">{doc.type}</Badge>
</TableCell>
<TableCell className="py-3 pr-4 align-top">
<div className="flex flex-col">
<span>{doc.date}</span>
<span className="text-muted-foreground">{doc.by}</span>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SubTabsContent>
))}
</SubTabs>
)
}
function MessagesTab({
messages,
officer,
onSend,
}: {
messages: ChatMessage[]
officer: Officer
onSend: (text: string) => void
}) {
const id = React.useId()
const [draft, setDraft] = React.useState('')
const [error, setError] = React.useState<string | null>(null)
function send(event: React.FormEvent) {
event.preventDefault()
if (!draft.trim()) {
setError('Write a message before sending.')
return
}
onSend(draft.trim())
setDraft('')
setError(null)
}
return (
<Card>
<CardHeader>
<CardTitle as="h4">Conversation with Anna Lindqvist</CardTitle>
<CardDescription>Messages are registered on the case and sent to the applicant’s digital mailbox.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<MessageGroup className="gap-5" role="log" aria-label="Messages">
{messages.map((m) => {
const isOfficer = m.from === 'officer'
return (
<Message key={m.id} align={isOfficer ? 'end' : 'start'}>
<MessageAvatar>
<Avatar size="sm">
<AvatarFallback>{isOfficer ? officer.initials : 'AL'}</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<MessageHeader>{isOfficer ? officer.name : 'Anna Lindqvist'}</MessageHeader>
<Bubble variant={isOfficer ? 'default' : 'muted'}>
<BubbleContent>{m.text}</BubbleContent>
</Bubble>
{m.files?.map((file) => (
<FileChip key={file.name} name={file.name} meta={file.meta} />
))}
<MessageFooter className="gap-1">
{m.time}
{m.status ? (
<>
<span aria-hidden="true">·</span>
{m.status === 'Read' ? (
<CheckCheckIcon className="size-3.5" aria-hidden="true" />
) : (
<CheckIcon className="size-3.5" aria-hidden="true" />
)}
{m.status}
</>
) : null}
</MessageFooter>
</MessageContent>
</Message>
)
})}
</MessageGroup>
<Separator />
<form onSubmit={send} noValidate>
<Field data-invalid={error ? true : undefined}>
<FieldLabel htmlFor={`${id}-reply`}>Reply to Anna Lindqvist</FieldLabel>
<Textarea
id={`${id}-reply`}
value={draft}
onChange={(event) => {
setDraft(event.target.value)
if (error) setError(null)
}}
placeholder="Write a message…"
rows={3}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-reply-error` : `${id}-reply-hint`}
/>
{error ? (
<FieldError id={`${id}-reply-error`}>{error}</FieldError>
) : (
<FieldDescription id={`${id}-reply-hint`}>The applicant is notified by email.</FieldDescription>
)}
<div className="flex justify-end">
<Button type="submit">
<SendIcon data-icon="inline-start" />
Send
</Button>
</div>
</Field>
</form>
</CardContent>
</Card>
)
}
function HistoryTab({ history }: { history: HistoryEvent[] }) {
return (
<Card>
<CardHeader>
<CardTitle as="h4">Case history</CardTitle>
<CardDescription>Newest first. All events are logged automatically.</CardDescription>
</CardHeader>
<CardContent>
<ol className="relative flex flex-col gap-1 before:absolute before:top-4 before:bottom-4 before:left-7 before:w-px before:bg-border">
{history.map((event) => {
const Icon = event.icon
return (
<Item key={event.id} render={<li />} size="sm" className="relative">
<ItemMedia className="z-10 size-8 rounded-full border bg-card">
<Icon className="size-4 text-muted-foreground" aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle>{event.title}</ItemTitle>
<ItemDescription>
{event.who} · <time>{event.when}</time>
</ItemDescription>
</ItemContent>
</Item>
)
})}
</ol>
</CardContent>
</Card>
)
}
// ——— Aside ———————————————————————————————————————————————————————————————————————————————————————
function CaseAside({ status }: { status: Status }) {
return (
<aside aria-label="Case details" className="flex flex-col gap-4">
<Card size="sm">
<CardHeader>
<CardTitle as="h4">Applicant</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>AL</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="font-medium">Anna Lindqvist</span>
<span className="text-muted-foreground">Private individual</span>
</div>
</div>
<DescriptionList className="gap-y-2">
<DescriptionListItem className="flex-row items-center gap-2">
<DescriptionListTerm className="flex shrink-0">
<HomeIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">Address</span>
</DescriptionListTerm>
<DescriptionListDetails>Storgatan 14, 722 15</DescriptionListDetails>
</DescriptionListItem>
<DescriptionListItem className="flex-row items-center gap-2">
<DescriptionListTerm className="flex shrink-0">
<PhoneIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">Phone</span>
</DescriptionListTerm>
<DescriptionListDetails>070-123 45 67</DescriptionListDetails>
</DescriptionListItem>
<DescriptionListItem className="flex-row items-center gap-2">
<DescriptionListTerm className="flex shrink-0">
<MailIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">Email</span>
</DescriptionListTerm>
<DescriptionListDetails className="truncate">anna.lindqvist@example.se</DescriptionListDetails>
</DescriptionListItem>
<DescriptionListItem className="flex-row items-center gap-2">
<DescriptionListTerm className="flex shrink-0">
<UserIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">Preferred contact</span>
</DescriptionListTerm>
<DescriptionListDetails>Prefers digital mailbox</DescriptionListDetails>
</DescriptionListItem>
</DescriptionList>
</CardContent>
</Card>
<Card size="sm">
<CardHeader>
<CardTitle as="h4">Deadlines</CardTitle>
</CardHeader>
<CardContent>
<ul className="flex flex-col gap-3">
{deadlines.map((d, index) => {
const met = index < 2 && ['granted', 'granted-conditions', 'refused', 'closed'].includes(status)
return (
<li key={d.label} className="flex gap-2">
{met ? (
<CheckCircle2Icon className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden="true" />
) : (
<CalendarClockIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
)}
<div className="flex min-w-0 flex-col">
<span className="font-medium">{d.label}</span>
<span className="text-muted-foreground">
{d.date} · {met ? 'Met' : d.note}
</span>
</div>
</li>
)
})}
</ul>
</CardContent>
</Card>
<Card size="sm">
<CardHeader>
<CardTitle as="h4">Related cases</CardTitle>
</CardHeader>
<CardContent className="px-1.5">
<ul className="flex flex-col">
{relatedCases.map((c) => (
<li key={c.id}>
{/* biome-ignore lint/a11y/useAnchorContent: the children are rendered inside the anchor */}
<Item size="xs" onClick={(event) => event.preventDefault()} render={<a href={`#case-${c.id}`} />}>
<ItemMedia variant="icon">
<LinkIcon className="text-muted-foreground" aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle>{c.id}</ItemTitle>
<ItemDescription>{c.title}</ItemDescription>
</ItemContent>
<Badge variant="outline">{c.status}</Badge>
</Item>
</li>
))}
</ul>
</CardContent>
</Card>
</aside>
)
}
// ——— Decision sheet ——————————————————————————————————————————————————————————————————————————————
type DecisionErrors = Partial<Record<'type' | 'conditions' | 'date', string>>
function DecisionSheet({
open,
onOpenChange,
onDecide,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onDecide: (type: DecisionType, date: string) => void
}) {
const id = React.useId()
const [type, setType] = React.useState<DecisionType | ''>('')
const [conditions, setConditions] = React.useState('')
const [date, setDate] = React.useState('')
const [errors, setErrors] = React.useState<DecisionErrors>({})
const [confirmOpen, setConfirmOpen] = React.useState(false)
const needsText = type === 'granted-conditions' || type === 'refused'
const typeLabel = decisionTypes.find((d) => d.value === type)?.label
function review() {
const next: DecisionErrors = {}
if (!type) next.type = 'Choose a decision.'
if (needsText && !conditions.trim())
next.conditions = type === 'refused' ? 'State the reasons for refusal.' : 'Write at least one condition.'
if (!date) next.date = 'Choose the decision date.'
setErrors(next)
if (Object.keys(next).length === 0) setConfirmOpen(true)
}
function reset() {
setType('')
setConditions('')
setDate('')
setErrors({})
}
return (
<>
<Sheet
open={open}
onOpenChange={(next) => {
onOpenChange(next)
if (!next) reset()
}}
>
<SheetContent className="sm:max-w-md">
<SheetHeader className="pr-12">
<SheetTitle>Make decision</SheetTitle>
<SheetDescription>2026-04812 · Extension of single-family home, Björken 4:12</SheetDescription>
</SheetHeader>
<FieldGroup className="px-4">
<FieldSet data-invalid={errors.type ? true : undefined}>
<FieldLegend variant="label">Decision</FieldLegend>
<RadioGroup
value={type}
onValueChange={(value) => {
setType(value as DecisionType)
setErrors((e) => ({ ...e, type: undefined }))
}}
aria-invalid={errors.type ? true : undefined}
aria-describedby={errors.type ? `${id}-type-error` : undefined}
>
{decisionTypes.map((d) => (
<FieldLabel key={d.value} htmlFor={`${id}-${d.value}`}>
<Field orientation="horizontal">
<RadioGroupItem value={d.value} id={`${id}-${d.value}`} />
<div className="flex flex-col gap-0.5">
<span className="font-medium">{d.label}</span>
<span className="font-normal text-muted-foreground">{d.description}</span>
</div>
</Field>
</FieldLabel>
))}
</RadioGroup>
<FieldError id={`${id}-type-error`}>{errors.type}</FieldError>
</FieldSet>
<Field data-invalid={errors.conditions ? true : undefined}>
<FieldLabel htmlFor={`${id}-conditions`}>
{type === 'refused' ? 'Reasons for refusal' : 'Conditions'}
{needsText ? null : <span className="font-normal text-muted-foreground">(optional)</span>}
</FieldLabel>
<Textarea
id={`${id}-conditions`}
rows={4}
value={conditions}
onChange={(event) => {
setConditions(event.target.value)
setErrors((e) => ({ ...e, conditions: undefined }))
}}
placeholder={
type === 'refused'
? 'The extension exceeds the permitted building height in DP 1284…'
: 'The roof height may not exceed 5.6 m above ground…'
}
aria-invalid={errors.conditions ? true : undefined}
aria-describedby={errors.conditions ? `${id}-conditions-error` : `${id}-conditions-hint`}
/>
{errors.conditions ? (
<FieldError id={`${id}-conditions-error`}>{errors.conditions}</FieldError>
) : (
<FieldDescription id={`${id}-conditions-hint`}>
Shown in the decision letter to the applicant and neighbours.
</FieldDescription>
)}
</Field>
<Field data-invalid={errors.date ? true : undefined}>
<FieldLabel htmlFor={`${id}-date`}>Decision date</FieldLabel>
<DateTimePicker
id={`${id}-date`}
value={date}
onChange={(value) => {
setDate(value)
setErrors((e) => ({ ...e, date: undefined }))
}}
placeholder="Choose a date"
disabledDates={{ dayOfWeek: [0, 6] }}
aria-invalid={errors.date ? true : undefined}
className="w-full"
/>
<FieldError>{errors.date}</FieldError>
</Field>
</FieldGroup>
<SheetFooter className="border-t sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={review}>
<GavelIcon data-icon="inline-start" />
Review and decide
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Register decision “{typeLabel}”?</AlertDialogTitle>
<AlertDialogDescription>
The decision dated {date ? formatDate(date) : ''} is registered on case 2026-04812 and sent to Anna Lindqvist and
the three neighbours. It can only be changed by a new decision.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Go back</AlertDialogCancel>
<AlertDialogAction
variant={type === 'refused' ? 'destructive' : 'default'}
onClick={() => {
if (!type) return
onDecide(type, date)
onOpenChange(false)
reset()
}}
>
Register decision
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
// ——— Screen ——————————————————————————————————————————————————————————————————————————————————————
export default function CaseDetailExample() {
const id = React.useId()
const [status, setStatus] = React.useState<Status>('in-progress')
const [officerId, setOfficerId] = React.useState('sofia')
const [stepIndex, setStepIndex] = React.useState(3)
const [tab, setTab] = React.useState('overview')
const [documents, setDocuments] = React.useState(initialDocuments)
const [messages, setMessages] = React.useState(initialMessages)
const [history, setHistory] = React.useState(initialHistory)
const [notice, setNotice] = React.useState<{ title: string; text: string } | null>(null)
const [decisionOpen, setDecisionOpen] = React.useState(false)
const [supplementOpen, setSupplementOpen] = React.useState(false)
const [supplementText, setSupplementText] = React.useState('')
const [supplementError, setSupplementError] = React.useState<string | null>(null)
const [reassignOpen, setReassignOpen] = React.useState(false)
const [reassignTo, setReassignTo] = React.useState<string | null>('sofia')
const [closeOpen, setCloseOpen] = React.useState(false)
const officer = officers.find((o) => o.id === officerId) ?? officers[0]!
const decided = ['granted', 'granted-conditions', 'refused', 'closed'].includes(status)
function log(icon: React.ElementType, title: string) {
setHistory((h) => [{ id: `h${Date.now()}`, icon, title, who: officer.name, when: nowLabel() }, ...h])
}
function sendMessage(text: string) {
setMessages((m) => [
...m,
{
id: `m${Date.now()}`,
from: 'officer',
text,
time: new Date().toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }),
status: 'Delivered',
},
])
log(MessageSquareIcon, 'Message sent to applicant')
}
function requestSupplement() {
if (!supplementText.trim()) {
setSupplementError('Describe what the applicant needs to send.')
return
}
sendMessage(supplementText.trim())
setDocuments((d) => [
...d,
{
id: `d${Date.now()}`,
title: 'Request for supplementary documents',
direction: 'outgoing',
type: 'Letter',
date: formatDate(new Date().toISOString().slice(0, 10)),
by: officer.name,
files: [{ name: 'request-supplement-2.pdf', meta: 'PDF · 72 kB', kind: 'pdf' }],
},
])
log(ArrowUpRightIcon, 'Supplement requested')
setStatus('awaiting-supplement')
setSupplementOpen(false)
setSupplementText('')
setNotice({ title: 'Supplement requested', text: 'Anna Lindqvist has been asked to send more documents.' })
}
function decide(type: DecisionType, date: string) {
const label = decisionTypes.find((d) => d.value === type)?.label ?? ''
setStatus(type)
setStepIndex(steps.length)
setDocuments((d) => [
...d,
{
id: `d${Date.now()}`,
title: `Decision – ${label.toLowerCase()}`,
direction: 'outgoing',
type: 'Decision',
date: formatDate(date),
by: officer.name,
files: [{ name: 'decision-2026-04812.pdf', meta: 'PDF · 210 kB', kind: 'pdf' }],
},
])
log(GavelIcon, `Decision registered: ${label}`)
setNotice({
title: `Decision registered: ${label}`,
text: `Dated ${formatDate(date)}. The decision letter has been sent.`,
})
}
function reassign() {
if (!reassignTo || reassignTo === officerId) {
setReassignOpen(false)
return
}
const next = officers.find((o) => o.id === reassignTo)
setOfficerId(reassignTo)
setHistory((h) => [
{
id: `h${Date.now()}`,
icon: UserCogIcon,
title: `Case reassigned to ${next?.name}`,
who: officer.name,
when: nowLabel(),
},
...h,
])
setReassignOpen(false)
setNotice({ title: 'Case reassigned', text: `${next?.name} is now handling the case.` })
}
return (
<div className="@container flex min-h-[720px] flex-col gap-6 bg-background p-4 text-sm @3xl:p-6">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#cases" onClick={(event) => event.preventDefault()}>
Cases
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#building-permits" onClick={(event) => event.preventDefault()}>
Building permits
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>2026-04812</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<header className="flex flex-col gap-4 @4xl:flex-row @4xl:items-start @4xl:justify-between">
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-muted-foreground">2026-04812</span>
<StatusBadge status={status} />
</div>
<h3 className="font-heading text-xl font-semibold text-balance">Extension of single-family home, Björken 4:12</h3>
<DescriptionList className="flex flex-wrap items-center gap-x-6 gap-y-2">
<DescriptionListItem className="flex-row items-center gap-2">
<DescriptionListTerm>Handler</DescriptionListTerm>
<DescriptionListDetails>
<HandlerCard officer={officer} />
</DescriptionListDetails>
</DescriptionListItem>
<DescriptionListItem className="flex-row items-center gap-2">
<DescriptionListTerm>Due</DescriptionListTerm>
<DescriptionListDetails className="inline-flex items-center gap-1.5 font-medium">
<CalendarClockIcon className="size-4 text-muted-foreground" aria-hidden="true" />8 Jun 2026
{decided ? null : <span className="font-normal text-muted-foreground">(in 35 days)</span>}
</DescriptionListDetails>
</DescriptionListItem>
</DescriptionList>
</div>
<div className="flex flex-wrap items-center gap-2">
<ButtonGroup aria-label="Case actions">
<Button variant="outline" disabled={decided} onClick={() => setSupplementOpen(true)}>
<FileTextIcon data-icon="inline-start" className="hidden @md:block" />
<span className="@md:hidden">Supplement</span>
<span className="hidden @md:inline">Request supplement</span>
</Button>
<Button disabled={decided} onClick={() => setDecisionOpen(true)}>
<GavelIcon data-icon="inline-start" className="hidden @md:block" />
Make decision
</Button>
</ButtonGroup>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="icon" aria-label="More case actions" />}>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuLabel>Case 2026-04812</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
setReassignTo(officerId)
setReassignOpen(true)
}}
>
<UserCogIcon />
Reassign…
</DropdownMenuItem>
{status === 'on-hold' ? (
<DropdownMenuItem
onClick={() => {
setStatus('in-progress')
log(PlayCircleIcon, 'Case resumed')
setNotice(null)
}}
>
<PlayCircleIcon />
Resume case
</DropdownMenuItem>
) : (
<DropdownMenuItem
disabled={decided}
onClick={() => {
setStatus('on-hold')
log(PauseCircleIcon, 'Case put on hold')
setNotice({ title: 'Case on hold', text: 'The statutory deadline is paused until you resume the case.' })
}}
>
<PauseCircleIcon />
Put on hold
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" disabled={status === 'closed'} onClick={() => setCloseOpen(true)}>
<XCircleIcon />
Close case…
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
{notice ? (
<Alert>
<CheckCircle2Icon />
<AlertTitle>{notice.title}</AlertTitle>
<AlertDescription>{notice.text}</AlertDescription>
<AlertAction>
<Button variant="ghost" size="sm" onClick={() => setNotice(null)}>
Dismiss
</Button>
</AlertAction>
</Alert>
) : null}
<div className="grid items-start gap-6 @4xl:grid-cols-[minmax(0,1fr)_300px]">
<Tabs value={tab} onValueChange={(value) => setTab(String(value))} className="min-w-0 gap-4">
<div className="-mx-4 overflow-x-auto px-4 @3xl:mx-0 @3xl:px-0">
<TabsList aria-label="Case sections">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="documents">
Documents
<Badge variant="secondary">{documents.length}</Badge>
</TabsTrigger>
<TabsTrigger value="messages">
Messages
<Badge variant="secondary">{messages.length}</Badge>
</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>
</div>
<TabsContent value="overview">
<OverviewTab stepIndex={stepIndex} />
</TabsContent>
<TabsContent value="documents">
{documents.length ? (
<DocumentsTab documents={documents} />
) : (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<FileTextIcon />
</EmptyMedia>
<EmptyTitle>No documents</EmptyTitle>
<EmptyDescription>Registered documents appear here.</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</TabsContent>
<TabsContent value="messages">
<MessagesTab messages={messages} officer={officer} onSend={sendMessage} />
</TabsContent>
<TabsContent value="history">
<HistoryTab history={history} />
</TabsContent>
</Tabs>
<CaseAside status={status} />
</div>
<DecisionSheet open={decisionOpen} onOpenChange={setDecisionOpen} onDecide={decide} />
<Dialog
open={supplementOpen}
onOpenChange={(open) => {
setSupplementOpen(open)
if (!open) setSupplementError(null)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Request supplement</DialogTitle>
<DialogDescription>
The request is sent to Anna Lindqvist and the case status changes to “Awaiting supplement”.
</DialogDescription>
</DialogHeader>
<Field data-invalid={supplementError ? true : undefined}>
<FieldLabel htmlFor={`${id}-supplement`}>What is missing?</FieldLabel>
<Textarea
id={`${id}-supplement`}
rows={4}
value={supplementText}
onChange={(event) => {
setSupplementText(event.target.value)
setSupplementError(null)
}}
placeholder="Please send a sun study for 20 March and 21 June by 14 May 2026."
aria-invalid={supplementError ? true : undefined}
aria-describedby={supplementError ? `${id}-supplement-error` : undefined}
/>
<FieldError id={`${id}-supplement-error`}>{supplementError}</FieldError>
</Field>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
<Button onClick={requestSupplement}>
<SendIcon data-icon="inline-start" />
Send request
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={reassignOpen} onOpenChange={setReassignOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reassign case</DialogTitle>
<DialogDescription>The new handler is notified and the change is logged in the history.</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel htmlFor={`${id}-officer`}>Handler</FieldLabel>
<Select value={reassignTo} onValueChange={(value) => setReassignTo(value)} items={officerItems}>
<SelectTrigger id={`${id}-officer`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{officers.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
<Button onClick={reassign}>Reassign</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={closeOpen} onOpenChange={setCloseOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Close case 2026-04812 without a decision?</AlertDialogTitle>
<AlertDialogDescription>
Use this when the applicant withdraws the application. The case is archived and can be reopened by the registry.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep open</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setStatus('closed')
log(XCircleIcon, 'Case closed without decision')
setNotice({ title: 'Case closed', text: 'The case has been archived.' })
}}
>
Close case
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}