{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "clinic-1",
  "title": "Clinic 1: day schedule",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A clinic's day: a column per provider, a row per quarter hour, appointments at their real time and length with status written out, one button to move a patient on, and a privacy switch that shows initials only.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/clinic-1/clinic-1.tsx",
      "content": "// Ballmac UI: Clinic 1. https://ui.ballmac.com/blocks/clinic-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, DoorOpen, Eye, EyeOff, UserCheck, UserX } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Clinic1Status = \"booked\" | \"arrived\" | \"in-room\" | \"done\" | \"no-show\"\n\ntype Clinic1Appointment = {\n  id: string\n  /** Which provider's column it is in. */\n  providerId: string\n  /** Minutes after the first slot, in steps of 15. */\n  start: number\n  /** Length in minutes. */\n  length: number\n  patient: string\n  /** For example \"Follow-up\" or \"New patient\". */\n  reason: string\n  status: Clinic1Status\n}\n\ntype Clinic1Provider = { id: string; name: string; role?: string }\n\ntype Clinic1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    providers?: Clinic1Provider[]\n    appointments?: Clinic1Appointment[]\n    title?: string\n    /** Label for the day, such as \"Friday 24 October\". */\n    day?: string\n    /** The first slot, in 24-hour time, such as \"08:00\". */\n    firstSlot?: string\n    /** How many 15-minute slots the day has. */\n    slots?: number\n    /** Show patients as initials only. Useful on a screen the waiting room can see. */\n    defaultPrivate?: boolean\n    /** Called when the front desk moves a patient on: arrived, in room, done or no-show. */\n    onStatus?: (appointment: Clinic1Appointment, status: Clinic1Status) => void | Promise<void>\n  }\n\nconst providers: Clinic1Provider[] = [\n  { id: \"p1\", name: \"Dr. Amara Okafor\", role: \"General practice\" },\n  { id: \"p2\", name: \"Dr. Jonas Weber\", role: \"General practice\" },\n  { id: \"p3\", name: \"Nurse Lina Rossi\", role: \"Vaccinations\" },\n]\nconst appts: Clinic1Appointment[] = [\n  { id: \"a1\", providerId: \"p1\", start: 0, length: 30, patient: \"Maria Lopez\", reason: \"Follow-up\", status: \"done\" },\n  { id: \"a2\", providerId: \"p1\", start: 30, length: 30, patient: \"Ken Watanabe\", reason: \"Blood pressure check\", status: \"in-room\" },\n  { id: \"a3\", providerId: \"p1\", start: 75, length: 45, patient: \"Fatima Zahra\", reason: \"New patient\", status: \"arrived\" },\n  { id: \"a4\", providerId: \"p1\", start: 150, length: 30, patient: \"Oscar Lindqvist\", reason: \"Follow-up\", status: \"booked\" },\n  { id: \"a5\", providerId: \"p2\", start: 15, length: 30, patient: \"Grace Mbeki\", reason: \"Test results\", status: \"done\" },\n  { id: \"a6\", providerId: \"p2\", start: 60, length: 30, patient: \"Ivan Petrov\", reason: \"Back pain\", status: \"no-show\" },\n  { id: \"a7\", providerId: \"p2\", start: 105, length: 30, patient: \"Chloe Martin\", reason: \"Prescription\", status: \"booked\" },\n  { id: \"a8\", providerId: \"p3\", start: 0, length: 15, patient: \"Noah Kim\", reason: \"Flu vaccine\", status: \"done\" },\n  { id: \"a9\", providerId: \"p3\", start: 30, length: 15, patient: \"Sofia Alves\", reason: \"Flu vaccine\", status: \"arrived\" },\n  { id: \"a10\", providerId: \"p3\", start: 60, length: 15, patient: \"Yusuf Demir\", reason: \"Travel vaccine\", status: \"booked\" },\n]\n\nconst STATUS: Record<Clinic1Status, { label: string; tone: string }> = {\n  booked: { label: \"Booked\", tone: \"bg-card\" },\n  arrived: { label: \"Arrived\", tone: \"bg-chart-3/15 border-chart-3/50\" },\n  \"in-room\": { label: \"In room\", tone: \"bg-chart-1/15 border-chart-1/50\" },\n  done: { label: \"Done\", tone: \"bg-muted/60 text-muted-foreground\" },\n  \"no-show\": { label: \"No-show\", tone: \"border-destructive/50 border-dashed bg-destructive/5\" },\n}\nconst NEXT: { to: Clinic1Status; label: string; icon: typeof Check; when: Clinic1Status[] }[] = [\n  { to: \"arrived\", label: \"Mark arrived\", icon: UserCheck, when: [\"booked\"] },\n  { to: \"in-room\", label: \"Send to room\", icon: DoorOpen, when: [\"arrived\"] },\n  { to: \"done\", label: \"Mark done\", icon: Check, when: [\"in-room\"] },\n  { to: \"no-show\", label: \"Mark no-show\", icon: UserX, when: [\"booked\", \"arrived\"] },\n]\n\nconst SLOT = 44\nconst clock = (first: string, minutes: number) => {\n  const [h, m] = first.split(\":\").map(Number)\n  const t = h * 60 + m + minutes\n  return `${String(Math.floor(t / 60)).padStart(2, \"0\")}:${String(t % 60).padStart(2, \"0\")}`\n}\nconst initials = (n: string) => n.split(/\\s+/).map((w) => w[0]).join(\".\").toUpperCase() + \".\"\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-3 md:grid-cols-3\">\n      {[0, 1, 2].map((i) => (\n        <Skeleton key={i} className=\"h-96 rounded-xl\" />\n      ))}\n    </div>\n  )\n}\n\n/**\n * A clinic's day: a column for each provider and a row for every quarter hour, each appointment at its real time and length, with the status written out\n * (booked, arrived, in room, done, no-show) and one button to move a patient on. A privacy switch shows initials only, for a screen the waiting room can see.\n */\nfunction Clinic1({ providers: provs = providers, appointments: apptsProp = appts, title = \"Today's schedule\", day = \"Friday 24 October\", firstSlot = \"08:00\", slots = 12, defaultPrivate = false, onStatus, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Clinic1Props) {\n  const id = React.useId()\n  const [list, setList] = React.useState<Clinic1Appointment[]>(apptsProp)\n  React.useEffect(() => setList(apptsProp), [apptsProp])\n  const [priv, setPriv] = React.useState(defaultPrivate)\n  const [sel, setSel] = React.useState<string | null>(null)\n  const [note, setNote] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const view = state === \"ready\" && list.length === 0 ? \"empty\" : state\n  const chosen = list.find((a) => a.id === sel)\n  const who = (a: Clinic1Appointment) => (priv ? initials(a.patient) : a.patient)\n  const counts = (s: Clinic1Status) => list.filter((a) => a.status === s).length\n  const hours = Array.from({ length: slots }, (_, i) => clock(firstSlot, i * 15))\n\n  async function move(a: Clinic1Appointment, to: Clinic1Status) {\n    setNote(null)\n    const before = a\n    setList((l) => l.map((x) => (x.id === a.id ? { ...x, status: to } : x)))\n    try {\n      await onStatus?.(a, to)\n      setNote({ ok: true, text: `${who(a)} is now ${STATUS[to].label.toLowerCase()}.` })\n    } catch (e) {\n      setList((l) => l.map((x) => (x.id === a.id ? before : x)))\n      setNote({ ok: false, text: e instanceof Error ? e.message : `${who(a)} could not be updated, so it is as it was.` })\n    }\n  }\n\n  return (\n    <section data-slot=\"clinic-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8 flex flex-wrap items-end justify-between gap-4\">\n        <div>\n          <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n            {title}\n          </h1>\n          <p className=\"text-muted-foreground mt-2\">{day}</p>\n        </div>\n        {view === \"ready\" && (\n          <Button variant=\"outline\" aria-pressed={priv} onClick={() => setPriv((v) => !v)}>\n            {priv ? <EyeOff aria-hidden=\"true\" /> : <Eye aria-hidden=\"true\" />} {priv ? \"Initials only\" : \"Show full names\"}\n          </Button>\n        )}\n      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the schedule\" loading={<Skel />} empty={{ title: \"No appointments today\", description: \"Booked appointments will appear in each provider's column.\", ...emptyState }} error={{ title: \"The schedule could not be loaded\", description: \"No appointment was changed. Check your connection and try again.\", ...errorState }}>\n        <p aria-live=\"polite\" className=\"text-muted-foreground mb-4 text-sm tabular-nums\">\n          {counts(\"arrived\")} waiting · {counts(\"in-room\")} in a room · {counts(\"done\")} done · {counts(\"booked\")} still to come{counts(\"no-show\") ? ` · ${counts(\"no-show\")} no-show` : \"\"}\n        </p>\n        <div className=\"overflow-x-auto rounded-xl border\">\n          <div className=\"grid min-w-[44rem]\" style={{ gridTemplateColumns: `4rem repeat(${provs.length}, minmax(0, 1fr))` }}>\n            <div className=\"bg-muted/40 border-b\" />\n            {provs.map((p) => (\n              <div key={p.id} className=\"bg-muted/40 border-s border-b px-3 py-2.5\">\n                <p className=\"text-sm font-medium\">{p.name}</p>\n                {p.role && <p className=\"text-muted-foreground text-xs\">{p.role}</p>}\n              </div>\n            ))}\n            <div>\n              {hours.map((h, i) => (\n                <div key={h} className={cn(\"text-muted-foreground flex items-start justify-end pe-2 font-mono text-[11px] tabular-nums\", i % 4 !== 0 && \"opacity-0\")} style={{ height: SLOT }} dir=\"ltr\" aria-hidden=\"true\">\n                  {h}\n                </div>\n              ))}\n            </div>\n            {provs.map((p) => (\n              <div key={p.id} role=\"group\" aria-label={`${p.name}, appointments`} className=\"relative border-s\" style={{ height: slots * SLOT }}>\n                {hours.map((h, i) => (\n                  <span key={h} aria-hidden=\"true\" className={cn(\"absolute inset-x-0 border-t\", i % 4 === 0 ? \"border-border\" : \"border-border/40\")} style={{ top: i * SLOT }} />\n                ))}\n                {list.filter((a) => a.providerId === p.id).map((a) => {\n                  const st = STATUS[a.status]\n                  const on = sel === a.id\n                  return (\n                    <button\n                      key={a.id}\n                      type=\"button\"\n                      data-status={a.status}\n                      aria-pressed={on}\n                      aria-label={`${clock(firstSlot, a.start)} to ${clock(firstSlot, a.start + a.length)}, ${who(a)}, ${a.reason}, ${st.label}`}\n                      onClick={() => { setSel(on ? null : a.id); setNote(null) }}\n                      className={cn(\"focus-visible:ring-ring/50 absolute inset-x-1 flex flex-col overflow-hidden rounded-lg border px-2 py-1 text-start text-xs outline-none transition-shadow focus-visible:z-10 focus-visible:ring-[3px] motion-reduce:transition-none\", st.tone, on && \"ring-foreground/70 z-10 ring-2\")}\n                      style={{ top: (a.start / 15) * SLOT + 2, height: (a.length / 15) * SLOT - 4 }}\n                    >\n                      <span className=\"truncate font-medium\">{who(a)}</span>\n                      {a.length >= 30 && <span className=\"text-muted-foreground truncate\">{a.reason}</span>}\n                      <span className=\"mt-auto truncate font-medium\">{st.label}</span>\n                    </button>\n                  )\n                })}\n              </div>\n            ))}\n          </div>\n        </div>\n\n        <div aria-live=\"polite\" className=\"bg-card mt-4 min-h-[5rem] rounded-xl border p-4\">\n          {chosen ? (\n            <div className=\"flex flex-wrap items-center justify-between gap-3\">\n              <div>\n                <p className=\"font-medium\">{who(chosen)} <span className=\"text-muted-foreground font-normal\">· {chosen.reason}</span></p>\n                <p className=\"text-muted-foreground text-sm tabular-nums\">{clock(firstSlot, chosen.start)} to {clock(firstSlot, chosen.start + chosen.length)} · {provs.find((p) => p.id === chosen.providerId)?.name} · {STATUS[chosen.status].label}</p>\n                {note && <p role={note.ok ? \"status\" : \"alert\"} className={cn(\"mt-1 text-sm font-medium\", !note.ok && \"text-destructive\")}>{note.text}</p>}\n              </div>\n              <div className=\"flex flex-wrap gap-2\">\n                {NEXT.filter((n) => n.when.includes(chosen.status)).map((n) => (\n                  <Button key={n.to} size=\"sm\" variant={n.to === \"no-show\" ? \"outline\" : \"default\"} onClick={() => void move(chosen, n.to)}>\n                    <n.icon aria-hidden=\"true\" /> {n.label}\n                  </Button>\n                ))}\n              </div>\n            </div>\n          ) : (\n            <p className=\"text-muted-foreground text-sm\">Select an appointment to move the patient on.</p>\n          )}\n        </div>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Clinic1, type Clinic1Props, type Clinic1Appointment, type Clinic1Provider, type Clinic1Status }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/clinic-1/clinic-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "clinic",
      "healthcare",
      "appointments",
      "schedule",
      "front desk",
      "patients",
      "medical",
      "booking"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "A clinic day view. <Clinic1 providers={[{ id, name, role? }]} appointments={[{ id, providerId, start: 0 /* minutes after firstSlot, in 15s */, length: 30, patient, reason, status: 'booked' | 'arrived' | 'in-room' | 'done' | 'no-show' }]} day='Friday 24 October' firstSlot='08:00' slots={12} defaultPrivate onStatus={(appointment, status) => Promise} />. Select an appointment to see the next actions that fit its status. Initials-only mode hides names in the grid and in the accessible labels.",
      "whenToUse": [
        "A front-desk screen for a clinic, dentist or therapy practice",
        "Any day schedule with providers and check-in steps"
      ],
      "whenNotToUse": [
        "A calendar for events; use calendar-1",
        "Booking a class for many people; use booking-1"
      ],
      "composesWith": [
        "booking-1",
        "roster-1",
        "calendar-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Columns",
          "action": "Each provider is a labelled group; each appointment is a toggle button that reads '08:30 to 09:00, Ken Watanabe, Blood pressure check, In room'"
        },
        {
          "keys": "Status",
          "action": "Written on every appointment in words (Booked, Arrived, In room, Done, No-show); tints only reinforce it"
        },
        {
          "keys": "Privacy",
          "action": "The switch is a toggle button; in initials-only mode names are removed from the labels as well, not just hidden"
        },
        {
          "keys": "Moving a patient on",
          "action": "Real buttons whose names say what happens; the result is a polite announcement and a failure is an alert"
        }
      ],
      "customization": [
        "providers, firstSlot and slots",
        "defaultPrivate for waiting-room screens",
        "onStatus; reject and the status goes back with the message"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "clinic-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton columns under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No appointments today",
        "example": "clinic-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no appointments today; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "clinic-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying no appointment was changed, with Try again (onRetry). A status update that fails goes back and says why."
      }
    ],
    "examples": [
      "clinic-1-demo",
      "clinic-1-private",
      "clinic-1-loading",
      "clinic-1-empty",
      "clinic-1-error"
    ],
    "url": "https://ui.ballmac.com/components/clinic-1"
  },
  "categories": [
    "blocks",
    "clinic"
  ],
  "type": "registry:block"
}