{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inspection-1",
  "title": "Inspection 1: checklist inspection",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A field inspection checklist: pass, fail or not applicable on each item with what to look for, a required note when something fails, progress through each section, and a submit that says exactly what is missing.",
  "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/inspection-1/inspection-1.tsx",
      "content": "// Ballmac UI: Inspection 1. https://ui.ballmac.com/blocks/inspection-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Camera, CircleCheck, Loader2, X } 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 Inspection1Answer = \"pass\" | \"fail\" | \"na\"\ntype Inspection1Item = { id: string; text: string; /** What to look for. */ hint?: string }\ntype Inspection1Section = { id: string; title: string; items: Inspection1Item[] }\ntype Inspection1Result = Record<string, { answer: Inspection1Answer; note?: string; photos?: string[] }>\n\ntype Inspection1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    inspection?: { title: string; site: string; inspector: string; sections: Inspection1Section[] } | null\n    /** Answers already given. */\n    defaultResult?: Inspection1Result\n    /** Called with every answer when the inspection is submitted. Reject and it stays open with the message. */\n    onSubmit?: (result: Inspection1Result) => void | Promise<void>\n  }\n\nconst sample = {\n  title: \"Monthly kitchen safety inspection\",\n  site: \"Harbour Street kitchen\",\n  inspector: \"Marcus Bell\",\n  sections: [\n    { id: \"cold\", title: \"Cold storage\", items: [{ id: \"c1\", text: \"Fridge reads 5 °C or below\", hint: \"Check the display and the probe thermometer.\" }, { id: \"c2\", text: \"Freezer reads −18 °C or below\" }, { id: \"c3\", text: \"Raw and cooked food are kept apart\", hint: \"Raw meat on the lowest shelf.\" }] },\n    { id: \"clean\", title: \"Cleaning\", items: [{ id: \"k1\", text: \"Work surfaces are clean and dry\" }, { id: \"k2\", text: \"Hand-wash sink has soap and paper towels\" }, { id: \"k3\", text: \"Cleaning chemicals are labelled and stored away from food\" }] },\n    { id: \"fire\", title: \"Fire safety\", items: [{ id: \"f1\", text: \"Exits are clear\", hint: \"Nothing stored in front of a door or on a route.\" }, { id: \"f2\", text: \"Extinguisher is in date and in place\" }] },\n  ],\n}\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-4\">\n      <Skeleton className=\"h-16 rounded-xl\" />\n      {[0, 1, 2].map((i) => (\n        <Skeleton key={i} className=\"h-40 rounded-xl\" />\n      ))}\n    </div>\n  )\n}\n\nconst ANSWERS: { id: Inspection1Answer; label: string }[] = [{ id: \"pass\", label: \"Pass\" }, { id: \"fail\", label: \"Fail\" }, { id: \"na\", label: \"Not applicable\" }]\n\n/**\n * A checklist inspection for a site visit: every item answered Pass, Fail or Not applicable, a note that is required when something fails, photos that can be\n * attached, and a running count of what is left and what failed. It can only be submitted when every item has an answer and every failure has a note.\n */\nfunction Inspection1({ inspection = sample, defaultResult = {}, onSubmit, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Inspection1Props) {\n  const id = React.useId()\n  const [result, setResult] = React.useState<Inspection1Result>(defaultResult)\n  const [busy, setBusy] = React.useState(false)\n  const [message, setMessage] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const [tried, setTried] = React.useState(false)\n  const view = state === \"ready\" && !inspection ? \"empty\" : state\n  const items = inspection?.sections.flatMap((s) => s.items) ?? []\n  const answered = items.filter((i) => result[i.id]).length\n  const fails = items.filter((i) => result[i.id]?.answer === \"fail\")\n  const missingNotes = fails.filter((i) => !result[i.id]?.note?.trim())\n  const ready = items.length > 0 && answered === items.length && missingNotes.length === 0\n\n  const set = (itemId: string, patch: Partial<Inspection1Result[string]>) => {\n    setMessage(null)\n    setResult((r) => ({ ...r, [itemId]: { ...(r[itemId] ?? { answer: \"pass\" }), ...patch } as Inspection1Result[string] }))\n  }\n  async function submit() {\n    setTried(true)\n    if (!ready) return\n    setBusy(true)\n    setMessage(null)\n    try {\n      await onSubmit?.(result)\n      setMessage({ ok: true, text: fails.length ? `Inspection submitted with ${fails.length} ${fails.length === 1 ? \"failure\" : \"failures\"} to follow up.` : \"Inspection submitted. Everything passed.\" })\n    } catch (e) {\n      setMessage({ ok: false, text: e instanceof Error ? e.message : \"The inspection was not submitted. Your answers are still here.\" })\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  return (\n    <section data-slot=\"inspection-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-3xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the inspection\" loading={<Skel />} empty={{ title: \"No inspection to do\", description: \"When one is scheduled for you it will open here.\", ...emptyState }} error={{ title: \"The inspection could not be loaded\", description: \"Nothing was submitted. Check your connection and try again.\", ...errorState }}>\n        {inspection && (\n          <>\n            <header className=\"mb-6\">\n              <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">{inspection.title}</h1>\n              <p className=\"text-muted-foreground mt-2\">{inspection.site} · inspector {inspection.inspector}</p>\n            </header>\n            <div className=\"bg-card mb-6 grid gap-2.5 rounded-xl border p-4\">\n              <div className=\"flex flex-wrap items-baseline justify-between gap-2 text-sm\">\n                <p className=\"font-medium tabular-nums\">{answered} of {items.length} answered</p>\n                <p aria-live=\"polite\" className={cn(\"text-xs tabular-nums\", fails.length ? \"text-foreground font-medium\" : \"text-muted-foreground\")}>{fails.length ? `${fails.length} failed` : \"No failures so far\"}</p>\n              </div>\n              <div role=\"meter\" aria-label=\"Items answered\" aria-valuemin={0} aria-valuemax={items.length} aria-valuenow={answered} aria-valuetext={`${answered} of ${items.length} answered`} className=\"bg-muted h-2 overflow-hidden rounded-full\">\n                <div className=\"bg-foreground h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none\" style={{ width: `${items.length ? (answered / items.length) * 100 : 0}%` }} />\n              </div>\n            </div>\n\n            <div className=\"grid gap-6\">\n              {inspection.sections.map((sec) => (\n                <section key={sec.id} aria-labelledby={`${id}-${sec.id}`}>\n                  <h2 id={`${id}-${sec.id}`} className=\"mb-3 text-lg font-semibold tracking-tight\">{sec.title}</h2>\n                  <ul className=\"grid gap-3\">\n                    {sec.items.map((it) => {\n                      const r = result[it.id]\n                      const needsNote = r?.answer === \"fail\" && !r.note?.trim()\n                      const unanswered = tried && !r\n                      return (\n                        <li key={it.id} data-answer={r?.answer} className={cn(\"bg-card rounded-xl border p-4\", r?.answer === \"fail\" && \"border-destructive/60\")}>\n                          <fieldset>\n                            <legend className=\"text-sm font-medium\">{it.text}</legend>\n                            {it.hint && <p className=\"text-muted-foreground mt-0.5 text-xs\">{it.hint}</p>}\n                            <div className=\"bg-muted/60 mt-3 inline-flex rounded-full p-0.5\">\n                              {ANSWERS.map((a) => (\n                                <label key={a.id} className=\"relative cursor-pointer\">\n                                  <input type=\"radio\" name={`${id}-${it.id}`} value={a.id} checked={r?.answer === a.id} onChange={() => set(it.id, { answer: a.id })} className=\"peer sr-only\" />\n                                  <span className={cn(\"peer-focus-visible:ring-ring/50 block rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors peer-focus-visible:ring-[3px] motion-reduce:transition-none\", r?.answer === a.id ? (a.id === \"fail\" ? \"bg-destructive text-white\" : \"bg-foreground text-background\") : \"text-muted-foreground hover:text-foreground\")}>{a.label}</span>\n                                </label>\n                              ))}\n                            </div>\n                            {unanswered && <p role=\"alert\" className=\"text-destructive mt-2 text-xs font-medium\">Answer this item.</p>}\n                            {r?.answer === \"fail\" && (\n                              <div className=\"mt-3 grid gap-2\">\n                                <label htmlFor={`${id}-${it.id}-note`} className=\"text-xs font-medium\">What is wrong? <span className=\"text-muted-foreground font-normal\">Required for a failure.</span></label>\n                                <textarea id={`${id}-${it.id}-note`} value={r.note ?? \"\"} onChange={(e) => set(it.id, { note: e.target.value })} aria-invalid={tried && needsNote} rows={2} className=\"aria-invalid:border-destructive bg-background focus-visible:ring-ring/50 w-full resize-y rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-[3px]\" />\n                                {tried && needsNote && <p role=\"alert\" className=\"text-destructive text-xs font-medium\">Say what is wrong so someone can fix it.</p>}\n                                <div className=\"flex flex-wrap items-center gap-2\">\n                                  <label className=\"focus-within:ring-ring/50 hover:bg-accent inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border px-3 text-xs font-medium focus-within:ring-[3px]\">\n                                    <Camera aria-hidden=\"true\" className=\"size-3.5\" /> Add a photo\n                                    <input type=\"file\" accept=\"image/*\" multiple className=\"sr-only\" aria-label={`Add photos for ${it.text}`} onChange={(e) => { const names = [...(e.target.files ?? [])].map((f) => f.name); if (names.length) set(it.id, { photos: [...(r.photos ?? []), ...names] }); e.target.value = \"\" }} />\n                                  </label>\n                                  {(r.photos ?? []).map((p) => (\n                                    <span key={p} className=\"bg-muted inline-flex items-center gap-1 rounded-full py-0.5 ps-2.5 pe-1 text-xs\">\n                                      {p}\n                                      <button type=\"button\" aria-label={`Remove ${p}`} onClick={() => set(it.id, { photos: (r.photos ?? []).filter((x) => x !== p) })} className=\"hover:bg-accent focus-visible:ring-ring/50 flex size-5 items-center justify-center rounded-full outline-none focus-visible:ring-[3px]\"><X aria-hidden=\"true\" className=\"size-3\" /></button>\n                                    </span>\n                                  ))}\n                                </div>\n                              </div>\n                            )}\n                          </fieldset>\n                        </li>\n                      )\n                    })}\n                  </ul>\n                </section>\n              ))}\n            </div>\n\n            <div className=\"bg-background/90 sticky bottom-3 mt-8 flex flex-wrap items-center justify-between gap-3 rounded-2xl border p-3 shadow-lg backdrop-blur\">\n              <p aria-live=\"polite\" role={message && !message.ok ? \"alert\" : undefined} className={cn(\"flex items-center gap-2 text-sm\", message && !message.ok && \"text-destructive font-medium\")}>\n                {message?.ok && <CircleCheck aria-hidden=\"true\" className=\"size-4\" />}\n                {message ? message.text : answered < items.length ? `${items.length - answered} still to answer.` : missingNotes.length ? `${missingNotes.length} ${missingNotes.length === 1 ? \"failure needs\" : \"failures need\"} a note.` : \"Ready to submit.\"}\n              </p>\n              <Button disabled={busy || message?.ok === true} onClick={() => void submit()}>\n                {busy && <Loader2 aria-hidden=\"true\" className=\"animate-spin\" />} Submit inspection\n              </Button>\n            </div>\n          </>\n        )}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Inspection1, type Inspection1Answer, type Inspection1Item, type Inspection1Props, type Inspection1Result, type Inspection1Section }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/inspection-1/inspection-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "inspection",
      "checklist",
      "audit",
      "safety",
      "field",
      "compliance",
      "facilities",
      "form"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "Inspection checklist. <Inspection1 inspection={{ title, site, inspector, sections: [{ id, title, items: [{ id, text, hint? }] }] }} defaultResult={{ itemId: { answer: 'pass'|'fail'|'na', note? } }} onSubmit={(result) => Promise} />. A failed item needs a note. Submitting with gaps lists exactly which items are open and moves focus to the first.",
      "whenToUse": [
        "Safety, hygiene or quality inspections on a phone or tablet",
        "Any pass or fail checklist with evidence"
      ],
      "whenNotToUse": [
        "A survey with free-form questions; use form items",
        "Onboarding steps; use checklist"
      ],
      "composesWith": [
        "fleet-1",
        "kitchen-1",
        "workorder-1"
      ],
      "a11y": [
        {
          "keys": "Answer",
          "action": "Each item is a radio group named by the item with Pass, Fail and Not applicable"
        },
        {
          "keys": "Note",
          "action": "A failed item shows a required, labelled note field"
        },
        {
          "keys": "Progress",
          "action": "Answered of total is written and announced politely"
        },
        {
          "keys": "Submit",
          "action": "Missing items are listed in an alert and focus moves to the first"
        }
      ],
      "customization": [
        "inspection and defaultResult",
        "onSubmit",
        "Sections and items are data"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "inspection-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton checklist under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No inspection assigned",
        "example": "inspection-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no inspection is assigned; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "inspection-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was submitted, with Try again (onRetry). A submit that fails keeps every answer and says why."
      }
    ],
    "examples": [
      "inspection-1-demo",
      "inspection-1-done",
      "inspection-1-loading",
      "inspection-1-empty",
      "inspection-1-error"
    ],
    "url": "https://ui.ballmac.com/components/inspection-1"
  },
  "categories": [
    "blocks",
    "inspection"
  ],
  "type": "registry:block"
}