{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dispatch-1",
  "title": "Dispatch 1: field service board",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A dispatch board for field work: waiting jobs with urgent ones first and named, a lane per technician with their jobs in time order, assignment by a labelled select instead of a drag, take-back, and changes undone with the reason.",
  "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/dispatch-1/dispatch-1.tsx",
      "content": "// Ballmac UI: Dispatch 1. https://ui.ballmac.com/blocks/dispatch-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CircleAlert, MapPin, Truck, UserMinus } 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 Dispatch1JobStatus = \"scheduled\" | \"en-route\" | \"on-site\" | \"done\"\n\ntype Dispatch1Job = {\n  id: string\n  /** For example \"Replace the boiler pump\". */\n  title: string\n  address: string\n  /** The time window promised to the customer, such as \"09:00 to 11:00\". */\n  window: string\n  priority: \"normal\" | \"urgent\"\n  /** Which technician has it. Leave it out for an unassigned job. */\n  assignedTo?: string\n  status?: Dispatch1JobStatus\n}\n\ntype Dispatch1Technician = {\n  id: string\n  name: string\n  /** The area they cover, such as \"North\". */\n  area?: string\n  /** What they are doing now, such as \"On site at 14 Mill Lane\". */\n  now?: string\n}\n\ntype Dispatch1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    jobs?: Dispatch1Job[]\n    technicians?: Dispatch1Technician[]\n    title?: string\n    /** Called to give a job to a technician, or take it back when techId is null. Reject and it is undone with the message. */\n    onAssign?: (jobId: string, techId: string | null) => void | Promise<void>\n  }\n\nconst technicians: Dispatch1Technician[] = [\n  { id: \"t1\", name: \"Marcus Bell\", area: \"North\", now: \"On site at 14 Mill Lane\" },\n  { id: \"t2\", name: \"Aisha Rahman\", area: \"Centre\", now: \"Driving to Unit 9, Dock Road\" },\n  { id: \"t3\", name: \"Piotr Nowak\", area: \"South\", now: \"Free\" },\n]\nconst sample: Dispatch1Job[] = [\n  { id: \"j1\", title: \"Replace the boiler pump\", address: \"14 Mill Lane\", window: \"09:00 to 11:00\", priority: \"normal\", assignedTo: \"t1\", status: \"on-site\" },\n  { id: \"j2\", title: \"Fix a leaking radiator valve\", address: \"3 Harbour View\", window: \"12:00 to 14:00\", priority: \"normal\", assignedTo: \"t1\", status: \"scheduled\" },\n  { id: \"j3\", title: \"No heating or hot water\", address: \"Unit 9, Dock Road\", window: \"10:00 to 12:00\", priority: \"urgent\", assignedTo: \"t2\", status: \"en-route\" },\n  { id: \"j4\", title: \"Annual boiler service\", address: \"22 Orchard Close\", window: \"14:00 to 16:00\", priority: \"normal\", assignedTo: \"t2\", status: \"scheduled\" },\n  { id: \"j5\", title: \"Gas smell reported\", address: \"8 Kiln Street\", window: \"As soon as possible\", priority: \"urgent\" },\n  { id: \"j6\", title: \"Fit a thermostat\", address: \"51 Ropewalk\", window: \"13:00 to 15:00\", priority: \"normal\" },\n]\nconst STATUS: Record<Dispatch1JobStatus, string> = { scheduled: \"Scheduled\", \"en-route\": \"On the way\", \"on-site\": \"On site\", done: \"Done\" }\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]\">\n      <Skeleton className=\"h-96 rounded-xl\" />\n      <div className=\"grid gap-4 sm:grid-cols-3\">\n        {[0, 1, 2].map((i) => (\n          <Skeleton key={i} className=\"h-96 rounded-xl\" />\n        ))}\n      </div>\n    </div>\n  )\n}\n\n/**\n * A dispatch board for field work: jobs waiting for someone, with urgent ones first and named as urgent, and a lane for each technician with what they are doing\n * now and their jobs in time order. Giving a job to someone is a labelled select, not a drag, so it works from the keyboard; a job can be taken back, and\n * a change that fails is undone with the reason.\n */\nfunction Dispatch1({ jobs: jobsProp = sample, technicians: techs = technicians, title = \"Dispatch\", onAssign, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Dispatch1Props) {\n  const id = React.useId()\n  const [jobs, setJobs] = React.useState<Dispatch1Job[]>(jobsProp)\n  React.useEffect(() => setJobs(jobsProp), [jobsProp])\n  const [note, setNote] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const view = state === \"ready\" && jobs.length === 0 ? \"empty\" : state\n  const waiting = jobs.filter((j) => !j.assignedTo).sort((a, b) => Number(b.priority === \"urgent\") - Number(a.priority === \"urgent\"))\n  const urgentWaiting = waiting.filter((j) => j.priority === \"urgent\").length\n\n  async function assign(job: Dispatch1Job, techId: string | null) {\n    setNote(null)\n    const tech = techs.find((t) => t.id === techId)\n    setJobs((l) => l.map((j) => (j.id === job.id ? { ...j, assignedTo: techId ?? undefined, status: techId ? \"scheduled\" : undefined } : j)))\n    try {\n      await onAssign?.(job.id, techId)\n      setNote({ ok: true, text: techId ? `${job.title} is now ${tech?.name}'s.` : `${job.title} is back in the waiting list.` })\n    } catch (e) {\n      setJobs((l) => l.map((j) => (j.id === job.id ? job : j)))\n      setNote({ ok: false, text: e instanceof Error ? e.message : `${job.title} could not be changed, so it is as it was.` })\n    }\n  }\n\n  return (\n    <section data-slot=\"dispatch-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\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p aria-live=\"polite\" className=\"text-muted-foreground mt-2\">\n          {waiting.length === 0 ? \"Every job has someone.\" : <>{waiting.length} {waiting.length === 1 ? \"job is\" : \"jobs are\"} waiting{urgentWaiting ? <> · <strong className=\"text-foreground\">{urgentWaiting} urgent</strong></> : \"\"}</>}\n        </p>\n      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading jobs\" loading={<Skel />} empty={{ title: \"No jobs today\", description: \"New jobs appear here until someone is given them.\", ...emptyState }} error={{ title: \"Jobs could not be loaded\", description: \"Nothing was assigned or changed. Check your connection and try again.\", ...errorState }}>\n        {note && <p role={note.ok ? \"status\" : \"alert\"} className={cn(\"mb-4 text-sm font-medium\", !note.ok && \"text-destructive\")}>{note.text}</p>}\n        <div className=\"grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]\">\n          <section aria-labelledby={`${id}-wait`} className=\"min-w-0\">\n            <h2 id={`${id}-wait`} className=\"bg-muted/50 mb-3 flex items-center justify-between rounded-lg px-3 py-2 text-sm font-semibold\">Waiting for someone <span className=\"text-muted-foreground font-mono text-xs tabular-nums\">{waiting.length}</span></h2>\n            <ul className=\"grid gap-3\">\n              {waiting.map((j) => (\n                <li key={j.id} data-priority={j.priority} className={cn(\"bg-card rounded-xl border p-4\", j.priority === \"urgent\" && \"border-destructive border-2\")}>\n                  {j.priority === \"urgent\" && <p className=\"text-destructive mb-1.5 flex items-center gap-1.5 text-xs font-semibold\"><CircleAlert aria-hidden=\"true\" className=\"size-3.5\" /> Urgent</p>}\n                  <p className=\"font-medium\">{j.title}</p>\n                  <p className=\"text-muted-foreground mt-1 flex items-center gap-1.5 text-xs\"><MapPin aria-hidden=\"true\" className=\"size-3.5\" /> {j.address}</p>\n                  <p className=\"text-muted-foreground mt-0.5 text-xs\">{j.window}</p>\n                  <label className=\"mt-3 grid gap-1 text-xs font-medium\">\n                    Give to\n                    <select value=\"\" aria-label={`Give ${j.title} to`} onChange={(e) => e.target.value && void assign(j, e.target.value)} className=\"bg-background focus-visible:ring-ring/50 h-9 rounded-md border px-2 text-sm font-normal outline-none focus-visible:ring-[3px]\">\n                      <option value=\"\">Choose a technician</option>\n                      {techs.map((t) => (\n                        <option key={t.id} value={t.id}>{t.name}{t.area ? `, ${t.area}` : \"\"}</option>\n                      ))}\n                    </select>\n                  </label>\n                </li>\n              ))}\n            </ul>\n            {waiting.length === 0 && <p className=\"text-muted-foreground rounded-xl border border-dashed px-4 py-8 text-center text-sm\">All jobs are assigned.</p>}\n          </section>\n\n          <div className=\"grid min-w-0 gap-5 sm:grid-cols-[repeat(auto-fit,minmax(12rem,1fr))]\">\n            {techs.map((t) => {\n              const mine = jobs.filter((j) => j.assignedTo === t.id).sort((a, b) => a.window.localeCompare(b.window))\n              return (\n                <section key={t.id} aria-label={`${t.name}'s jobs`} className=\"min-w-0\">\n                  <h2 className=\"bg-muted/50 mb-3 rounded-lg px-3 py-2 text-sm font-semibold\">\n                    <span className=\"flex items-center justify-between\">{t.name} <span className=\"text-muted-foreground font-mono text-xs tabular-nums\">{mine.length}</span></span>\n                    <span className=\"text-muted-foreground flex items-center gap-1.5 text-xs font-normal\"><Truck aria-hidden=\"true\" className=\"size-3\" /> {t.now ?? t.area ?? \"\"}</span>\n                  </h2>\n                  <ul className=\"grid gap-3\">\n                    {mine.map((j) => (\n                      <li key={j.id} data-status={j.status} className={cn(\"bg-card rounded-xl border p-3.5\", j.priority === \"urgent\" && \"border-destructive\")}>\n                        <p className=\"flex items-center justify-between gap-2 text-xs\"><span className=\"text-muted-foreground tabular-nums\">{j.window}</span><span className=\"font-medium\">{j.status ? STATUS[j.status] : \"\"}</span></p>\n                        {j.priority === \"urgent\" && <p className=\"text-destructive mt-1 flex items-center gap-1 text-xs font-semibold\"><CircleAlert aria-hidden=\"true\" className=\"size-3\" /> Urgent</p>}\n                        <p className=\"mt-1 text-sm font-medium\">{j.title}</p>\n                        <p className=\"text-muted-foreground text-xs\">{j.address}</p>\n                        {j.status === \"scheduled\" && (\n                          <Button size=\"sm\" variant=\"ghost\" className=\"mt-2 -ms-2\" aria-label={`Take ${j.title} back from ${t.name}`} onClick={() => void assign(j, null)}>\n                            <UserMinus aria-hidden=\"true\" /> Take back\n                          </Button>\n                        )}\n                      </li>\n                    ))}\n                  </ul>\n                  {mine.length === 0 && <p className=\"text-muted-foreground rounded-xl border border-dashed px-4 py-8 text-center text-sm\">Free.</p>}\n                </section>\n              )\n            })}\n          </div>\n        </div>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Dispatch1, type Dispatch1Job, type Dispatch1JobStatus, type Dispatch1Props, type Dispatch1Technician }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/dispatch-1/dispatch-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "dispatch",
      "field service",
      "technicians",
      "jobs",
      "scheduling",
      "trades",
      "logistics",
      "assign"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "A dispatch board. <Dispatch1 technicians={[{ id, name, area?, now? }]} jobs={[{ id, title, address, window, priority: 'normal' | 'urgent', assignedTo?: techId, status?: 'scheduled' | 'en-route' | 'on-site' | 'done' }]} onAssign={(jobId, techId | null) => Promise} />. Waiting jobs list urgent first. Assigning uses a labelled select (no drag), and only a scheduled job can be taken back.",
      "whenToUse": [
        "An office view for assigning field jobs to technicians",
        "Any queue where jobs wait and are given to people"
      ],
      "whenNotToUse": [
        "A kitchen's tickets by station; use kitchen-1",
        "A calendar of a person's day; use calendar-1"
      ],
      "composesWith": [
        "workorder-1",
        "kitchen-1",
        "roster-1",
        "locations-1"
      ],
      "a11y": [
        {
          "keys": "Assigning",
          "action": "A native select per waiting job named 'Give Fit a thermostat to'; no drag is needed"
        },
        {
          "keys": "Urgent",
          "action": "Written as the word Urgent with an icon, never a coloured border alone"
        },
        {
          "keys": "Lanes",
          "action": "Each technician is a labelled region with a count, and jobs are listed in time order with the status in words"
        },
        {
          "keys": "Take back",
          "action": "A button named for the job and the technician, only on jobs not yet under way"
        }
      ],
      "customization": [
        "technicians and jobs",
        "onAssign; reject and the job goes back",
        "The waiting count and urgent count update as you assign"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "dispatch-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton for the waiting list and the lanes under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No jobs today",
        "example": "dispatch-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no jobs today; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "dispatch-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was assigned or changed, with Try again (onRetry). A failed assignment is undone and says why."
      }
    ],
    "examples": [
      "dispatch-1-demo",
      "dispatch-1-loading",
      "dispatch-1-empty",
      "dispatch-1-error"
    ],
    "url": "https://ui.ballmac.com/components/dispatch-1"
  },
  "categories": [
    "blocks",
    "dispatch"
  ],
  "type": "registry:block"
}