Skip to contentVibraUI
Part of the Clinic dashboardinstalls at /clinic/appointments

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

Open the live page

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

Install

npx shadcn@latest add @vibra/clinic-appointments

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

Source

app/clinic/appointments/page.tsx
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>
  )
}
app/clinic/appointments/data.ts
/**
 * 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 }
}
app/clinic/appointments/actions.ts
"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 } }
}
app/clinic/appointments/components/appointments-book.tsx
"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>
  )
}
app/clinic/appointments/components/visit-columns.tsx
"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" },
    },
  ]
}
app/clinic/appointments/components/visit-figures.tsx
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>
  )
}
app/clinic/appointments/components/visit-vocabulary.ts
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)
}