Upcoming appointments
Everything booked from now on, soonest first and searchable by patient, a visit due today checked in from its row through the server; reads upcomingAppointments().
Preview
"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>
)
}Install
$
npx shadcn@latest add @vibra/widget-clinic-overview-appointments-tableNeeds the @vibra registry in your components.json — set it up once.
Source
"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>
)
}/**
* 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 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" },
},
]
}Its page
On its page the card sits among the rest of the dashboard and shares its range and its data with them.
From the Clinic dashboard page