/clinicClinic dashboard
A clinic's month: appointments, new patients, procedures and revenue in four toned tiles, visits by department, the calendar with each day's book, the desk's notes, the top procedures, and everything booked from today with a check-in on every row.
Installs at /clinic. The page is a server component inside AppShell: it reads the book through db.appointments and works everything out as rules over those rows — the four tiles over the last thirty days against the thirty before, with revenue as minutes in a room times a rate; visits by department over six months; distinct patients per department; each day's count for the calendar; and the five procedures done most often. The one series with no rows behind it, the year before for the year select, comes from seeded("clinic-overview") once, in data.ts. The desk's notes are the page's own list, seeded once and appended through addNote, which refuses a blank. A visit due today carries a row menu that checks the patient in through checkInAppointment, the Clinic dashboard's own action shared with the Appointments page through actions-clinic — a later day's visit carries none, since the action would refuse it: the row's pill turns and the status line names them, while a visit that is cancelled, done or already in is refused in an alert above the rows and its row takes the status the store reports. Composes AppShell, PageHeader, DashboardGrid, StatCardGroup, StatCard, ChartCard, LineChart, DonutChart, Select, Card, MiniCalendar, DataList, DataTable, StatusBadge, UserCell, PercentageBar, Progress, Input, Button and Callout.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { AppointmentsTable } from "./components/appointments-table"
import { ClinicCalendar } from "./components/clinic-calendar"
import { ClinicNotes } from "./components/clinic-notes"
import { ClinicStats } from "./components/clinic-stats"
import { DepartmentDonut } from "./components/department-donut"
import { RecentProcedures } from "./components/recent-procedures"
import { TopProcedures } from "./components/top-procedures"
import { VisitsChart } from "./components/visits-chart"
import {
TODAY,
appointmentsByDay,
chartDepartments,
clinicNotes,
clinicStats,
currentUser,
patientsByDepartment,
recentProcedures,
shellNotifications,
topProcedures,
upcomingAppointments,
visitsByDepartment,
visitsWindow,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/clinic/nav"
/**
* The clinic. The page is a server component: it reads the book through
* `db.appointments`, works the month's numbers out, and hands plain rows to
* the islands that need state — the year select, the calendar, the notes and
* the table whose row menu checks a patient in.
*/
export default function ClinicPage() {
const byYear = visitsByDepartment()
const upcoming = upcomingAppointments()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.overview}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Clinic"
description="The book, the desk and the month's numbers."
meta={`${upcoming.length} visits booked from today`}
/>
<ClinicStats stats={clinicStats()} />
<DashboardGrid>
<DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
<VisitsChart
byYear={byYear}
departments={chartDepartments()}
windowFor={Object.fromEntries(Object.keys(byYear).map((year) => [year, visitsWindow(Number(year))]))}
/>
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
<DepartmentDonut data={patientsByDepartment()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, md: 6, lg: 4 }}>
<ClinicCalendar byDay={appointmentsByDay()} today={TODAY} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, md: 6, lg: 4 }}>
<ClinicNotes notes={clinicNotes()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<TopProcedures procedures={topProcedures()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
<AppointmentsTable rows={upcoming} today={TODAY} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<RecentProcedures rows={recentProcedures()} />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/clinic-overviewNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { AppointmentsTable } from "./components/appointments-table"
import { ClinicCalendar } from "./components/clinic-calendar"
import { ClinicNotes } from "./components/clinic-notes"
import { ClinicStats } from "./components/clinic-stats"
import { DepartmentDonut } from "./components/department-donut"
import { RecentProcedures } from "./components/recent-procedures"
import { TopProcedures } from "./components/top-procedures"
import { VisitsChart } from "./components/visits-chart"
import {
TODAY,
appointmentsByDay,
chartDepartments,
clinicNotes,
clinicStats,
currentUser,
patientsByDepartment,
recentProcedures,
shellNotifications,
topProcedures,
upcomingAppointments,
visitsByDepartment,
visitsWindow,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/clinic/nav"
/**
* The clinic. The page is a server component: it reads the book through
* `db.appointments`, works the month's numbers out, and hands plain rows to
* the islands that need state — the year select, the calendar, the notes and
* the table whose row menu checks a patient in.
*/
export default function ClinicPage() {
const byYear = visitsByDepartment()
const upcoming = upcomingAppointments()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.overview}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Clinic"
description="The book, the desk and the month's numbers."
meta={`${upcoming.length} visits booked from today`}
/>
<ClinicStats stats={clinicStats()} />
<DashboardGrid>
<DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
<VisitsChart
byYear={byYear}
departments={chartDepartments()}
windowFor={Object.fromEntries(Object.keys(byYear).map((year) => [year, visitsWindow(Number(year))]))}
/>
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
<DepartmentDonut data={patientsByDepartment()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, md: 6, lg: 4 }}>
<ClinicCalendar byDay={appointmentsByDay()} today={TODAY} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, md: 6, lg: 4 }}>
<ClinicNotes notes={clinicNotes()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<TopProcedures procedures={topProcedures()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
<AppointmentsTable rows={upcoming} today={TODAY} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<RecentProcedures rows={recentProcedures()} />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}/**
* What this page reads. Every visit comes from `db.appointments`; the four
* headline numbers, the two charts, the calendar's marks and the procedure
* ranking are rules over those rows. The one thing with no rows behind it —
* last year's visits, for the year select — is generated once from
* `seeded("dashboard-clinic")`. "Now" is `REFERENCE_DATE`.
*
* The desk's notes are the page's own: a module-level list seeded once here
* and appended through `pushNote`, which only the server action calls.
*
* The client islands import only the types below. The words the page puts on
* a status are the Clinic dashboard's, in
* `@/lib/dashboards/clinic/vocabulary`, so nothing that reaches
* `db` is ever pulled into the browser.
*/
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, seeded, type Appointment, type Member } from "@/lib/sample-data"
export type Department = Appointment["department"]
export type AppointmentStatus = Appointment["status"]
/** One visit as the page prints it. */
export type AppointmentRow = {
id: string
patient: string
email: string
avatarUrl?: string
initials: string
doctor: string
department: Department
at: Date
durationMin: number
status: AppointmentStatus
procedure?: string
isNewPatient: boolean
}
function toRow(row: Appointment): AppointmentRow {
return {
id: row.id,
patient: row.patient,
email: row.email,
avatarUrl: row.avatarUrl,
initials: getInitials(row.patient),
doctor: row.doctor,
department: row.department,
at: row.at,
durationMin: row.durationMin,
status: row.status,
procedure: row.procedure,
isNewPatient: row.isNewPatient,
}
}
const DAY_MS = 86_400_000
const NOW = REFERENCE_DATE.getTime()
/** What a minute in a consulting room bills at, in cents. */
export const RATE_CENTS_PER_MINUTE = 450
const between = (row: Appointment, from: number, to: number) => row.at.getTime() > from && row.at.getTime() <= to
export type ClinicStat = {
id: "appointments" | "patients" | "procedures" | "revenue"
label: string
value: string
/** The change against the thirty days before, as a ratio. */
delta: number
description: string
}
const DOLLARS = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
const ratio = (now: number, before: number) => (before === 0 ? 0 : (now - before) / before)
/**
* The four headline numbers over the last thirty days, each against the
* thirty before. A calendar month would show three days of work on the
* fourth; a trailing window is what a desk compares against.
*/
export function clinicStats(): ClinicStat[] {
const rows = db.appointments.all()
const window = (offset: number) => rows.filter((row) => between(row, NOW - (offset + 30) * DAY_MS, NOW - offset * DAY_MS))
const now = window(0)
const before = window(30)
const patients = (set: Appointment[]) => set.filter((row) => row.isNewPatient).length
const procedures = (set: Appointment[]) => set.filter((row) => row.status === "done" && row.procedure).length
const revenue = (set: Appointment[]) =>
set.filter((row) => row.status === "done").reduce((sum, row) => sum + row.durationMin * RATE_CENTS_PER_MINUTE, 0)
const description = "vs the 30 days before"
return [
{ id: "appointments", label: "Appointments", value: String(now.length), delta: ratio(now.length, before.length), description },
{ id: "patients", label: "New patients", value: String(patients(now)), delta: ratio(patients(now), patients(before)), description },
{ id: "procedures", label: "Procedures", value: String(procedures(now)), delta: ratio(procedures(now), procedures(before)), description },
{ id: "revenue", label: "Revenue", value: DOLLARS.format(revenue(now) / 100), delta: ratio(revenue(now), revenue(before)), description },
]
}
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })
const MONTH_LONG = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })
/** The first of the month `back` months before the reference month. */
function monthStart(back: number, year = REFERENCE_DATE.getUTCFullYear()): Date {
return new Date(Date.UTC(year, REFERENCE_DATE.getUTCMonth() - back, 1))
}
export type VisitPoint = { month: string } & Record<string, string | number>
/** The three departments that see the most patients, busiest first — the chart's series. */
export function chartDepartments(): Department[] {
const counts = new Map<Department, number>()
for (const row of db.appointments.all()) counts.set(row.department, (counts.get(row.department) ?? 0) + 1)
return [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 3)
.map(([department]) => department)
}
const rand = seeded("dashboard-clinic")
// Last year's six months, drawn once: the rows only go back six months, so
// the year before is the one series here without rows behind it.
const LAST_YEAR = Array.from({ length: 6 }, () => [0, 1, 2].map(() => 9 + Math.floor(rand() * 14)))
/**
* Visits by department over the six months ending in the reference month,
* keyed by year. The current year is counted off the rows; the year before is
* the seeded series.
*/
export function visitsByDepartment(): Record<string, VisitPoint[]> {
const departments = chartDepartments()
const rows = db.appointments.all()
const year = REFERENCE_DATE.getUTCFullYear()
const months = Array.from({ length: 6 }, (_, index) => 5 - index)
const current = months.map((back) => {
const start = monthStart(back)
const end = monthStart(back - 1)
const point: VisitPoint = { month: MONTH.format(start) }
for (const department of departments) {
point[department] = rows.filter(
(row) => row.department === department && row.status !== "cancelled" && row.at >= start && row.at < end
).length
}
return point
})
const previous = months.map((back, index) => {
const point: VisitPoint = { month: MONTH.format(monthStart(back)) }
departments.forEach((department, series) => {
point[department] = LAST_YEAR[index][series]
})
return point
})
return { [String(year)]: current, [String(year - 1)]: previous }
}
/** "Six months to September 2026", for whichever year the select is on. */
export function visitsWindow(year: number): string {
return `Six months to ${MONTH_LONG.format(monthStart(0))} ${year}`
}
/** Distinct patients seen per department, largest first. */
export function patientsByDepartment(): { name: string; value: number }[] {
const seen = new Map<Department, Set<string>>()
for (const row of db.appointments.all()) {
if (!seen.has(row.department)) seen.set(row.department, new Set())
seen.get(row.department)!.add(row.email)
}
return [...seen.entries()]
.map(([name, patients]) => ({ name, value: patients.size }))
.sort((a, b) => b.value - a.value || a.name.localeCompare(b.name))
}
/** Every visit that is not cancelled, keyed by its UTC day, for the calendar. */
export function appointmentsByDay(): Record<string, AppointmentRow[]> {
const out: Record<string, AppointmentRow[]> = {}
for (const row of db.appointments.all()) {
if (row.status === "cancelled") continue
const key = row.at.toISOString().slice(0, 10)
;(out[key] ??= []).push(toRow(row))
}
for (const key of Object.keys(out)) out[key].sort((a, b) => a.at.getTime() - b.at.getTime())
return out
}
/** Midnight UTC on the reference day: the day the calendar opens on. */
export const TODAY = new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate()))
export type ProcedureShare = { name: string; patients: number; share: number }
/** The five procedures booked most often — done or still to come — with each one's share of all of them. */
export function topProcedures(): ProcedureShare[] {
const counts = new Map<string, number>()
for (const row of db.appointments.all()) {
if (row.status === "cancelled" || !row.procedure) continue
counts.set(row.procedure, (counts.get(row.procedure) ?? 0) + 1)
}
const total = [...counts.values()].reduce((sum, count) => sum + count, 0)
return [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 5)
.map(([name, patients]) => ({ name, patients, share: total === 0 ? 0 : patients / total }))
}
/** Everything still booked from now on, soonest first. */
export function upcomingAppointments(): AppointmentRow[] {
return db.appointments
.all()
.filter((row) => row.status === "scheduled" && row.at.getTime() >= NOW)
.sort((a, b) => a.at.getTime() - b.at.getTime())
.map(toRow)
}
/** The six procedures finished most recently. */
export function recentProcedures(): AppointmentRow[] {
return db.appointments
.all()
.filter((row) => row.status === "done" && row.procedure)
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(toRow)
}
export type ClinicNote = { id: string; text: string; at: Date }
// The desk's notes, seeded once. Not an entity: nothing else reads them, and
// a list this page alone appends to has no repository to swap.
const NOTE_TEXTS = [
"Dr. Osei's surgery moved to 10 AM",
"Staff meeting at 2 PM in the boardroom",
"New patient orientation packs restocked",
"Inventory check on the second floor",
"Annual flu clinic opens next Monday",
"Pediatrics waiting room repainted",
]
const NOTES: ClinicNote[] = NOTE_TEXTS.map((text, index) => ({
id: `note_${String(index + 1).padStart(3, "0")}`,
text,
at: new Date(NOW - (index * 4 + 1 + Math.floor(rand() * 3)) * DAY_MS),
}))
/** The notes, newest first. */
export function clinicNotes(): ClinicNote[] {
return [...NOTES].sort((a, b) => b.at.getTime() - a.at.getTime())
}
/** Appends a note dated now. Only the server action calls this; the page reads `clinicNotes`. */
export function pushNote(text: string): ClinicNote {
const note = { id: `note_${String(NOTES.length + 1).padStart(3, "0")}`, text, at: REFERENCE_DATE }
NOTES.push(note)
return note
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { asString, type Result } from "@/lib/sample-data"
import { pushNote, type ClinicNote } from "./data"
/**
* What this page changes on its own: the session and the desk's notes. The
* check-in is the Clinic dashboard's, shared by every page that moves a
* visit, and lives in `@/lib/dashboards/clinic/actions`.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
const MAX_NOTE = 160
/** Pins a note to the desk. Blank is refused; so is an essay. */
export async function addNote(text: string): Promise<Result<ClinicNote>> {
const trimmed = asString(text).trim()
if (!trimmed) {
return { ok: false, error: { code: "required", message: "Write a note before adding it.", field: "text" } }
}
if (trimmed.length > MAX_NOTE) {
return {
ok: false,
error: { code: "too_long", message: `Keep a note under ${MAX_NOTE} characters.`, field: "text" },
}
}
return { ok: true, data: pushNote(trimmed) }
}"use client"
import { LogInIcon } from "lucide-react"
import { VISIT_DATE, VISIT_STATUS_LABELS, VISIT_STATUS_MAP, VISIT_TIME } from "@/lib/dashboards/clinic/vocabulary"
import {
DataTableColumnHeader,
DataTableRowActions,
type DataTableColumnDef,
} from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { type AppointmentRow } from "../data"
/** Midnight UTC on the day `date` falls on — the clinic keeps UTC. */
const utcDay = (date: Date) => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
/**
* The columns of the upcoming book. Sorting is the table's own, over the rows
* the page handed it; a visit due today carries a menu whose one entry asks
* the server to check the patient in — a visit on a later day carries none,
* because the server would refuse it.
*/
export function appointmentColumns(
onCheckIn: (row: AppointmentRow) => void,
today: Date
): DataTableColumnDef<AppointmentRow>[] {
return [
{
accessorKey: "patient",
header: ({ column }) => <DataTableColumnHeader column={column} title="Patient" />,
cell: ({ row }) => (
<UserCell
size="sm"
name={row.original.patient}
src={row.original.avatarUrl}
description={row.original.isNewPatient ? "New patient" : undefined}
/>
),
meta: { label: "Patient" },
},
{
accessorKey: "at",
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
cell: ({ row }) => <span className="tabular-nums whitespace-nowrap">{VISIT_DATE.format(row.original.at)}</span>,
meta: { label: "Date" },
},
{
id: "time",
accessorFn: (row) => row.at.getTime(),
header: ({ column }) => <DataTableColumnHeader column={column} title="Time" />,
cell: ({ row }) => (
<span className="tabular-nums whitespace-nowrap text-muted-foreground">{VISIT_TIME.format(row.original.at)}</span>
),
meta: { label: "Time" },
},
{
accessorKey: "doctor",
header: ({ column }) => <DataTableColumnHeader column={column} title="Doctor" />,
cell: ({ row }) => <span className="whitespace-nowrap">{row.original.doctor}</span>,
meta: { label: "Doctor" },
},
{
accessorKey: "department",
header: ({ column }) => <DataTableColumnHeader column={column} title="Department" />,
cell: ({ row }) => <span className="text-muted-foreground">{row.original.department}</span>,
meta: { label: "Department" },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => (
<StatusBadge status={row.original.status} label={VISIT_STATUS_LABELS[row.original.status]} map={VISIT_STATUS_MAP} />
),
meta: { label: "Status" },
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
enableSorting: false,
enableHiding: false,
cell: ({ row }) =>
utcDay(row.original.at) === utcDay(today) ? (
<DataTableRowActions
label={`Open menu for ${row.original.patient}`}
actions={[{ label: "Check in", icon: <LogInIcon />, onSelect: () => onCheckIn(row.original) }]}
/>
) : null,
meta: { align: "right", label: "Actions" },
},
]
}"use client"
import * as React from "react"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DataTable } from "@/components/ui/data-table"
import { checkInAppointment } from "@/lib/dashboards/clinic/actions"
import { type AppointmentRow } from "../data"
import { appointmentColumns } from "./appointment-columns"
/**
* Everything still booked, soonest first. The rows arrive from the server
* page; this island owns them from then on, so a check-in the server accepts
* changes the row's pill without a second read. The check-in is the Clinic
* dashboard's own action, shared with the appointments page. The server's answer is
* printed here either way — a refusal in an alert above the rows, a success
* in the status line under them — and the two never share a slot.
*/
export function AppointmentsTable({ rows: initial, today }: { rows: AppointmentRow[]; today: Date }) {
// The title names the table too, so a screen reader announces it by name.
const titleId = React.useId()
const [rows, setRows] = React.useState(initial)
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
// Only setters are closed over, which never change, so the columns holding
// this are built once rather than on every keystroke in the search box.
const arrive = React.useCallback(async (row: AppointmentRow) => {
setNotice("")
setRefusal("")
const result = await checkInAppointment(row.id)
// A refusal carries the visit as the store holds it: the row shows that, not the state that was refused.
const stored = result.ok ? result.data : result.current
if (stored) setRows((current) => current.map((entry) => (entry.id === row.id ? { ...entry, status: stored.status } : entry)))
if (!result.ok) return setRefusal(result.error.message)
setNotice(`${result.data.patient} is checked in.`)
}, [])
const columns = React.useMemo(() => appointmentColumns(arrive, today), [arrive, today])
return (
<Card data-widget="widget-clinic-overview-appointments-table" role="region" aria-label="Upcoming appointments" className="h-full">
<CardHeader>
<CardTitle id={titleId}>Upcoming appointments</CardTitle>
<CardDescription>Everything booked from now on.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<DataTable
aria-labelledby={titleId}
columns={columns}
data={rows}
searchKey="patient"
searchPlaceholder="Search patients"
enableRowSelection={false}
getRowId={(row) => row.id}
pageSize={8}
pageSizeOptions={[8, 16, 32]}
size="sm"
emptyMessage="Nothing booked matches that name."
/>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
</CardContent>
</Card>
)
}"use client"
import * as React from "react"
import { VISIT_STATUS_LABELS, VISIT_STATUS_MAP, VISIT_TIME } from "@/lib/dashboards/clinic/vocabulary"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { MiniCalendar, type MiniCalendarMark } from "@/components/ui/mini-calendar"
import { StatusBadge } from "@/components/ui/status-badge"
import { type AppointmentRow } from "../data"
export type ClinicCalendarProps = {
/** Every visit, keyed by its UTC day. */
byDay: Record<string, AppointmentRow[]>
/** Midnight UTC on the reference day — the day the calendar opens on. */
today: Date
}
const FULL_DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "full", timeZone: "UTC" })
/**
* The month, marked with how many visits each day carries, and the picked
* day's visits under it. The marks are counts rather than colours: three
* dots are three visits, and the day's own name reads the number out.
*/
export function ClinicCalendar({ byDay, today }: ClinicCalendarProps) {
const [picked, setPicked] = React.useState<Date | undefined>(today)
const marks = React.useMemo(() => {
const out: Record<string, MiniCalendarMark> = {}
for (const [key, rows] of Object.entries(byDay)) out[key] = { count: rows.length, tone: "brand" }
return out
}, [byDay])
const key = picked?.toISOString().slice(0, 10)
const rows = key ? (byDay[key] ?? []) : []
return (
<Card data-widget="widget-clinic-overview-clinic-calendar" role="region" aria-label="Calendar" className="h-full">
<CardHeader>
<CardTitle>Calendar</CardTitle>
<CardDescription>Visits by day; pick one to see who is booked.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<MiniCalendar
month={today}
value={picked}
onValueChange={setPicked}
marks={marks}
today={today}
weekStartsOn={1}
className="w-full"
/>
<div
role="region"
aria-label="Appointments on the picked day"
className="flex flex-col gap-1"
>
<p className="type-eyebrow">{picked ? FULL_DAY.format(picked) : "Pick a day"}</p>
{rows.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">No appointments that day</p>
) : (
<DataList divided>
{rows.map((row) => (
<DataListItem
key={row.id}
leading={
<Avatar size="sm">
{row.avatarUrl ? <AvatarImage src={row.avatarUrl} alt="" /> : null}
<AvatarFallback>{row.initials}</AvatarFallback>
</Avatar>
}
title={row.patient}
description={`${row.doctor} · ${row.department}`}
meta={<span className="tabular-nums">{VISIT_TIME.format(row.at)}</span>}
actions={
<StatusBadge
status={row.status}
label={VISIT_STATUS_LABELS[row.status]}
map={VISIT_STATUS_MAP}
size="sm"
/>
}
/>
))}
</DataList>
)}
</div>
</CardContent>
</Card>
)
}"use client"
import * as React from "react"
import { PlusIcon } from "lucide-react"
import { VISIT_DATE } from "@/lib/dashboards/clinic/vocabulary"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { Input } from "@/components/ui/input"
import { addNote } from "../actions"
import { type ClinicNote } from "../data"
/**
* The desk's pinboard. The notes arrive from the server page; this island
* owns them from then on, so an accepted note is on the list before the
* field has cleared. A blank one is refused where the field is.
*/
export function ClinicNotes({ notes: initial }: { notes: ClinicNote[] }) {
const [notes, setNotes] = React.useState(initial)
const [draft, setDraft] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const [pending, setPending] = React.useState(false)
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setRefusal("")
setPending(true)
const result = await addNote(draft)
setPending(false)
if (!result.ok) return setRefusal(result.error.message)
setNotes((current) => [result.data, ...current])
setDraft("")
}
return (
<Card data-widget="widget-clinic-overview-clinic-notes" role="region" aria-label="Notes" className="h-full">
<CardHeader>
<CardTitle>Notes</CardTitle>
<CardDescription>What the desk needs to remember.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<DataList divided>
{notes.map((note) => (
<DataListItem
key={note.id}
title={note.text}
wrapTitle
meta={<span className="tabular-nums">{VISIT_DATE.format(note.at)}</span>}
/>
))}
</DataList>
<form onSubmit={submit} noValidate className="flex flex-col gap-2">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<div className="flex items-center gap-2">
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Add a new note"
aria-label="New note"
aria-invalid={refusal ? true : undefined}
className="flex-1"
/>
<Button type="submit" variant="outline" disabled={pending}>
<PlusIcon data-icon="inline-start" />
Add
</Button>
</div>
</form>
</CardContent>
</Card>
)
}import * as React from "react"
import { CalendarCheckIcon, ReceiptIcon, StethoscopeIcon, UserRoundPlusIcon } from "lucide-react"
import { StatCard, type StatCardProps } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type ClinicStat } from "../data"
// Each tile in its own tint, with the icon that names it, so the desk finds
// this month's revenue by the amber frame before reading a word.
const TILE: Record<ClinicStat["id"], { tone: NonNullable<StatCardProps["tone"]>; icon: React.ReactNode }> = {
appointments: { tone: "info", icon: <CalendarCheckIcon /> },
patients: { tone: "success", icon: <UserRoundPlusIcon /> },
procedures: { tone: "warning", icon: <StethoscopeIcon /> },
revenue: { tone: "danger", icon: <ReceiptIcon /> },
}
/** The last thirty days in four numbers. No state, so the tiles render on the server. */
export function ClinicStats({ stats }: { stats: ClinicStat[] }) {
return (
<StatCardGroup data-widget="widget-clinic-overview-clinic-stats" columns={4}>
{stats.map((stat) => (
<StatCard
key={stat.id}
id={stat.id}
label={stat.label}
value={stat.value}
delta={stat.delta}
description={stat.description}
tone={TILE[stat.id].tone}
icon={TILE[stat.id].icon}
/>
))}
</StatCardGroup>
)
}"use client"
import { formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { DonutChart } from "@/components/ui/donut-chart"
/** Distinct patients per department, with the legend the donut alone cannot carry. */
export function DepartmentDonut({ data }: { data: { name: string; value: number }[] }) {
const total = data.reduce((sum, slice) => sum + slice.value, 0)
return (
<ChartCard
data-widget="widget-clinic-overview-department-donut"
role="region"
aria-label="Patients by department"
title="Patients by department"
description="Everyone the clinic has seen, by where."
height={260}
className="h-full"
>
<DonutChart
data={data}
height={260}
innerRadius={60}
centerLabel="Patients"
centerValue={formatNumber(total)}
valueFormatter={(value) => formatNumber(value)}
/>
</ChartCard>
)
}import { VISIT_DATE } from "@/lib/dashboards/clinic/vocabulary"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { UserCell } from "@/components/ui/user-cell"
import { type AppointmentRow } from "../data"
/** The six procedures finished most recently, newest first. */
export function RecentProcedures({ rows }: { rows: AppointmentRow[] }) {
return (
<Card data-widget="widget-clinic-overview-recent-procedures" role="region" aria-label="Recent procedures" className="h-full">
<CardHeader>
<CardTitle>Recent procedures</CardTitle>
<CardDescription>Who was seen for what, newest first.</CardDescription>
</CardHeader>
<CardContent>
<DataList divided>
{rows.map((row) => (
<DataListItem
key={row.id}
title={<UserCell name={row.patient} email={row.email} src={row.avatarUrl} size="sm" />}
meta={
<span className="flex flex-col items-end gap-0.5">
<span className="text-sm font-medium text-foreground">{row.procedure}</span>
<span className="tabular-nums">{VISIT_DATE.format(row.at)}</span>
</span>
}
/>
))}
</DataList>
</CardContent>
</Card>
)
}import { formatNumber, formatPercent } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { PercentageBar } from "@/components/ui/percentage-bar"
import { Progress } from "@/components/ui/progress"
import { type ProcedureShare } from "../data"
/**
* The five procedures done most often: one segmented bar for the split, then
* a row per procedure with its count, its share and its own bar. No state,
* so it renders on the server.
*/
export function TopProcedures({ procedures }: { procedures: ProcedureShare[] }) {
const total = procedures.reduce((sum, row) => sum + row.patients, 0)
return (
<Card data-widget="widget-clinic-overview-top-procedures" role="region" aria-label="Top procedures" className="h-full">
<CardHeader>
<CardTitle>Top procedures</CardTitle>
<CardDescription>{`${formatNumber(total)} booked across the five most common.`}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<PercentageBar
segments={procedures.map((row) => ({ label: row.name, value: row.patients }))}
showLegend={false}
height="sm"
/>
<ul className="flex flex-col gap-3">
{procedures.map((row, index) => (
<li key={row.name} data-slot="procedure-row" className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="flex min-w-0 items-baseline gap-2">
<span className="w-4 shrink-0 text-right text-xs tabular-nums text-muted-foreground">{index + 1}</span>
<span className="truncate font-medium">{row.name}</span>
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{`${formatNumber(row.patients)} patients · ${formatPercent(row.share, { maximumFractionDigits: 0 })}`}
</span>
</div>
<Progress value={Math.round(row.share * 100)} aria-label={`${row.name} share`} />
</li>
))}
</ul>
</CardContent>
</Card>
)
}"use client"
import * as React from "react"
import { formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { LineChart } from "@/components/ui/line-chart"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { type VisitPoint } from "../data"
export type VisitsChartProps = {
/** The series per year, the current year counted off the rows and the year before seeded. */
byYear: Record<string, VisitPoint[]>
departments: string[]
/** "Six months to September" — the window, without the year the select adds. */
windowFor: Record<string, string>
}
/**
* Visits by department over six months. The year is client state; the
* series for both years arrive computed from the server page, so turning the
* select back a year re-binds the chart rather than fetching anything.
*/
export function VisitsChart({ byYear, departments, windowFor }: VisitsChartProps) {
const years = Object.keys(byYear).sort((a, b) => Number(b) - Number(a))
const [year, setYear] = React.useState(years[0])
return (
<ChartCard
data-widget="widget-clinic-overview-visits-chart"
title="Visits by department"
description={windowFor[year]}
height={260}
className="h-full"
actions={
<Select value={year} onValueChange={(next) => setYear(String(next))}>
<SelectTrigger size="sm" aria-label="Year" className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{years.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
}
>
<LineChart
data={byYear[year]}
index="month"
series={departments.map((department) => ({ key: department, label: department }))}
valueFormatter={(value) => formatNumber(value)}
height={260}
dots
/>
</ChartCard>
)
}