/clinic/patientsPatient list
Every patient in the book, worked out of the visits under their name — visits, first and last, the departments that saw them and the next appointment — with this month's new patients in the figures, search, and a sheet with each patient's history.
Installs at /clinic/patients. A patient is a name: every db.appointments row under it is the same patient. The page is a server component; patientRows works each record out of those rows — the visits the patient was seen at (done, or checked in today; a cancellation stays on the record but was not a visit), the first and the last of them, the departments that saw them in the order they first did, and the next appointment still booked — and patientFigures counts the patients, this month's new ones (a visit this month the book flags isNewPatient, the overview's own rule), the returning and those booked ahead. The island searches, sorts and pages the records, the one seen most recently first, and a row, clicked or opened with Enter, shows the patient's history as a timeline, newest first, each visit with the status pill it carries in the book. Composes AppShell, PageHeader, StatCardGroup, StatCard, DataTable, DataTableColumnHeader, UserCell, NumberCell, InspectorPanel, DescriptionList, SectionHeader, Timeline, TimelineItem and StatusBadge.
Preview
import { NAV, ROUTES } from "@/lib/dashboards/clinic/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { PatientFigures } from "./components/patient-figures"
import { PatientsTable } from "./components/patients-table"
import { currentUser, lastUpdated, monthName, patientFigures, patientRows, shellNotifications } from "./data"
/**
* The patient list. The page is a server component: it works every patient's
* record out of `db.appointments`, counts the four figures off those records,
* and hands the records to the island that searches them and opens a history.
*/
export default function PatientsPage() {
const rows = patientRows()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.patients}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="clinic-patients-title"
title="Patients"
description="Everyone in the book: when they were seen, by whom, and what is next."
meta={lastUpdated()}
/>
<div className="flex flex-col gap-4">
<PatientFigures figures={patientFigures(rows)} month={monthName()} />
<PatientsTable titleId="clinic-patients-title" rows={rows} />
</div>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/clinic-patientsNeeds the @vibra registry in your components.json — set it up once.
Source
import { NAV, ROUTES } from "@/lib/dashboards/clinic/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { PatientFigures } from "./components/patient-figures"
import { PatientsTable } from "./components/patients-table"
import { currentUser, lastUpdated, monthName, patientFigures, patientRows, shellNotifications } from "./data"
/**
* The patient list. The page is a server component: it works every patient's
* record out of `db.appointments`, counts the four figures off those records,
* and hands the records to the island that searches them and opens a history.
*/
export default function PatientsPage() {
const rows = patientRows()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.patients}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="clinic-patients-title"
title="Patients"
description="Everyone in the book: when they were seen, by whom, and what is next."
meta={lastUpdated()}
/>
<div className="flex flex-col gap-4">
<PatientFigures figures={patientFigures(rows)} month={monthName()} />
<PatientsTable titleId="clinic-patients-title" rows={rows} />
</div>
</AppShell>
)
}/**
* What this page reads. A patient is a name: every `db.appointments` row
* under the same name is the same patient, and `patientRows` works each one's
* record out of those rows — the visits they were seen at (done, or checked in
* today), the first and the last of them, the departments that saw them, and
* the next appointment still booked. A cancelled visit stays on the record
* but was not a visit. "New this month" is the book's own word for it: a
* visit this month flagged `isNewPatient`. "Now" is `REFERENCE_DATE`.
*
* The client islands import only the types below.
*/
import { formatDate, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Appointment, type Member } from "@/lib/sample-data"
/** One appointment on a patient's record. */
export type PatientVisit = {
id: string
at: Date
doctor: string
department: Appointment["department"]
durationMin: number
status: Appointment["status"]
procedure?: string
}
export type PatientRow = {
/** The patient is the name. */
name: string
avatarUrl?: string
/** The address on their most recent appointment. */
email: string
/** Visits they were seen at: done, or checked in. */
visits: number
firstVisit?: Date
lastVisit?: Date
/** The departments that saw them, in the order they first did. */
departments: Appointment["department"][]
nextAppointment?: { at: Date; doctor: string; department: Appointment["department"] }
newThisMonth: boolean
/** Every appointment under the name, the newest first. */
history: PatientVisit[]
}
export type PatientFigures = { patients: number; newThisMonth: number; returning: number; bookedAhead: number }
const NOW = REFERENCE_DATE.getTime()
const seen = (row: Appointment) => row.status === "done" || row.status === "checked_in"
const inMonth = (at: Date) =>
at.getUTCFullYear() === REFERENCE_DATE.getUTCFullYear() && at.getUTCMonth() === REFERENCE_DATE.getUTCMonth()
/** Every patient in the book, the one seen most recently first. */
export function patientRows(): PatientRow[] {
const byName = new Map<string, Appointment[]>()
for (const row of [...db.appointments.all()].sort((a, b) => a.at.getTime() - b.at.getTime())) {
const rows = byName.get(row.patient)
if (rows) rows.push(row)
else byName.set(row.patient, [row])
}
return [...byName.entries()]
.map(([name, rows]) => {
const visits = rows.filter(seen)
const next = rows.find((row) => row.status === "scheduled" && row.at.getTime() >= NOW)
const latest = rows.at(-1)!
return {
name,
avatarUrl: latest.avatarUrl,
email: latest.email,
visits: visits.length,
...(visits.length > 0 ? { firstVisit: visits[0].at, lastVisit: visits.at(-1)!.at } : {}),
departments: [...new Set(visits.map((row) => row.department))],
...(next ? { nextAppointment: { at: next.at, doctor: next.doctor, department: next.department } } : {}),
newThisMonth: rows.some((row) => row.isNewPatient && row.status !== "cancelled" && inMonth(row.at)),
history: [...rows].reverse().map(({ id, at, doctor, department, durationMin, status, procedure }) => ({
id,
at,
doctor,
department,
durationMin,
status,
procedure,
})),
}
})
.sort((a, b) => (b.lastVisit?.getTime() ?? -Infinity) - (a.lastVisit?.getTime() ?? -Infinity) || a.name.localeCompare(b.name))
}
/** The four numbers above the list, off the same records. */
export function patientFigures(rows: PatientRow[] = patientRows()): PatientFigures {
return {
patients: rows.length,
newThisMonth: rows.filter((row) => row.newThisMonth).length,
returning: rows.filter((row) => row.visits >= 2).length,
bookedAhead: rows.filter((row) => row.nextAppointment).length,
}
}
const MONTH = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })
/** "September": the month "new" is counted over. */
export function monthName(): string {
return MONTH.format(REFERENCE_DATE)
}
/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}
/** 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 { type Result } from "@/lib/sample-data"
/**
* What this page changes: only the session. The patient list reads; the
* desk's verbs live on the appointments page and the overview.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}"use client"
import { VISIT_DATE, VISIT_TIME } from "@/lib/dashboards/clinic/vocabulary"
import { DataTableColumnHeader, type DataTableColumnDef } from "@/components/ui/data-table"
import { NumberCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { type PatientRow } from "../data"
const day = (date: Date | undefined) =>
date ? <span className="whitespace-nowrap tabular-nums">{VISIT_DATE.format(date)}</span> : <span className="text-muted-foreground">—</span>
/**
* The list's columns. A patient never seen, or with nothing booked, sorts
* after everyone who has a date there, which is why the date columns sort on
* a number with the gap pushed to the far end.
*/
export const PATIENT_COLUMNS: DataTableColumnDef<PatientRow>[] = [
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Patient" />,
cell: ({ row }) => <UserCell size="sm" name={row.original.name} src={row.original.avatarUrl} email={row.original.email} />,
meta: { label: "Patient" },
},
{
accessorKey: "visits",
header: ({ column }) => <DataTableColumnHeader column={column} title="Visits" />,
cell: ({ row }) => <NumberCell value={row.original.visits} />,
meta: { align: "right", label: "Visits" },
},
{
id: "firstVisit",
accessorFn: (row) => row.firstVisit?.getTime() ?? Number.POSITIVE_INFINITY,
header: ({ column }) => <DataTableColumnHeader column={column} title="First visit" />,
cell: ({ row }) => day(row.original.firstVisit),
meta: { label: "First visit" },
},
{
id: "lastVisit",
accessorFn: (row) => row.lastVisit?.getTime() ?? Number.NEGATIVE_INFINITY,
header: ({ column }) => <DataTableColumnHeader column={column} title="Last visit" />,
cell: ({ row }) => day(row.original.lastVisit),
meta: { label: "Last visit" },
},
{
id: "departments",
accessorFn: (row) => row.departments.join(", "),
header: ({ column }) => <DataTableColumnHeader column={column} title="Seen in" />,
cell: ({ row }) =>
row.original.departments.length > 0 ? (
<span className="text-muted-foreground">{row.original.departments.join(", ")}</span>
) : (
<span className="text-muted-foreground">—</span>
),
meta: { label: "Seen in" },
},
{
id: "nextAppointment",
accessorFn: (row) => row.nextAppointment?.at.getTime() ?? Number.POSITIVE_INFINITY,
header: ({ column }) => <DataTableColumnHeader column={column} title="Next appointment" />,
cell: ({ row }) => {
const next = row.original.nextAppointment
if (!next) return <span className="text-muted-foreground">—</span>
return (
<span className="flex flex-col whitespace-nowrap">
<span className="tabular-nums">{`${VISIT_DATE.format(next.at)} · ${VISIT_TIME.format(next.at)}`}</span>
<span className="text-xs text-muted-foreground">{next.doctor}</span>
</span>
)
},
meta: { label: "Next appointment" },
},
]import { CalendarClockIcon, RepeatIcon, UserRoundPlusIcon, UsersIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type PatientFigures as Figures } from "../data"
/** The patient list in four numbers. No state, so the tiles render on the server. */
export function PatientFigures({ figures, month }: { figures: Figures; month: string }) {
return (
<StatCardGroup data-widget="widget-clinic-patients-patient-figures" columns={4} role="region" aria-label="Patients in figures">
<StatCard label="Patients" value={formatNumber(figures.patients)} description="Everyone in the book" icon={<UsersIcon />} />
<StatCard
label="New this month"
value={formatNumber(figures.newThisMonth)}
description={`First visits in ${month}`}
icon={<UserRoundPlusIcon />}
/>
<StatCard label="Returning" value={formatNumber(figures.returning)} description="Seen twice or more" icon={<RepeatIcon />} />
<StatCard
label="Booked ahead"
value={formatNumber(figures.bookedAhead)}
description="With a visit still to come"
icon={<CalendarClockIcon />}
/>
</StatCardGroup>
)
}"use client"
import * as React from "react"
import {
VISIT_DATE,
VISIT_STATUS_LABELS,
VISIT_STATUS_MAP,
VISIT_TIME,
} from "@/lib/dashboards/clinic/vocabulary"
import { DescriptionList } from "@/components/ui/description-list"
import { InspectorPanel } from "@/components/ui/inspector-panel"
import { SectionHeader } from "@/components/ui/section-header"
import { StatusBadge } from "@/components/ui/status-badge"
import { Timeline, TimelineItem, type TimelineTone } from "@/components/ui/timeline"
import { UserCell } from "@/components/ui/user-cell"
import { type PatientRow } from "../data"
/** A visit's dot on the history: the same tones as its status pill, done in the plain ink. */
const TIMELINE_TONE: Record<PatientRow["history"][number]["status"], TimelineTone> = {
scheduled: "info",
checked_in: "success",
done: "default",
cancelled: "danger",
}
export type PatientInspectorProps = { patient: PatientRow | null; onClose: () => void }
/**
* One patient's record, opened over the list: who they are, when they were
* first and last seen and what is booked next, then every appointment under
* their name as a history, newest first — the cancelled ones included, each
* with the status pill it carries in the book.
*/
export function PatientInspector({ patient, onClose }: PatientInspectorProps) {
// The sheet outlives the selection by one animation, so the last patient
// stays drawn while it slides out instead of blanking mid-flight.
const [last, setLast] = React.useState(patient)
if (patient && patient !== last) setLast(patient)
const shown = patient ?? last
if (!shown) return null
return (
<InspectorPanel
open={patient !== null}
mode="overlay"
width="lg"
onOpenChange={(open) => {
if (!open) onClose()
}}
title={shown.name}
description={`${shown.visits === 1 ? "1 visit" : `${shown.visits} visits`}${shown.departments.length > 0 ? ` · ${shown.departments.join(", ")}` : ""}`}
>
<div className="flex flex-col gap-6">
<UserCell name={shown.name} src={shown.avatarUrl} email={shown.email} />
<DescriptionList
size="sm"
items={[
{ term: "First visit", description: shown.firstVisit ? VISIT_DATE.format(shown.firstVisit) : "Not seen yet" },
{ term: "Last visit", description: shown.lastVisit ? VISIT_DATE.format(shown.lastVisit) : "—" },
{
term: "Next appointment",
description: shown.nextAppointment
? `${VISIT_DATE.format(shown.nextAppointment.at)}, ${VISIT_TIME.format(shown.nextAppointment.at)} · ${shown.nextAppointment.doctor}`
: "Nothing booked",
},
]}
/>
<section className="flex flex-col gap-3">
<SectionHeader as="h2" size="sm" title="Visit history" />
<Timeline aria-label={`${shown.name}'s appointments`}>
{shown.history.map((visit) => (
<TimelineItem
key={visit.id}
tone={TIMELINE_TONE[visit.status]}
title={
<span className="flex flex-wrap items-center gap-2">
<span>{visit.procedure ?? "Consultation"}</span>
<StatusBadge status={visit.status} label={VISIT_STATUS_LABELS[visit.status]} map={VISIT_STATUS_MAP} size="sm" />
</span>
}
description={`${visit.doctor} · ${visit.department} · ${visit.durationMin} min`}
time={`${VISIT_DATE.format(visit.at)}, ${VISIT_TIME.format(visit.at)}`}
/>
))}
</Timeline>
</section>
</div>
</InspectorPanel>
)
}"use client"
import * as React from "react"
import { DataTable } from "@/components/ui/data-table"
import { type PatientRow } from "../data"
import { PATIENT_COLUMNS } from "./patient-columns"
import { PatientInspector } from "./patient-inspector"
/**
* The patient list. The records arrive whole from the server page — the
* grouping of the book by name is the server's work — and the table searches,
* sorts and pages them here. A row, clicked or opened with Enter, shows the
* patient's history; the row already holds it.
*/
export function PatientsTable({ titleId, rows }: { titleId: string; rows: PatientRow[] }) {
const [open, setOpen] = React.useState<PatientRow | null>(null)
return (
<>
<DataTable
aria-labelledby={titleId}
columns={PATIENT_COLUMNS}
data={rows}
getRowId={(row) => row.name}
searchKey="name"
searchPlaceholder="Search patients"
enableRowSelection={false}
initialSorting={[{ id: "lastVisit", desc: true }]}
onRowClick={setOpen}
pageSize={10}
pageSizeOptions={[10, 25, 50]}
emptyMessage="No patient by that name."
/>
<PatientInspector patient={open} onClose={() => setOpen(null)} />
</>
)
}