Skip to contentVibraUI
Part of the Clinic dashboardinstalls at /clinic

Clinic 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.

Open the live page

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

Install

npx shadcn@latest add @vibra/clinic-overview

Needs the @vibra registry in your components.json — set it up once.

Source

app/clinic/page.tsx
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>
  )
}
app/clinic/data.ts
/**
 * 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 }
}
app/clinic/actions.ts
"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) }
}
app/clinic/components/appointment-columns.tsx
"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" },
    },
  ]
}
app/clinic/components/appointments-table.tsx
"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>
  )
}
app/clinic/components/clinic-calendar.tsx
"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>
  )
}
app/clinic/components/clinic-notes.tsx
"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>
  )
}
app/clinic/components/clinic-stats.tsx
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>
  )
}
app/clinic/components/department-donut.tsx
"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>
  )
}
app/clinic/components/recent-procedures.tsx
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>
  )
}
app/clinic/components/top-procedures.tsx
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>
  )
}
app/clinic/components/visits-chart.tsx
"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>
  )
}