{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kitchen-1",
  "title": "Kitchen 1: kitchen display",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A kitchen display: a column per station with the oldest ticket first, items with their changes, allergies always spelled out, late and due-soon tickets written in words, a Bump button and a one-step Recall.",
  "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/kitchen-1/kitchen-1.tsx",
      "content": "// Ballmac UI: Kitchen 1. https://ui.ballmac.com/blocks/kitchen-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Clock, TriangleAlert, Undo2 } 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 Kitchen1Item = {\n  qty: number\n  name: string\n  /** Changes to the dish, such as \"no onions\" or \"sauce on the side\". */\n  mods?: string[]\n  /** An allergy to take care of, such as \"Peanuts\". Always shown in words. */\n  allergy?: string\n}\n\ntype Kitchen1Ticket = {\n  id: string\n  /** Order number shown large, for example \"128\". */\n  number: string\n  /** Where it is going: \"Table 12\", \"Delivery\", \"Pickup\". */\n  channel: string\n  /** The station that makes it, matching one of `stations`. */\n  station: string\n  items: Kitchen1Item[]\n  /** Seconds since it was sent to the kitchen. The parent keeps this up to date. */\n  elapsed: number\n  /** Seconds it should be ready within. */\n  target: number\n}\n\ntype Kitchen1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    tickets?: Kitchen1Ticket[]\n    stations?: string[]\n    title?: string\n    /** Called when a ticket is bumped, meaning that station has finished it. */\n    onBump?: (ticket: Kitchen1Ticket) => void | Promise<void>\n    /** Called when the last bumped ticket is brought back. */\n    onRecall?: (ticket: Kitchen1Ticket) => void | Promise<void>\n  }\n\nconst stations = [\"Grill\", \"Fry\", \"Cold\"]\nconst sample: Kitchen1Ticket[] = [\n  { id: \"t1\", number: \"126\", channel: \"Table 4\", station: \"Grill\", elapsed: 842, target: 720, items: [{ qty: 2, name: \"Smash burger\", mods: [\"no pickles\"] }, { qty: 1, name: \"Grilled halloumi\", allergy: \"Milk\" }] },\n  { id: \"t2\", number: \"127\", channel: \"Delivery\", station: \"Grill\", elapsed: 410, target: 720, items: [{ qty: 1, name: \"Chicken skewers\", mods: [\"extra hot\"] }] },\n  { id: \"t3\", number: \"128\", channel: \"Table 12\", station: \"Grill\", elapsed: 95, target: 720, items: [{ qty: 3, name: \"Smash burger\" }, { qty: 1, name: \"Veggie burger\", mods: [\"vegan bun\"] }] },\n  { id: \"t4\", number: \"126\", channel: \"Table 4\", station: \"Fry\", elapsed: 842, target: 480, items: [{ qty: 2, name: \"Fries\", mods: [\"salt only\"] }, { qty: 1, name: \"Onion rings\" }] },\n  { id: \"t5\", number: \"129\", channel: \"Pickup\", station: \"Fry\", elapsed: 180, target: 480, items: [{ qty: 2, name: \"Fries\" }] },\n  { id: \"t6\", number: \"127\", channel: \"Delivery\", station: \"Cold\", elapsed: 390, target: 420, items: [{ qty: 1, name: \"House salad\", mods: [\"dressing on the side\"], allergy: \"Peanuts\" }] },\n]\n\nconst mmss = (s: number) => `${Math.floor(Math.max(0, s) / 60)}:${String(Math.max(0, Math.floor(s)) % 60).padStart(2, \"0\")}`\ntype Level = \"ok\" | \"soon\" | \"late\"\nconst levelOf = (t: Kitchen1Ticket): Level => (t.elapsed > t.target ? \"late\" : t.elapsed >= t.target * 0.8 ? \"soon\" : \"ok\")\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-4 md:grid-cols-3\">\n      {[0, 1, 2].map((i) => (\n        <div key={i} className=\"grid gap-3\">\n          <Skeleton className=\"h-8 rounded-lg\" />\n          <Skeleton className=\"h-40 rounded-xl\" />\n          <Skeleton className=\"h-32 rounded-xl\" />\n        </div>\n      ))}\n    </div>\n  )\n}\n\n/**\n * A kitchen display: a column per station, the oldest ticket first, each with the order number, where it is going, every item with its changes, and the\n * time it has waited. Late and nearly-late tickets say so in words, allergies are always spelled out, and Bump clears a ticket with a one-step Recall.\n * It keeps no clock of its own: pass `elapsed` and update it as often as you like.\n */\nfunction Kitchen1({ tickets: ticketsProp = sample, stations: cols = stations, title = \"Kitchen\", onBump, onRecall, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Kitchen1Props) {\n  const id = React.useId()\n  const [bumped, setBumped] = React.useState<string[]>([])\n  const [note, setNote] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const view = state === \"ready\" && ticketsProp.length === 0 ? \"empty\" : state\n  const live = ticketsProp.filter((t) => !bumped.includes(t.id))\n  const last = bumped.length ? ticketsProp.find((t) => t.id === bumped[bumped.length - 1]) : undefined\n  const late = live.filter((t) => levelOf(t) === \"late\").length\n\n  async function bump(t: Kitchen1Ticket) {\n    setNote(null)\n    setBumped((b) => [...b, t.id])\n    try {\n      await onBump?.(t)\n    } catch (e) {\n      setBumped((b) => b.filter((x) => x !== t.id))\n      setNote({ ok: false, text: e instanceof Error ? e.message : `Order ${t.number} could not be bumped, so it is still on the screen.` })\n    }\n  }\n  async function recall() {\n    if (!last) return\n    setBumped((b) => b.slice(0, -1))\n    try {\n      await onRecall?.(last)\n    } catch (e) {\n      setBumped((b) => [...b, last.id])\n      setNote({ ok: false, text: e instanceof Error ? e.message : `Order ${last.number} could not be brought back.` })\n    }\n  }\n\n  return (\n    <section data-slot=\"kitchen-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-6 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 aria-live=\"polite\" className=\"text-muted-foreground mt-2 tabular-nums\">\n            {live.length} {live.length === 1 ? \"ticket\" : \"tickets\"} open{late ? <> · <strong className=\"text-foreground\">{late} late</strong></> : \"\"}\n          </p>\n        </div>\n        {view === \"ready\" && (\n          <Button variant=\"outline\" disabled={!last} onClick={() => void recall()}>\n            <Undo2 aria-hidden=\"true\" className=\"rtl:-scale-x-100\" /> Recall {last ? `order ${last.number}` : \"last bump\"}\n          </Button>\n        )}\n      </header>\n      {note && <p role=\"alert\" className=\"text-destructive mb-4 text-sm font-medium\">{note.text}</p>}\n      <StateView state={view} onRetry={onRetry} label=\"Loading tickets\" loading={<Skel />} empty={{ title: \"No tickets\", description: \"New orders appear here as soon as they are sent to the kitchen.\", ...emptyState }} error={{ title: \"Tickets could not be loaded\", description: \"Nothing was bumped. Check the connection and try again.\", ...errorState }}>\n        <div className=\"grid gap-5\" style={{ gridTemplateColumns: `repeat(auto-fit, minmax(16rem, 1fr))` }}>\n          {cols.map((s) => {\n            const mine = live.filter((t) => t.station === s).sort((a, b) => b.elapsed - a.elapsed)\n            return (\n              <section key={s} aria-label={`${s} station`} className=\"min-w-0\">\n                <h2 className=\"bg-muted/50 mb-3 flex items-center justify-between rounded-lg px-3 py-2 text-sm font-semibold\">\n                  {s}\n                  <span className=\"text-muted-foreground font-mono text-xs tabular-nums\">{mine.length}</span>\n                </h2>\n                <ul className=\"grid gap-3\">\n                  {mine.map((t) => {\n                    const lv = levelOf(t)\n                    return (\n                      <li key={t.id} data-level={lv} className={cn(\"bg-card rounded-xl border p-4\", lv === \"late\" && \"border-destructive border-2\", lv === \"soon\" && \"border-chart-3\")}>\n                        <div className=\"flex items-start justify-between gap-3\">\n                          <div>\n                            <p className=\"text-2xl leading-none font-semibold tracking-tight tabular-nums\"><span className=\"sr-only\">Order </span>{t.number}</p>\n                            <p className=\"text-muted-foreground mt-1 text-sm\">{t.channel}</p>\n                          </div>\n                          <p className={cn(\"flex items-center gap-1.5 text-sm tabular-nums\", lv === \"late\" ? \"text-destructive font-semibold\" : lv === \"soon\" ? \"text-foreground font-medium\" : \"text-muted-foreground\")}>\n                            {lv === \"ok\" ? <Clock aria-hidden=\"true\" className=\"size-4\" /> : <TriangleAlert aria-hidden=\"true\" className=\"size-4\" />}\n                            {mmss(t.elapsed)}\n                            {lv !== \"ok\" && <span className=\"text-xs\">{lv === \"late\" ? \"Late\" : \"Due soon\"}</span>}\n                          </p>\n                        </div>\n                        <ul className=\"mt-3 grid gap-2 border-t pt-3\">\n                          {t.items.map((it, i) => (\n                            <li key={i} className=\"text-sm\">\n                              <p className=\"font-medium\"><span className=\"tabular-nums\">{it.qty}×</span> {it.name}</p>\n                              {it.mods?.map((m) => (\n                                <p key={m} className=\"text-muted-foreground ps-5 text-[13px]\">{m}</p>\n                              ))}\n                              {it.allergy && (\n                                <p className=\"mt-1 ms-5 flex w-fit items-center gap-1.5 rounded-md border border-destructive px-2 py-0.5 text-xs font-semibold text-destructive\">\n                                  <TriangleAlert aria-hidden=\"true\" className=\"size-3.5\" /> Allergy: {it.allergy}\n                                </p>\n                              )}\n                            </li>\n                          ))}\n                        </ul>\n                        <Button className=\"mt-4 w-full\" size=\"lg\" aria-label={`Bump order ${t.number} at ${s}`} onClick={() => void bump(t)}>\n                          <Check aria-hidden=\"true\" /> Bump\n                        </Button>\n                      </li>\n                    )\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\">All clear.</p>}\n              </section>\n            )\n          })}\n        </div>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Kitchen1, type Kitchen1Item, type Kitchen1Props, type Kitchen1Ticket }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/kitchen-1/kitchen-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "kitchen display",
      "restaurant",
      "orders",
      "pos",
      "tickets",
      "hospitality",
      "food service",
      "kds"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "A kitchen display system. <Kitchen1 stations={['Grill','Fry','Cold']} tickets={[{ id, number: '128', channel: 'Table 12', station: 'Grill', items: [{ qty, name, mods?: string[], allergy?: string }], elapsed: 95 /* seconds, kept up to date by you */, target: 720 }]} onBump={(ticket) => Promise} onRecall={(ticket) => Promise} />. A ticket is Due soon at 80% of its target and Late past it. It keeps no clock: update elapsed from your own timer or server.",
      "whenToUse": [
        "A screen over the pass in a restaurant or ghost kitchen",
        "Any station-by-station queue where each job has a target time"
      ],
      "whenNotToUse": [
        "Taking orders at a till; use an ecommerce checkout block",
        "Pick lists in a warehouse; use dispatch-1 or inventory-1"
      ],
      "composesWith": [
        "dispatch-1",
        "inventory-1",
        "app-shell-1",
        "status-1"
      ],
      "a11y": [
        {
          "keys": "Stations",
          "action": "Each column is a labelled region with a heading and a count"
        },
        {
          "keys": "Late and due soon",
          "action": "Written as words with an icon beside the time; the red border only reinforces it"
        },
        {
          "keys": "Allergies",
          "action": "Always spelled out as 'Allergy: Peanuts' in a bordered tag"
        },
        {
          "keys": "Bump and Recall",
          "action": "Large buttons named for the order and station ('Bump order 129 at Fry'); a failed bump is an alert and the ticket stays"
        }
      ],
      "customization": [
        "stations and tickets",
        "elapsed and target in seconds",
        "onBump and onRecall"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "kitchen-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton columns under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No tickets",
        "example": "kitchen-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no tickets; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "kitchen-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was bumped, with Try again (onRetry). A bump that fails puts the ticket back and says why."
      }
    ],
    "examples": [
      "kitchen-1-demo",
      "kitchen-1-loading",
      "kitchen-1-empty",
      "kitchen-1-error"
    ],
    "url": "https://ui.ballmac.com/components/kitchen-1"
  },
  "categories": [
    "blocks",
    "kitchen"
  ],
  "type": "registry:block"
}