/clinic/appointmentsAppointment book
Every visit in the clinic's book: the patient with a face, the doctor, the department, when and for how long, the procedure and where it stands — from today, the past or all of it, with a facet on department, doctor and status, four figures, and a check-in or a cancellation on every visit still booked.
Installs at /clinic/appointments. The page is a server component: it reads every visit through db.appointments and hands the rows, with REFERENCE_DATE as now, to the island — the book is small enough to search, filter, sort and page in the browser. The windows split it at today's UTC midnight: from today, soonest first; the past, latest first; or all of it. The table's own facets narrow it by department, doctor and status, and the four figures — today's visits, what is booked from now on in hours of clinic time, the month's visits and new patients, and the month's cancellations — are counted off the same rows, so they move with them. A visit still booked carries a menu with the Clinic dashboard's own verbs from actions-clinic, the ones the overview calls too: check in on the visit's own day, and cancel on any, behind a confirmation; every other visit carries none. An accepted action rewrites its row, and so does a refused one, to the status the store reports; a success is said in the status line and a refusal in its own alert above the rows. The words come from vocabulary-clinic. Composes AppShell, PageHeader, StatCardGroup, StatCard, QuickFilters, DataTable, DataTableColumnHeader, DataTableRowActions, UserCell, StatusBadge, Callout and ConfirmDialog.
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 { AppointmentsBook } from "./components/appointments-book"
import { currentUser, lastUpdated, shellNotifications, visitRows } from "./data"
/**
* The clinic's book. The page is a server component: it reads every visit
* through `db.appointments` and hands the rows, and "now", to the island that
* searches, filters and acts on them.
*/
export default function AppointmentsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.appointments}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="clinic-appointments-title"
title="Appointments"
description="Every visit in the book: who, with whom, when, and where it stands."
meta={lastUpdated()}
/>
<AppointmentsBook titleId="clinic-appointments-title" rows={visitRows()} now={REFERENCE_DATE} />
</AppShell>
)
}Install
npx shadcn@latest add @vibra/clinic-appointmentsNeeds 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 { AppointmentsBook } from "./components/appointments-book"
import { currentUser, lastUpdated, shellNotifications, visitRows } from "./data"
/**
* The clinic's book. The page is a server component: it reads every visit
* through `db.appointments` and hands the rows, and "now", to the island that
* searches, filters and acts on them.
*/
export default function AppointmentsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.appointments}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="clinic-appointments-title"
title="Appointments"
description="Every visit in the book: who, with whom, when, and where it stands."
meta={lastUpdated()}
/>
<AppointmentsBook titleId="clinic-appointments-title" rows={visitRows()} now={REFERENCE_DATE} />
</AppShell>
)
}/**
* What this page reads: every visit in `db.appointments`, as the rows the
* book prints. The book is small enough to hand to the table whole, so the
* table searches, filters, sorts and pages it in the browser, and the figures
* above are counted off the same rows there — a check-in or a cancellation
* moves them in the one render. "Now" is `REFERENCE_DATE`, handed to the
* island rather than read off the browser's clock.
*
* The client islands import only the types below; `db` never crosses into
* the browser.
*/
import { formatDate, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Appointment, type Member } from "@/lib/sample-data"
/** One visit as the book prints it. */
export type VisitRow = {
id: string
patient: string
avatarUrl?: string
email: string
doctor: string
department: Appointment["department"]
at: Date
durationMin: number
status: Appointment["status"]
procedure?: string
isNewPatient: boolean
}
function toRow(row: Appointment): VisitRow {
return {
id: row.id,
patient: row.patient,
avatarUrl: row.avatarUrl,
email: row.email,
doctor: row.doctor,
department: row.department,
at: row.at,
durationMin: row.durationMin,
status: row.status,
procedure: row.procedure,
isNewPatient: row.isNewPatient,
}
}
/** Every visit in the book, oldest first. */
export function visitRows(): VisitRow[] {
return db.appointments
.all()
.sort((a, b) => a.at.getTime() - b.at.getTime())
.map(toRow)
}
/** 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 on its own: the session. The check-in and the
* cancellation on every row are the Clinic dashboard's own actions, shared
* with the overview, and live in `@/lib/dashboards/clinic/actions`.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}"use client"
import * as React from "react"
import type { SortingState } from "@tanstack/react-table"
import {
cancelAppointment,
checkInAppointment,
type VisitAnswer,
type VisitChange,
} from "@/lib/dashboards/clinic/actions"
import {
DEPARTMENTS,
VISIT_DAY,
VISIT_STATUS_LABELS,
VISIT_STATUS_ORDER,
VISIT_TIME,
} from "@/lib/dashboards/clinic/vocabulary"
import { Callout } from "@/components/ui/callout"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { DataTable } from "@/components/ui/data-table"
import { QuickFilters } from "@/components/ui/quick-filters"
import { type VisitRow } from "../data"
import { visitColumns } from "./visit-columns"
import { VisitFigures } from "./visit-figures"
import { WINDOWS, inWindow, type VisitWindow } from "./visit-vocabulary"
// A window brings its own order: the book ahead soonest first, the past latest first.
const WINDOW_SORT: Record<VisitWindow, SortingState> = {
ahead: [{ id: "at", desc: false }],
past: [{ id: "at", desc: true }],
all: [{ id: "at", desc: false }],
}
export type AppointmentsBookProps = {
/** The page's h1 id, which names the table. */
titleId: string
rows: VisitRow[]
/** REFERENCE_DATE, from the page: the island never reads the browser's clock. */
now: Date
}
/**
* The book. The rows arrive whole from the server page and this island owns
* them from then on: the window, the search, the three facets, the sort and
* the pager are the table's, and a check-in or a cancellation the server
* accepts rewrites the row it moved — its pill, and the figures above,
* follow in the same render. A refusal rewrites it too, to the status the
* store says it has, so the book never keeps offering a visit the desk was
* just told has moved on. A success is said in the status line and a refusal
* in its own alert; the two never share a slot.
*/
export function AppointmentsBook({ titleId, rows: initial, now }: AppointmentsBookProps) {
const [rows, setRows] = React.useState(initial)
const [range, setRange] = React.useState<VisitWindow>("ahead")
const [sorting, setSorting] = React.useState<SortingState>(WINDOW_SORT.ahead)
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const [cancelling, setCancelling] = React.useState<VisitRow | null>(null)
const run = React.useCallback(
async (verb: (id: string) => Promise<VisitAnswer>, row: VisitRow, say: (visit: VisitChange) => string) => {
setNotice("")
setRefusal("")
const result = await verb(row.id)
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(say(result.data))
},
[]
)
// Only setters and the stable `run` are closed over, so the columns are
// built once rather than on every keystroke in the search box.
const columns = React.useMemo(
() =>
visitColumns(
{
onCheckIn: (row) => run(checkInAppointment, row, (visit) => `${visit.patient} is checked in.`),
onCancel: setCancelling,
},
now
),
[run, now]
)
const shown = React.useMemo(() => rows.filter((row) => inWindow(row, range, now)), [rows, range, now])
const doctors = React.useMemo(() => [...new Set(rows.map((row) => row.doctor))].sort(), [rows])
return (
<div data-slot="appointments-book" className="flex flex-col gap-4">
<VisitFigures rows={rows} now={now} />
<div className="flex flex-col gap-3">
<QuickFilters
aria-label="Appointments by window"
options={WINDOWS.map((entry) => ({ ...entry, count: rows.filter((row) => inWindow(row, entry.value, now)).length }))}
value={range}
onValueChange={(value) => {
setRange(value as VisitWindow)
setSorting(WINDOW_SORT[value as VisitWindow])
}}
/>
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
{/* A line held open even when empty, so a message arriving never pushes the rows down. */}
<p role="status" aria-live="polite" className="-my-1 min-h-4 text-xs text-muted-foreground">
{notice}
</p>
<DataTable
aria-labelledby={titleId}
columns={columns}
data={shown}
getRowId={(row) => row.id}
searchKey="patient"
searchPlaceholder="Search patients"
enableRowSelection={false}
state={{ sorting }}
onSortingChange={setSorting}
facets={[
{ columnId: "department", title: "Department", options: DEPARTMENTS.map((value) => ({ value, label: value })) },
{ columnId: "doctor", title: "Doctor", options: doctors.map((value) => ({ value, label: value })) },
{ columnId: "status", title: "Status", options: VISIT_STATUS_ORDER.map((value) => ({ value, label: VISIT_STATUS_LABELS[value] })) },
]}
pageSize={10}
pageSizeOptions={[10, 25, 50]}
emptyMessage="No visit matches these filters."
/>
</div>
<ConfirmDialog
variant="destructive"
open={cancelling !== null}
onOpenChange={(open) => {
if (!open) setCancelling(null)
}}
title={cancelling ? `Cancel ${cancelling.patient}'s visit?` : "Cancel this visit?"}
description={
cancelling
? `${cancelling.doctor}, ${VISIT_DAY.format(cancelling.at)} at ${VISIT_TIME.format(cancelling.at)}. The slot goes back into the book; a cancelled visit cannot be restored.`
: undefined
}
confirmText="Cancel appointment"
cancelText="Keep appointment"
onConfirm={() =>
cancelling
? run(cancelAppointment, cancelling, (visit) => `${visit.patient}'s visit with ${visit.doctor} is cancelled.`)
: undefined
}
/>
</div>
)
}"use client"
import { LogInIcon, XCircleIcon } from "lucide-react"
import { VISIT_DAY, 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 VisitRow } from "../data"
import { utcDay } from "./visit-vocabulary"
export type VisitVerbs = {
onCheckIn: (row: VisitRow) => void
onCancel: (row: VisitRow) => void
}
/**
* The book's columns. Sorting and the three facets are the table's own, over
* the rows the page handed it. A visit still booked carries a menu with the
* desk's verbs the server would accept — cancel on any day, check in only on
* the day itself — and any other visit carries none.
*/
export function visitColumns(verbs: VisitVerbs, now: Date): DataTableColumnDef<VisitRow>[] {
const today = utcDay(now)
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" : row.original.email}
/>
),
meta: { label: "Patient" },
},
{
accessorKey: "at",
header: ({ column }) => <DataTableColumnHeader column={column} title="When" />,
cell: ({ row }) => (
<span className="flex flex-col whitespace-nowrap">
<span className="tabular-nums">{VISIT_DAY.format(row.original.at)}</span>
<span className="text-xs tabular-nums text-muted-foreground">{VISIT_TIME.format(row.original.at)}</span>
</span>
),
meta: { label: "When" },
},
{
accessorKey: "durationMin",
header: ({ column }) => <DataTableColumnHeader column={column} title="Length" />,
cell: ({ row }) => <span className="whitespace-nowrap tabular-nums">{`${row.original.durationMin} min`}</span>,
meta: { align: "right", label: "Length" },
},
{
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" },
},
{
id: "procedure",
accessorFn: (row) => row.procedure ?? "Consultation",
header: ({ column }) => <DataTableColumnHeader column={column} title="Procedure" />,
cell: ({ row }) =>
row.original.procedure ? (
<span className="whitespace-nowrap">{row.original.procedure}</span>
) : (
<span className="text-muted-foreground">Consultation</span>
),
meta: { label: "Procedure" },
},
{
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 }) =>
row.original.status === "scheduled" ? (
<DataTableRowActions
label={`Open menu for ${row.original.patient} on ${VISIT_DAY.format(row.original.at)}`}
actions={[
...(utcDay(row.original.at) === today
? [{ label: "Check in", icon: <LogInIcon />, onSelect: () => verbs.onCheckIn(row.original) }]
: []),
{
label: "Cancel appointment",
icon: <XCircleIcon />,
destructive: true,
separatorBefore: true,
onSelect: () => verbs.onCancel(row.original),
},
]}
/>
) : null,
meta: { align: "right", label: "Actions" },
},
]
}import { CalendarCheckIcon, CalendarClockIcon, CalendarDaysIcon, CalendarXIcon } from "lucide-react"
import { formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type VisitRow } from "../data"
import { utcDay } from "./visit-vocabulary"
const inMonth = (at: Date, now: Date) => at.getUTCFullYear() === now.getUTCFullYear() && at.getUTCMonth() === now.getUTCMonth()
const hours = (minutes: number) => `${formatNumber(minutes / 60, { maximumFractionDigits: 1 })} h`
/**
* The book in four numbers, counted off the rows the table holds: today's
* visits, what is booked from now on, the month's visits and the month's
* cancellations. The rows are the island's own, so a check-in or a
* cancellation moves these in the same render as the row.
*/
export function VisitFigures({ rows, now }: { rows: VisitRow[]; now: Date }) {
const today = rows.filter((row) => utcDay(row.at) === utcDay(now) && row.status !== "cancelled")
const ahead = rows.filter((row) => row.status === "scheduled" && row.at.getTime() >= now.getTime())
const month = rows.filter((row) => inMonth(row.at, now))
const held = month.filter((row) => row.status !== "cancelled")
const cancelled = month.length - held.length
return (
<StatCardGroup data-widget="widget-clinic-appointments-visit-figures" columns={4} role="region" aria-label="The book in figures">
<StatCard
label="Today"
value={formatNumber(today.length)}
description={`${today.filter((row) => row.status === "checked_in").length} checked in · ${today.filter((row) => row.status === "done").length} done`}
icon={<CalendarCheckIcon />}
/>
<StatCard
label="Booked ahead"
value={formatNumber(ahead.length)}
description={`${hours(ahead.reduce((sum, row) => sum + row.durationMin, 0))} of clinic time`}
icon={<CalendarClockIcon />}
/>
<StatCard
label="This month"
value={formatNumber(held.length)}
description={`${held.filter((row) => row.isNewPatient).length} new patients`}
icon={<CalendarDaysIcon />}
/>
<StatCard
label="Cancelled"
value={formatPercent(month.length === 0 ? 0 : cancelled / month.length, { maximumFractionDigits: 1 })}
description={`${cancelled} of ${month.length} this month`}
icon={<CalendarXIcon />}
/>
</StatCardGroup>
)
}import { type VisitRow } from "../data"
/**
* The windows this page cuts the book into, and the rule each one reads.
* The words for a visit — its status, its day and time, the departments —
* are the Clinic dashboard's, in `@/lib/dashboards/clinic/vocabulary`.
*/
export const WINDOWS = [
{ value: "ahead", label: "From today" },
{ value: "past", label: "Past" },
{ value: "all", label: "All" },
] as const
export type VisitWindow = (typeof WINDOWS)[number]["value"]
/** Midnight UTC on the day `date` falls on. */
export const utcDay = (date: Date) => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
/** Which visits a window holds: from today on, before today, or every one. */
export function inWindow(row: VisitRow, window: VisitWindow, now: Date): boolean {
if (window === "all") return true
return window === "ahead" ? utcDay(row.at) >= utcDay(now) : utcDay(row.at) < utcDay(now)
}