{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inventory-1",
  "title": "Inventory 1: stock levels and reordering",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A stock overview: stock value, lines low or out, units on the way, and every item with a bar against its reorder point, days of cover at today's pace, status in words, and an order form that suggests how many to buy.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/input.json",
    "https://ui.ballmac.com/r/match-highlight.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/inventory-1/inventory-1.tsx",
      "content": "// Ballmac UI: Inventory 1. https://ui.ballmac.com/blocks/inventory-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { AlertTriangle, Boxes, CircleCheck, Loader2, PackageX, Search, Truck, X } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { MatchHighlight, matchesAll, queryTokens } from \"@/components/ballmac/match-highlight\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Inventory1Item = {\n  id: string\n  sku: string\n  name: string\n  category: string\n  onHand: number\n  /** When stock falls to this or below, it is low. */\n  reorderAt: number\n  /** Units on their way, and when. */\n  incoming?: { qty: number; eta: string }\n  /** Units sold or used a day, for days of cover. */\n  perDay: number\n  cost: number\n}\n\ntype Inventory1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    items?: Inventory1Item[]\n    title?: string\n    currency?: string\n    locale?: string\n    /** The item whose order form is open at first. */\n    defaultOrderingId?: string\n    /** Called to order more. Return a promise; reject and the order is not shown as placed. */\n    onReorder?: (item: Inventory1Item, qty: number) => void | Promise<void>\n  }\n\nconst items0: Inventory1Item[] = [\n  { id: \"1\", sku: \"TS-BLK-M\", name: \"Classic tee, black, M\", category: \"Tops\", onHand: 148, reorderAt: 60, perDay: 6, cost: 7.4 },\n  { id: \"2\", sku: \"TS-BLK-L\", name: \"Classic tee, black, L\", category: \"Tops\", onHand: 41, reorderAt: 60, perDay: 8, cost: 7.4, incoming: { qty: 200, eta: \"Oct 24\" } },\n  { id: \"3\", sku: \"HD-GRY-M\", name: \"Heavy hoodie, grey, M\", category: \"Tops\", onHand: 0, reorderAt: 25, perDay: 3, cost: 21.5 },\n  { id: \"4\", sku: \"HD-GRY-L\", name: \"Heavy hoodie, grey, L\", category: \"Tops\", onHand: 12, reorderAt: 25, perDay: 4, cost: 21.5 },\n  { id: \"5\", sku: \"CP-NVY\", name: \"Dad cap, navy\", category: \"Accessories\", onHand: 520, reorderAt: 80, perDay: 5, cost: 3.9 },\n  { id: \"6\", sku: \"TB-CNV\", name: \"Canvas tote\", category: \"Accessories\", onHand: 96, reorderAt: 50, perDay: 7, cost: 2.8 },\n  { id: \"7\", sku: \"SK-WHT-9\", name: \"Court sneaker, white, 9\", category: \"Shoes\", onHand: 9, reorderAt: 20, perDay: 2, cost: 34 },\n  { id: \"8\", sku: \"SK-WHT-10\", name: \"Court sneaker, white, 10\", category: \"Shoes\", onHand: 28, reorderAt: 20, perDay: 2, cost: 34 },\n  { id: \"9\", sku: \"BT-STL\", name: \"Steel water bottle\", category: \"Accessories\", onHand: 233, reorderAt: 70, perDay: 9, cost: 6.2 },\n]\n\ntype Status = \"out\" | \"low\" | \"ok\" | \"over\"\nconst statusMeta: Record<Status, { label: string; chip: string; icon: typeof Boxes }> = {\n  out: { label: \"Out of stock\", chip: \"bg-destructive/10 border-destructive/40\", icon: PackageX },\n  low: { label: \"Low\", chip: \"bg-amber-500/15 border-amber-500/40\", icon: AlertTriangle },\n  ok: { label: \"In stock\", chip: \"bg-chart-2/10 border-chart-2/30\", icon: CircleCheck },\n  over: { label: \"Plenty\", chip: \"bg-muted border-border\", icon: Boxes },\n}\nconst statusOf = (i: Inventory1Item): Status => (i.onHand <= 0 ? \"out\" : i.onHand <= i.reorderAt ? \"low\" : i.onHand > i.reorderAt * 4 ? \"over\" : \"ok\")\nconst cover = (i: Inventory1Item) => (i.perDay > 0 ? Math.floor(i.onHand / i.perDay) : Infinity)\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-5\">\n      <div className=\"grid gap-4 sm:grid-cols-4\">\n        {[0, 1, 2, 3].map((i) => (\n          <Skeleton key={i} className=\"h-24 rounded-2xl\" />\n        ))}\n      </div>\n      <Skeleton className=\"h-96 rounded-2xl\" />\n    </div>\n  )\n}\n\n/**\n * A stock overview: what the stock is worth, how many lines are low or out, and what is on its way, then every item with a bar against its reorder point, how\n * many days it will last at today's pace, and an order form that suggests how many to buy. Status is worked out from the numbers and written in words; filters, search\n * and a sort by days left help you find what to reorder first.\n */\nfunction Inventory1({ items: itemsProp = items0, title = \"Inventory\", currency = \"USD\", locale = \"en\", defaultOrderingId, onReorder, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Inventory1Props) {\n  const id = React.useId()\n  const [items, setItems] = React.useState(itemsProp)\n  React.useEffect(() => setItems(itemsProp), [itemsProp])\n  const list = state === \"empty\" ? [] : items\n  const [query, setQuery] = React.useState(\"\")\n  const [filter, setFilter] = React.useState<Status | \"all\">(\"all\")\n  const [sort, setSort] = React.useState<\"cover\" | \"name\">(\"cover\")\n  const [ordering, setOrdering] = React.useState<string | null>(defaultOrderingId ?? null)\n  const [qty, setQty] = React.useState(() => { const it = (itemsProp ?? items0).find((x) => x.id === defaultOrderingId); return it ? String(Math.max(1, Math.ceil(it.reorderAt * 2 + it.perDay * 14 - it.onHand - (it.incoming?.qty ?? 0)))) : \"\" })\n  const [busy, setBusy] = React.useState(false)\n  const [error, setError] = React.useState<string>()\n  const [announce, setAnnounce] = React.useState(\"\")\n  const tokens = queryTokens(query)\n  const money = (n: number) => new Intl.NumberFormat(locale, { style: \"currency\", currency, maximumFractionDigits: n < 100 ? 2 : 0 }).format(n)\n  const nf = new Intl.NumberFormat(locale)\n\n  const value = list.reduce((n, i) => n + i.onHand * i.cost, 0)\n  const counts = ([\"out\", \"low\", \"ok\", \"over\"] as Status[]).map((s) => [s, list.filter((i) => statusOf(i) === s).length] as const)\n  const incomingUnits = list.reduce((n, i) => n + (i.incoming?.qty ?? 0), 0)\n  const shown = list\n    .filter((i) => (filter === \"all\" || statusOf(i) === filter) && (!tokens.length || matchesAll(`${i.name} ${i.sku} ${i.category}`, tokens)))\n    .sort((a, b) => (sort === \"cover\" ? cover(a) - cover(b) || a.name.localeCompare(b.name) : a.name.localeCompare(b.name)))\n\n  const suggest = (i: Inventory1Item) => Math.max(1, Math.ceil(i.reorderAt * 2 + i.perDay * 14 - i.onHand - (i.incoming?.qty ?? 0)))\n  async function order(i: Inventory1Item) {\n    const n = Math.floor(Number(qty))\n    if (!Number.isFinite(n) || n < 1) return setError(\"Enter how many to order, as a whole number.\")\n    setError(undefined)\n    setBusy(true)\n    try {\n      await onReorder?.(i, n)\n      setItems((x) => x.map((y) => (y.id === i.id ? { ...y, incoming: { qty: (y.incoming?.qty ?? 0) + n, eta: \"in 7 days\" } } : y)))\n      setOrdering(null)\n      setAnnounce(`Ordered ${n} of ${i.name}`)\n    } catch (e) {\n      setError(e instanceof Error ? e.message : \"The order was not placed. Nothing was charged, so try again.\")\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  const content = !list.length ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <Boxes className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No stock recorded\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-sm text-sm text-pretty\">{emptyState?.description ?? \"Add products or connect your store and their stock levels appear here.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <div className=\"grid grid-cols-1 gap-6\">\n      <div className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-4\">\n        {[\n          { label: \"Stock value\", value: money(value), note: `${list.length} products` },\n          { label: \"Out of stock\", value: String(counts[0]![1]), note: counts[0]![1] ? \"Selling is paused\" : \"Nothing missing\" },\n          { label: \"Running low\", value: String(counts[1]![1]), note: \"At or under reorder point\" },\n          { label: \"On the way\", value: nf.format(incomingUnits), note: \"Units already ordered\" },\n        ].map((c) => (\n          <div key={c.label} className=\"bg-card rounded-2xl border p-5\">\n            <p className=\"text-muted-foreground text-sm\">{c.label}</p>\n            <p className=\"mt-1 text-3xl font-semibold tracking-[-0.04em] tabular-nums\">{c.value}</p>\n            <p className=\"text-muted-foreground mt-1 text-xs\">{c.note}</p>\n          </div>\n        ))}\n      </div>\n\n      <div className=\"bg-card min-w-0 rounded-2xl border\">\n        <div className=\"flex flex-col gap-3 border-b p-4 sm:flex-row sm:items-center sm:p-5\">\n          <div className=\"bg-background focus-within:ring-ring/50 flex h-10 flex-1 items-center gap-2 rounded-lg border px-3 focus-within:ring-[3px]\">\n            <Search aria-hidden=\"true\" className=\"text-muted-foreground size-4\" />\n            <input type=\"search\" aria-label=\"Search products\" placeholder=\"Search by name, SKU or category\" value={query} onChange={(e) => setQuery(e.target.value)} className=\"placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none\" />\n          </div>\n          <div className=\"flex items-center gap-2 text-sm\">\n            <label htmlFor={`${id}-sort`} className=\"text-muted-foreground\">Sort by</label>\n            <select id={`${id}-sort`} value={sort} onChange={(e) => setSort(e.target.value as \"cover\" | \"name\")} className=\"bg-background focus-visible:ring-ring/50 h-9 rounded-md border px-3 outline-none focus-visible:ring-[3px]\">\n              <option value=\"cover\">Days left, soonest first</option>\n              <option value=\"name\">Name</option>\n            </select>\n          </div>\n        </div>\n        <div role=\"group\" aria-label=\"Filter by stock level\" className=\"flex flex-wrap gap-2 border-b px-4 py-3 sm:px-5\">\n          {([[\"all\", \"All\"], [\"out\", \"Out of stock\"], [\"low\", \"Low\"], [\"ok\", \"In stock\"], [\"over\", \"Plenty\"]] as const).map(([k, l]) => (\n            <button key={k} type=\"button\" aria-pressed={filter === k} onClick={() => setFilter(k)} className={cn(\"focus-visible:ring-ring/50 rounded-full border px-3 py-1 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", filter === k ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}>\n              {l} <span className={cn(\"tabular-nums\", filter === k ? \"opacity-70\" : \"text-muted-foreground\")}>{k === \"all\" ? list.length : counts.find(([s]) => s === k)![1]}</span>\n            </button>\n          ))}\n        </div>\n        <p aria-live=\"polite\" className=\"text-muted-foreground px-5 pt-4 text-sm\">\n          {shown.length} {shown.length === 1 ? \"product\" : \"products\"}\n        </p>\n\n        {shown.length ? (\n          <ul className=\"divide-y\">\n            {shown.map((i) => {\n              const st = statusOf(i)\n              const M = statusMeta[st]\n              const days = cover(i)\n              const max = Math.max(i.onHand, i.reorderAt * 4, 1)\n              const open = ordering === i.id\n              return (\n                <li key={i.id} className=\"px-4 py-4 sm:px-5\">\n                  <div className=\"grid items-center gap-x-6 gap-y-3 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1.2fr)_9rem_auto]\">\n                    <div className=\"min-w-0\">\n                      <p className=\"font-medium text-pretty\">\n                        <MatchHighlight text={i.name} query={tokens} />\n                      </p>\n                      <p className=\"text-muted-foreground mt-0.5 font-mono text-xs\" dir=\"ltr\">\n                        <MatchHighlight text={i.sku} query={tokens} /> · {i.category}\n                      </p>\n                    </div>\n                    <div>\n                      <div className=\"flex items-center justify-between gap-3 text-sm\">\n                        <span className=\"font-semibold tabular-nums\">{nf.format(i.onHand)} <span className=\"text-muted-foreground font-normal\">on hand</span></span>\n                        <span className={cn(\"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium\", M.chip)}>\n                          <M.icon aria-hidden=\"true\" className=\"size-3\" />\n                          {M.label}\n                        </span>\n                      </div>\n                      <div role=\"img\" aria-label={`${nf.format(i.onHand)} on hand, reorder at ${nf.format(i.reorderAt)}`} className=\"bg-muted relative mt-2 h-2 overflow-hidden rounded-full\">\n                        <span className={cn(\"block h-full rounded-full\", st === \"out\" || st === \"low\" ? (st === \"out\" ? \"bg-destructive\" : \"bg-amber-500\") : \"bg-chart-2\")} style={{ width: `${(i.onHand / max) * 100}%` }} />\n                        <span aria-hidden=\"true\" className=\"bg-foreground/60 absolute inset-y-0 w-0.5\" style={{ insetInlineStart: `${(i.reorderAt / max) * 100}%` }} />\n                      </div>\n                      {i.incoming ? (\n                        <p className=\"text-muted-foreground mt-1.5 flex items-center gap-1.5 text-xs\">\n                          <Truck aria-hidden=\"true\" className=\"size-3.5\" />\n                          {nf.format(i.incoming.qty)} arriving {i.incoming.eta}\n                        </p>\n                      ) : null}\n                    </div>\n                    <p className=\"text-sm tabular-nums\">\n                      <span className={cn(\"font-semibold\", days <= 7 && \"text-amber-700 dark:text-amber-400\")}>{days === Infinity ? \"No sales\" : days === 0 ? \"None left\" : `${days} ${days === 1 ? \"day\" : \"days\"} left`}</span>\n                      <span className=\"text-muted-foreground block text-xs\">\n                        {i.perDay} a day · {money(i.onHand * i.cost)}\n                      </span>\n                    </p>\n                    <Button size=\"sm\" variant={st === \"out\" || st === \"low\" ? \"default\" : \"outline\"} aria-expanded={open} onClick={() => { setOrdering(open ? null : i.id); setQty(String(suggest(i))); setError(undefined) }}>\n                      Reorder <span className=\"sr-only\">{i.name}</span>\n                    </Button>\n                  </div>\n                  {open ? (\n                    <form onSubmit={(e) => { e.preventDefault(); void order(i) }} className=\"bg-muted/40 mt-4 flex flex-wrap items-end gap-3 rounded-xl border p-3.5\">\n                      <div>\n                        <label htmlFor={`${id}-q${i.id}`} className=\"text-muted-foreground mb-1.5 block text-xs font-medium\">How many</label>\n                        <Input id={`${id}-q${i.id}`} inputMode=\"numeric\" value={qty} onChange={(e) => setQty(e.target.value)} className=\"w-28 tabular-nums\" autoFocus />\n                      </div>\n                      <p className=\"text-muted-foreground pb-2 text-sm text-pretty\">\n                        Suggested {nf.format(suggest(i))}: enough for two weeks plus a buffer. About {money((Math.floor(Number(qty)) || 0) * i.cost)}.\n                      </p>\n                      <div className=\"ms-auto flex gap-2\">\n                        <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => setOrdering(null)}>\n                          <X aria-hidden=\"true\" />\n                          Cancel\n                        </Button>\n                        <Button type=\"submit\" size=\"sm\" disabled={busy}>\n                          {busy ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : null}\n                          {busy ? \"Ordering\" : \"Place order\"}\n                        </Button>\n                      </div>\n                      {error ? (\n                        <p role=\"alert\" className=\"text-destructive w-full text-sm\">\n                          {error}\n                        </p>\n                      ) : null}\n                    </form>\n                  ) : null}\n                </li>\n              )\n            })}\n          </ul>\n        ) : (\n          <div className=\"px-6 py-12 text-center\">\n            <p className=\"font-medium\">No products match</p>\n            <p className=\"text-muted-foreground mt-1 text-sm\">Try another word or look at every stock level.</p>\n            <Button variant=\"outline\" size=\"sm\" className=\"mt-4\" onClick={() => { setQuery(\"\"); setFilter(\"all\") }}>\n              Clear filters\n            </Button>\n          </div>\n        )}\n      </div>\n    </div>\n  )\n\n  return (\n    <section data-slot=\"inventory-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      <h1 id={`${id}-title`} className=\"mb-6 text-3xl font-semibold tracking-[-0.035em] text-balance\">\n        {title}\n      </h1>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading stock\" loading={<Skel />} error={{ title: errorState?.title ?? \"Stock could not be loaded\", description: errorState?.description ?? \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </section>\n  )\n}\n\nexport { Inventory1, type Inventory1Props, type Inventory1Item }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/inventory-1/inventory-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "inventory",
      "stock",
      "warehouse",
      "reorder",
      "products",
      "sku",
      "supply",
      "ecommerce",
      "low stock",
      "levels"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A stock overview. <Inventory1 items={[{ id, sku, name, category, onHand, reorderAt, perDay, cost, incoming: { qty, eta } }]} currency locale onReorder={(item, qty) => Promise} />. Status is Out of stock (0), Low (at or under reorderAt), Plenty (over four times reorderAt) or In stock; days left is onHand divided by perDay; stock value is onHand times cost. The suggested order is two reorder points plus fourteen days of sales, minus what is on hand and on the way. Reject onReorder to show the reason; nothing is marked as ordered.",
      "whenToUse": [
        "An ecommerce or warehouse admin: what to reorder first",
        "A restaurant or retail back office"
      ],
      "whenNotToUse": [
        "A customer-facing product page",
        "Order history (use a data table)"
      ],
      "composesWith": [
        "tickets-1",
        "invoice-1",
        "funnel-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Tab, Enter",
          "action": "Each Reorder button names its product; the order form field is labelled and focused when it opens, and a placed order is announced"
        },
        {
          "keys": "Status",
          "action": "Out of stock, Low, In stock and Plenty are written beside an icon, and 'N days left' is text; the bar has a text equivalent giving on hand and the reorder point"
        },
        {
          "keys": "Filters",
          "action": "Buttons say whether they are on and show counts; the number of products shown is announced politely"
        }
      ],
      "customization": [
        "SKUs are shown left to right in right-to-left layouts, like codes",
        "The mark on each bar is the reorder point",
        "Sorting by days left puts what will run out first at the top"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "inventory-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton cards and table under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No stock recorded",
        "example": "inventory-1-empty",
        "trigger": "state=\"empty\" (or items={[]})",
        "note": "Says to add products or connect a store; emptyState sets the copy. A search with no match says so with Clear filters."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "inventory-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). An order that fails says nothing was charged, in place."
      }
    ],
    "examples": [
      "inventory-1-demo",
      "inventory-1-ordering",
      "inventory-1-loading",
      "inventory-1-empty",
      "inventory-1-error"
    ],
    "url": "https://ui.ballmac.com/components/inventory-1"
  },
  "categories": [
    "blocks",
    "inventory"
  ],
  "type": "registry:block"
}