{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "workorder-1",
  "title": "Work order 1: repair job",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A repair work order: customer, item and reported fault, five steps with one button to advance, parts and labour with subtotal, tax and total, a line form, one-click customer messages, and the history so far.",
  "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/workorder-1/workorder-1.tsx",
      "content": "// Ballmac UI: Work order 1. https://ui.ballmac.com/blocks/workorder-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ChevronRight, Loader2, MessageSquare, Plus } 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 Workorder1Status = \"received\" | \"diagnosing\" | \"waiting-parts\" | \"repairing\" | \"ready\"\n\ntype Workorder1Line = {\n  id: string\n  kind: \"part\" | \"labour\"\n  description: string\n  qty: number\n  /** Price of one, as a number. For labour, one hour. */\n  unit: number\n}\n\ntype Workorder1Data = {\n  /** For example \"WO-1042\". */\n  id: string\n  customer: string\n  /** What is being repaired, for example \"Oven, Bosch HBG\". */\n  item: string\n  /** What the customer says is wrong. */\n  issue: string\n  status: Workorder1Status\n  lines: Workorder1Line[]\n  /** What happened and when, newest first. */\n  history: { id: string; time: string; text: string }[]\n}\n\ntype Workorder1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    order?: Workorder1Data | null\n    /** ISO currency code. */\n    currency?: string\n    /** Tax as a share of the subtotal, for example 0.2 for twenty percent. */\n    taxRate?: number\n    /** Messages that can be sent to the customer in one click. */\n    messages?: { id: string; label: string; text: string }[]\n    /** Called when the order moves to the next step. */\n    onAdvance?: (next: Workorder1Status) => void | Promise<void>\n    /** Called when a part or labour line is added. */\n    onAddLine?: (line: Omit<Workorder1Line, \"id\">) => void | Promise<void>\n    /** Called when a message is sent. */\n    onMessage?: (message: { id: string; text: string }) => void | Promise<void>\n  }\n\nconst STEPS: { id: Workorder1Status; label: string }[] = [\n  { id: \"received\", label: \"Received\" },\n  { id: \"diagnosing\", label: \"Diagnosing\" },\n  { id: \"waiting-parts\", label: \"Waiting for parts\" },\n  { id: \"repairing\", label: \"Repairing\" },\n  { id: \"ready\", label: \"Ready to collect\" },\n]\n\nconst sample: Workorder1Data = {\n  id: \"WO-1042\",\n  customer: \"Ines Carvalho\",\n  item: \"Oven, Bosch HBG\",\n  issue: \"Does not heat up. The fan runs and the light works.\",\n  status: \"diagnosing\",\n  lines: [\n    { id: \"l1\", kind: \"labour\", description: \"Diagnosis\", qty: 0.5, unit: 60 },\n    { id: \"l2\", kind: \"part\", description: \"Heating element, 2,300 W\", qty: 1, unit: 48.5 },\n  ],\n  history: [\n    { id: \"h3\", time: \"Today 10:12\", text: \"Heating element tested: open circuit. Part needed.\" },\n    { id: \"h2\", time: \"Today 09:40\", text: \"Started diagnosis.\" },\n    { id: \"h1\", time: \"Yesterday 16:05\", text: \"Received at the counter. Customer left a phone number.\" },\n  ],\n}\nconst messages = [\n  { id: \"m1\", label: \"Need a part\", text: \"We found the fault. A part is needed and we have ordered it.\" },\n  { id: \"m2\", label: \"Ready to collect\", text: \"Your repair is finished. You can collect it any day from 9 to 5.\" },\n]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-4\">\n      <Skeleton className=\"h-28 rounded-xl\" />\n      <Skeleton className=\"h-24 rounded-xl\" />\n      <Skeleton className=\"h-56 rounded-xl\" />\n    </div>\n  )\n}\n\n/**\n * A repair work order from the counter to collection: who and what, the five steps it moves through with one button to advance, the parts and labour with a\n * running total, a few messages the customer can be sent in one click, and what has happened so far.\n */\nfunction Workorder1({ order: orderProp = sample, currency = \"USD\", taxRate = 0.2, messages: msgs = messages, onAdvance, onAddLine, onMessage, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Workorder1Props) {\n  const id = React.useId()\n  const [order, setOrder] = React.useState<Workorder1Data | null>(orderProp)\n  React.useEffect(() => setOrder(orderProp), [orderProp])\n  const [busy, setBusy] = React.useState<string | null>(null)\n  const [note, setNote] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const [draft, setDraft] = React.useState({ kind: \"part\" as \"part\" | \"labour\", description: \"\", qty: \"1\", unit: \"\" })\n  const view = state === \"ready\" && !order ? \"empty\" : state\n  const money = (n: number) => new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency }).format(n)\n  const idx = order ? STEPS.findIndex((s) => s.id === order.status) : 0\n  const subtotal = order?.lines.reduce((t, l) => t + l.qty * l.unit, 0) ?? 0\n  const tax = subtotal * taxRate\n\n  async function run(key: string, work: () => void | Promise<void>, done: () => void, ok: string) {\n    setBusy(key)\n    setNote(null)\n    try {\n      await work()\n      done()\n      setNote({ ok: true, text: ok })\n    } catch (e) {\n      setNote({ ok: false, text: e instanceof Error ? e.message : \"That did not go through, so nothing changed.\" })\n    } finally {\n      setBusy(null)\n    }\n  }\n  function advance() {\n    if (!order || idx >= STEPS.length - 1) return\n    const next = STEPS[idx + 1]\n    void run(\"advance\", () => onAdvance?.(next.id), () => setOrder((o) => (o ? { ...o, status: next.id, history: [{ id: `h${o.history.length + 1}`, time: \"Just now\", text: `Moved to ${next.label.toLowerCase()}.` }, ...o.history] } : o)), `Moved to ${next.label.toLowerCase()}.`)\n  }\n  function addLine(e: React.FormEvent) {\n    e.preventDefault()\n    const qty = Number(draft.qty)\n    const unit = Number(draft.unit)\n    if (!draft.description.trim() || !(qty > 0) || !(unit >= 0) || draft.unit === \"\") return\n    const line = { kind: draft.kind, description: draft.description.trim(), qty, unit }\n    void run(\"line\", () => onAddLine?.(line), () => { setOrder((o) => (o ? { ...o, lines: [...o.lines, { ...line, id: `l${o.lines.length + 1}` }] } : o)); setDraft({ kind: draft.kind, description: \"\", qty: \"1\", unit: \"\" }) }, `Added ${line.description}.`)\n  }\n\n  return (\n    <section data-slot=\"workorder-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-4xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the work order\" loading={<Skel />} empty={{ title: \"No work order selected\", description: \"Pick a job from the list, or create one at the counter.\", ...emptyState }} error={{ title: \"The work order could not be loaded\", description: \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {order && (\n          <>\n            <header className=\"mb-6\">\n              <p className=\"text-muted-foreground font-mono text-xs\" dir=\"ltr\">{order.id}</p>\n              <h1 id={`${id}-title`} className=\"mt-1 text-3xl font-semibold tracking-[-0.035em] text-balance\">{order.item}</h1>\n              <p className=\"text-muted-foreground mt-1\">{order.customer}</p>\n              <p className=\"mt-3 max-w-2xl text-pretty\"><span className=\"font-medium\">Reported: </span>{order.issue}</p>\n            </header>\n\n            <ol aria-label=\"Progress\" className=\"bg-card grid gap-1 rounded-xl border p-3 sm:grid-cols-5\">\n              {STEPS.map((s, i) => (\n                <li key={s.id} aria-current={i === idx ? \"step\" : undefined} className={cn(\"flex items-center gap-2 rounded-lg px-3 py-2 text-sm\", i === idx && \"bg-accent font-medium\", i > idx && \"text-muted-foreground\")}>\n                  <span aria-hidden=\"true\" className={cn(\"flex size-5 shrink-0 items-center justify-center rounded-full border text-[11px]\", i < idx && \"bg-foreground text-background border-foreground\", i === idx && \"border-foreground\")}>{i < idx ? <Check className=\"size-3\" /> : i + 1}</span>\n                  <span>{s.label}<span className=\"sr-only\">{i < idx ? \", done\" : i === idx ? \", current step\" : \", not started\"}</span></span>\n                </li>\n              ))}\n            </ol>\n            <div className=\"mt-3 flex flex-wrap items-center justify-between gap-3\">\n              <p aria-live=\"polite\" className={cn(\"text-sm\", note && !note.ok && \"text-destructive font-medium\")} role={note && !note.ok ? \"alert\" : undefined}>{note?.text}</p>\n              <Button disabled={idx >= STEPS.length - 1 || busy === \"advance\"} onClick={advance}>\n                {busy === \"advance\" ? <Loader2 aria-hidden=\"true\" className=\"animate-spin\" /> : null}\n                {idx >= STEPS.length - 1 ? \"Ready to collect\" : `Move to ${STEPS[idx + 1].label.toLowerCase()}`}\n                {idx < STEPS.length - 1 && <ChevronRight aria-hidden=\"true\" className=\"rtl:rotate-180\" />}\n              </Button>\n            </div>\n\n            <div className=\"mt-6 grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]\">\n              <div className=\"min-w-0\">\n                <h2 className=\"mb-3 text-lg font-semibold tracking-tight\">Parts and labour</h2>\n                <div tabIndex={0} role=\"region\" aria-label=\"Parts and labour, scrollable\" className=\"focus-visible:ring-ring/50 overflow-x-auto rounded-xl border outline-none focus-visible:ring-[3px]\">\n                  <table className=\"w-full min-w-[26rem] border-collapse text-sm\">\n                    <caption className=\"sr-only\">Parts and labour for {order.id}</caption>\n                    <thead className=\"bg-muted/40 text-muted-foreground text-xs\">\n                      <tr>\n                        <th scope=\"col\" className=\"px-4 py-2.5 text-start font-medium\">Item</th>\n                        <th scope=\"col\" className=\"px-3 py-2.5 text-end font-medium\">Qty</th>\n                        <th scope=\"col\" className=\"px-3 py-2.5 text-end font-medium\">Each</th>\n                        <th scope=\"col\" className=\"px-4 py-2.5 text-end font-medium\">Total</th>\n                      </tr>\n                    </thead>\n                    <tbody className=\"divide-y\">\n                      {order.lines.map((l) => (\n                        <tr key={l.id}>\n                          <th scope=\"row\" className=\"px-4 py-2.5 text-start font-normal\">{l.description}<span className=\"text-muted-foreground block text-xs\">{l.kind === \"part\" ? \"Part\" : \"Labour, per hour\"}</span></th>\n                          <td className=\"px-3 py-2.5 text-end tabular-nums\">{l.qty}</td>\n                          <td className=\"text-muted-foreground px-3 py-2.5 text-end tabular-nums\">{money(l.unit)}</td>\n                          <td className=\"px-4 py-2.5 text-end font-medium tabular-nums\">{money(l.qty * l.unit)}</td>\n                        </tr>\n                      ))}\n                    </tbody>\n                    <tfoot className=\"border-t text-sm\">\n                      <tr><th scope=\"row\" colSpan={3} className=\"text-muted-foreground px-4 py-2 text-end font-normal\">Subtotal</th><td className=\"px-4 py-2 text-end tabular-nums\">{money(subtotal)}</td></tr>\n                      <tr><th scope=\"row\" colSpan={3} className=\"text-muted-foreground px-4 py-2 text-end font-normal\">Tax ({+(taxRate * 100).toFixed(1)}%)</th><td className=\"px-4 py-2 text-end tabular-nums\">{money(tax)}</td></tr>\n                      <tr className=\"bg-muted/30 font-semibold\"><th scope=\"row\" colSpan={3} className=\"px-4 py-2.5 text-end\">Total</th><td className=\"px-4 py-2.5 text-end tabular-nums\">{money(subtotal + tax)}</td></tr>\n                    </tfoot>\n                  </table>\n                </div>\n                <form onSubmit={addLine} className=\"mt-3 grid min-w-0 gap-2 sm:grid-cols-[6.5rem_minmax(0,1fr)_4rem_5.5rem_auto]\" aria-label=\"Add a line\">\n                  <select aria-label=\"Kind\" value={draft.kind} onChange={(e) => setDraft({ ...draft, kind: e.target.value as \"part\" | \"labour\" })} className=\"bg-background focus-visible:ring-ring/50 h-9 rounded-md border px-2 text-sm outline-none focus-visible:ring-[3px]\"><option value=\"part\">Part</option><option value=\"labour\">Labour</option></select>\n                  <input aria-label=\"Description\" placeholder=\"Description\" value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} className=\"bg-background focus-visible:ring-ring/50 h-9 min-w-0 rounded-md border px-3 text-sm outline-none focus-visible:ring-[3px]\" />\n                  <input aria-label=\"Quantity\" inputMode=\"decimal\" value={draft.qty} onChange={(e) => setDraft({ ...draft, qty: e.target.value })} className=\"bg-background focus-visible:ring-ring/50 h-9 min-w-0 rounded-md border px-2 text-end text-sm outline-none focus-visible:ring-[3px]\" />\n                  <input aria-label=\"Price each\" placeholder=\"Price\" inputMode=\"decimal\" value={draft.unit} onChange={(e) => setDraft({ ...draft, unit: e.target.value })} className=\"bg-background focus-visible:ring-ring/50 h-9 min-w-0 rounded-md border px-2 text-end text-sm outline-none focus-visible:ring-[3px]\" />\n                  <Button type=\"submit\" size=\"sm\" variant=\"outline\" disabled={busy === \"line\"}><Plus aria-hidden=\"true\" /> Add</Button>\n                </form>\n              </div>\n\n              <div className=\"grid min-w-0 content-start gap-6\">\n                <div>\n                  <h2 className=\"mb-3 flex items-center gap-2 text-lg font-semibold tracking-tight\"><MessageSquare aria-hidden=\"true\" className=\"size-4\" /> Message the customer</h2>\n                  <div className=\"grid gap-2\">\n                    {msgs.map((m) => (\n                      <Button key={m.id} variant=\"outline\" className=\"h-auto justify-start py-2.5 text-start whitespace-normal\" disabled={busy === m.id} onClick={() => void run(m.id, () => onMessage?.({ id: m.id, text: m.text }), () => setOrder((o) => (o ? { ...o, history: [{ id: `h${o.history.length + 1}`, time: \"Just now\", text: `Messaged the customer: ${m.label.toLowerCase()}.` }, ...o.history] } : o)), `Sent to ${order.customer}: ${m.label.toLowerCase()}.`)}>\n                        <span><span className=\"block font-medium\">{m.label}</span><span className=\"text-muted-foreground block text-xs font-normal\">{m.text}</span></span>\n                      </Button>\n                    ))}\n                  </div>\n                </div>\n                <div>\n                  <h2 className=\"mb-3 text-lg font-semibold tracking-tight\">History</h2>\n                  <ol aria-label=\"History, newest first\" className=\"grid gap-3\">\n                    {order.history.map((h) => (\n                      <li key={h.id} className=\"border-border border-s ps-4 text-sm\"><time className=\"text-muted-foreground block text-xs\">{h.time}</time>{h.text}</li>\n                    ))}\n                  </ol>\n                </div>\n              </div>\n            </div>\n          </>\n        )}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Workorder1, type Workorder1Data, type Workorder1Line, type Workorder1Props, type Workorder1Status }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/workorder-1/workorder-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "work order",
      "repair",
      "service",
      "workshop",
      "field service",
      "parts",
      "labour",
      "job card"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "A repair work order. <Workorder1 order={{ id, customer, item, issue, status: 'received' | 'diagnosing' | 'waiting-parts' | 'repairing' | 'ready', lines: [{ id, kind: 'part' | 'labour', description, qty, unit }], history: [{ id, time, text }] }} currency='USD' taxRate={0.2} messages={[{ id, label, text }]} onAdvance={(status) => Promise} onAddLine={(line) => Promise} onMessage={({ id, text }) => Promise} />. Totals are computed from the lines. Advancing, adding a line and messaging each update the screen and the history.",
      "whenToUse": [
        "A repair shop, workshop or service desk's job page",
        "Any job with steps, billable lines and customer updates"
      ],
      "whenNotToUse": [
        "A support ticket thread; use tickets-1",
        "An invoice for sending; use invoice-1"
      ],
      "composesWith": [
        "dispatch-1",
        "invoice-1",
        "tickets-1",
        "inventory-1"
      ],
      "a11y": [
        {
          "keys": "Progress",
          "action": "An ordered list where the current step has aria-current='step' and every step says done, current or not started in words"
        },
        {
          "keys": "Totals",
          "action": "A real table with a caption, a subtotal, tax and total; every number is text"
        },
        {
          "keys": "Add a line",
          "action": "A labelled form; an incomplete line is simply not added"
        },
        {
          "keys": "Advance and messages",
          "action": "Real buttons; the result is a polite announcement and a failure is an alert"
        }
      ],
      "customization": [
        "currency and taxRate",
        "messages you can send in one click",
        "onAdvance, onAddLine and onMessage"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "workorder-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton for the header, steps and totals under the page, announced as loading."
      },
      {
        "id": "empty",
        "title": "No work order selected",
        "example": "workorder-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no work order is selected; pass order={null} and emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "workorder-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A failed advance, line or message says why."
      }
    ],
    "examples": [
      "workorder-1-demo",
      "workorder-1-loading",
      "workorder-1-empty",
      "workorder-1-error"
    ],
    "url": "https://ui.ballmac.com/components/workorder-1"
  },
  "categories": [
    "blocks",
    "workorder"
  ],
  "type": "registry:block"
}