{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expenses-1",
  "title": "Expenses 1: expense report",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An expense report you can finish: lines with category, amount and receipt, a total by category, rules checked as you go (receipt needed over a threshold, category limits), an add form, and Submit that lists what needs fixing.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/input.json",
    "https://ui.ballmac.com/r/select.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/expenses-1/expenses-1.tsx",
      "content": "// Ballmac UI: Expenses 1. https://ui.ballmac.com/blocks/expenses-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CircleAlert, CircleCheck, Loader2, Paperclip, Plus, Receipt, Send, Trash2, TriangleAlert } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ballmac/select\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Expenses1Category = { id: string; label: string; /** Most one expense in this category may be before it needs a look, in the report's currency. */ limit?: number }\n\ntype Expenses1Line = { id: string; date: string; merchant: string; category: string; amount: number; /** Name of the attached receipt file. */ receipt?: string; note?: string }\n\ntype Expenses1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    title?: string\n    /** \"October trip to Lisbon\". */\n    report?: string\n    lines?: Expenses1Line[]\n    categories?: Expenses1Category[]\n    currency?: string\n    locale?: string\n    /** Amounts above this need a receipt. */\n    receiptOver?: number\n    /** Called when the report is submitted. Return a promise; reject to show the reason and keep the report open. */\n    onSubmit?: (lines: Expenses1Line[], total: number) => void | Promise<void>\n    onChange?: (lines: Expenses1Line[]) => void\n  }\n\nconst cats: Expenses1Category[] = [\n  { id: \"travel\", label: \"Travel\" },\n  { id: \"meals\", label: \"Meals\", limit: 75 },\n  { id: \"lodging\", label: \"Lodging\", limit: 220 },\n  { id: \"software\", label: \"Software\" },\n  { id: \"other\", label: \"Other\" },\n]\nconst sample: Expenses1Line[] = [\n  { id: \"e1\", date: \"2026-10-12\", merchant: \"TAP Air Portugal\", category: \"travel\", amount: 412.8, receipt: \"tap-ticket.pdf\" },\n  { id: \"e2\", date: \"2026-10-12\", merchant: \"Airport taxi\", category: \"travel\", amount: 38, receipt: \"taxi.jpg\" },\n  { id: \"e3\", date: \"2026-10-13\", merchant: \"Hotel Avenida\", category: \"lodging\", amount: 486, receipt: \"hotel-3-nights.pdf\", note: \"Three nights, 162 each\" },\n  { id: \"e4\", date: \"2026-10-13\", merchant: \"Time Out Market\", category: \"meals\", amount: 92.4, receipt: \"dinner.jpg\", note: \"Dinner with the client team of four\" },\n  { id: \"e5\", date: \"2026-10-14\", merchant: \"Café A Brasileira\", category: \"meals\", amount: 14.5 },\n  { id: \"e6\", date: \"2026-10-14\", merchant: \"Conference wifi pass\", category: \"software\", amount: 49 },\n  { id: \"e7\", date: \"2026-10-15\", merchant: \"Metro day pass\", category: \"travel\", amount: 9.6 },\n]\n\nconst barTone = [\"bg-chart-1\", \"bg-chart-2\", \"bg-chart-3\", \"bg-chart-4\", \"bg-chart-5\"]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-5\">\n      <Skeleton className=\"h-40 rounded-2xl\" />\n      <Skeleton className=\"h-80 rounded-2xl\" />\n    </div>\n  )\n}\n\n/**\n * An expense report you can finish: lines with merchant, date, category, amount and receipt, a total broken down by category, and rules that\n * are checked as you go: an amount over the receipt threshold needs a receipt, and a category with a limit flags what goes over it. Add a line\n * with the form, attach a receipt to any line, and Submit lists exactly what still needs fixing before it will send.\n */\nfunction Expenses1({\n  title = \"Expense report\",\n  report = \"October trip to Lisbon\",\n  lines: linesProp = sample,\n  categories = cats,\n  currency = \"EUR\",\n  locale = \"en-GB\",\n  receiptOver = 25,\n  onSubmit,\n  onChange,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Expenses1Props) {\n  const id = React.useId()\n  const [lines, setLines] = React.useState<Expenses1Line[]>(linesProp)\n  React.useEffect(() => setLines(linesProp), [linesProp])\n  const list = state === \"empty\" ? [] : lines\n  const [form, setForm] = React.useState({ merchant: \"\", amount: \"\", date: \"2026-10-16\", category: categories[0]!.id })\n  const [formError, setFormError] = React.useState<string>()\n  const [submitted, setSubmitted] = React.useState(false)\n  const [sending, setSending] = React.useState(false)\n  const [sendError, setSendError] = React.useState<string>()\n  const [tried, setTried] = React.useState(false)\n  const money = (n: number) => new Intl.NumberFormat(locale, { style: \"currency\", currency }).format(n)\n  const dateFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\", timeZone: \"UTC\" }), [locale])\n  const catOf = (cid: string) => categories.find((c) => c.id === cid)\n  const total = list.reduce((s, l) => s + l.amount, 0)\n\n  const issues = (l: Expenses1Line) => {\n    const out: string[] = []\n    if (l.amount > receiptOver && !l.receipt) out.push(`Needs a receipt: it is over ${money(receiptOver)}`)\n    const c = catOf(l.category)\n    if (c?.limit && l.amount > c.limit) out.push(`Over the ${money(c.limit)} limit for ${c.label}`)\n    return out\n  }\n  const missing = list.filter((l) => l.amount > receiptOver && !l.receipt)\n  const byCat = categories.map((c, i) => ({ c, sum: list.filter((l) => l.category === c.id).reduce((s, l) => s + l.amount, 0), tone: barTone[i % barTone.length]! })).filter((x) => x.sum > 0)\n\n  const set = (next: Expenses1Line[]) => {\n    setLines(next)\n    onChange?.(next)\n  }\n  function add(e: React.FormEvent) {\n    e.preventDefault()\n    const amount = Number(form.amount.replace(\",\", \".\"))\n    if (!form.merchant.trim()) return setFormError(\"Say who you paid.\")\n    if (!Number.isFinite(amount) || amount <= 0) return setFormError(\"Enter the amount as a number above zero.\")\n    setFormError(undefined)\n    set([...list, { id: `e${list.length + 1}${form.merchant.length}${Math.round(amount * 100)}`, date: form.date, merchant: form.merchant.trim(), category: form.category, amount }])\n    setForm({ ...form, merchant: \"\", amount: \"\" })\n  }\n  async function send() {\n    setTried(true)\n    if (missing.length) return\n    setSendError(undefined)\n    setSending(true)\n    try {\n      await onSubmit?.(list, total)\n      setSubmitted(true)\n    } catch (e) {\n      setSendError(e instanceof Error ? e.message : \"The report could not be sent. Nothing was lost, so try again.\")\n    } finally {\n      setSending(false)\n    }\n  }\n\n  const content = !list.length && !submitted ? (\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        <Receipt className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No expenses on this report\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-sm text-sm text-pretty\">{emptyState?.description ?? \"Add what you paid for, with a receipt, and the total is worked out for you.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : submitted ? (\n    <div role=\"status\" className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-chart-2/15 mx-auto flex size-14 items-center justify-center rounded-full\">\n        <CircleCheck className=\"size-7\" />\n      </span>\n      <h2 className=\"mt-5 text-2xl font-semibold tracking-tight\">Report sent for approval</h2>\n      <p className=\"text-muted-foreground mx-auto mt-2 max-w-sm text-pretty\">\n        {list.length} {list.length === 1 ? \"expense\" : \"expenses\"} totalling {money(total)} are with your manager. You will be told when it is approved.\n      </p>\n    </div>\n  ) : (\n    <div className=\"grid gap-6\">\n      <div className=\"bg-card rounded-2xl border p-5 sm:p-6\">\n        <div className=\"flex flex-wrap items-end justify-between gap-4\">\n          <div>\n            <p className=\"text-muted-foreground text-sm\">{report}</p>\n            <p className=\"text-4xl font-semibold tracking-[-0.045em] tabular-nums\">{money(total)}</p>\n          </div>\n          <p className=\"text-muted-foreground text-sm tabular-nums\">\n            {list.length} {list.length === 1 ? \"expense\" : \"expenses\"}\n          </p>\n        </div>\n        <div role=\"img\" aria-label={`Total by category: ${byCat.map((x) => `${x.c.label} ${money(x.sum)}`).join(\", \")}`} className=\"bg-muted mt-5 flex h-3 overflow-hidden rounded-full\">\n          {byCat.map((x) => (\n            <span key={x.c.id} className={cn(\"h-full first:rounded-s-full last:rounded-e-full\", x.tone)} style={{ width: `${(x.sum / total) * 100}%` }} />\n          ))}\n        </div>\n        <ul aria-hidden=\"true\" className=\"mt-3 flex flex-wrap gap-x-5 gap-y-1.5 text-sm\">\n          {byCat.map((x) => (\n            <li key={x.c.id} className=\"flex items-center gap-2\">\n              <span className={cn(\"size-2.5 rounded-full\", x.tone)} />\n              {x.c.label} <span className=\"text-muted-foreground tabular-nums\">{money(x.sum)}</span>\n            </li>\n          ))}\n        </ul>\n      </div>\n\n      <div className=\"bg-card rounded-2xl border\">\n        <h2 className=\"border-b px-5 py-3.5 font-semibold tracking-tight\">Expenses</h2>\n        <ul className=\"divide-y\">\n          {list.map((l) => {\n            const probs = issues(l)\n            return (\n              <li key={l.id} className=\"grid gap-x-4 gap-y-2 px-5 py-4 sm:grid-cols-[4.5rem_minmax(0,1fr)_auto]\">\n                <p className=\"text-muted-foreground text-sm tabular-nums\">{dateFmt.format(Date.parse(`${l.date}T00:00:00Z`))}</p>\n                <div className=\"min-w-0\">\n                  <p className=\"font-medium\">{l.merchant}</p>\n                  <p className=\"text-muted-foreground mt-0.5 flex flex-wrap items-center gap-x-2 text-sm\">\n                    <span className=\"bg-muted rounded-full px-2.5 py-0.5 text-xs font-medium\">{catOf(l.category)?.label ?? l.category}</span>\n                    {l.receipt ? (\n                      <span className=\"flex items-center gap-1 text-xs\">\n                        <Paperclip aria-hidden=\"true\" className=\"size-3\" />\n                        {l.receipt}\n                      </span>\n                    ) : (\n                      <label className=\"focus-within:ring-ring/50 hover:bg-accent flex cursor-pointer items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium focus-within:ring-[3px]\">\n                        <Paperclip aria-hidden=\"true\" className=\"size-3\" />\n                        Attach receipt\n                        <span className=\"sr-only\"> for {l.merchant}</span>\n                        <input type=\"file\" accept=\"image/*,.pdf\" className=\"sr-only\" onChange={(e) => { const f = e.target.files?.[0]; if (f) set(list.map((x) => (x.id === l.id ? { ...x, receipt: f.name } : x))) }} />\n                      </label>\n                    )}\n                  </p>\n                  {l.note ? <p className=\"text-muted-foreground mt-1 text-xs text-pretty\">{l.note}</p> : null}\n                  {probs.map((p) => (\n                    <p key={p} className={cn(\"mt-1.5 flex items-start gap-1.5 text-sm\", p.startsWith(\"Needs\") && tried ? \"text-destructive\" : \"\")}>\n                      {p.startsWith(\"Needs\") ? <CircleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" /> : <TriangleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400\" />}\n                      {p}\n                    </p>\n                  ))}\n                </div>\n                <div className=\"flex items-start justify-between gap-2 sm:justify-end\">\n                  <p className=\"font-semibold tabular-nums\">{money(l.amount)}</p>\n                  <Button variant=\"ghost\" size=\"icon\" aria-label={`Remove ${l.merchant}, ${money(l.amount)}`} onClick={() => set(list.filter((x) => x.id !== l.id))}>\n                    <Trash2 aria-hidden=\"true\" />\n                  </Button>\n                </div>\n              </li>\n            )\n          })}\n        </ul>\n\n        <form onSubmit={add} noValidate className=\"bg-muted/30 grid gap-3 border-t p-4 sm:grid-cols-[minmax(0,1.4fr)_7rem_9rem_9rem_auto] sm:items-end sm:p-5\">\n          <div>\n            <label htmlFor={`${id}-m`} className=\"text-muted-foreground mb-1.5 block text-xs font-medium\">Paid to</label>\n            <Input id={`${id}-m`} value={form.merchant} onChange={(e) => setForm({ ...form, merchant: e.target.value })} placeholder=\"Merchant\" />\n          </div>\n          <div>\n            <label htmlFor={`${id}-a`} className=\"text-muted-foreground mb-1.5 block text-xs font-medium\">Amount</label>\n            <Input id={`${id}-a`} inputMode=\"decimal\" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} placeholder=\"0.00\" className=\"text-end tabular-nums\" />\n          </div>\n          <div>\n            <label htmlFor={`${id}-d`} className=\"text-muted-foreground mb-1.5 block text-xs font-medium\">Date</label>\n            <Input id={`${id}-d`} type=\"date\" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />\n          </div>\n          <div>\n            <p id={`${id}-c`} className=\"text-muted-foreground mb-1.5 text-xs font-medium\">Category</p>\n            <Select value={form.category} onValueChange={(v) => setForm({ ...form, category: v })}>\n              <SelectTrigger aria-labelledby={`${id}-c`} className=\"w-full\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                {categories.map((c) => (\n                  <SelectItem key={c.id} value={c.id}>\n                    {c.label}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n          </div>\n          <Button type=\"submit\" variant=\"outline\">\n            <Plus aria-hidden=\"true\" />\n            Add\n          </Button>\n          {formError ? (\n            <p role=\"alert\" className=\"text-destructive text-sm sm:col-span-5\">\n              {formError}\n            </p>\n          ) : null}\n        </form>\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-between gap-4\">\n        <div aria-live=\"polite\" className=\"min-w-0 text-sm\">\n          {tried && missing.length ? (\n            <p role=\"alert\" className=\"text-destructive flex items-start gap-2 text-pretty\">\n              <CircleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n              {missing.length} {missing.length === 1 ? \"expense needs\" : \"expenses need\"} a receipt before you can send: {missing.map((m) => m.merchant).join(\", \")}.\n            </p>\n          ) : (\n            <p className=\"text-muted-foreground\">Receipts are needed for anything over {money(receiptOver)}.</p>\n          )}\n          {sendError ? <p role=\"alert\" className=\"text-destructive mt-1\">{sendError}</p> : null}\n        </div>\n        <Button size=\"lg\" onClick={() => void send()} disabled={sending || !list.length}>\n          {sending ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : <Send aria-hidden=\"true\" className=\"rtl:-scale-x-100\" />}\n          {sending ? \"Sending\" : `Submit ${money(total)}`}\n        </Button>\n      </div>\n    </div>\n  )\n\n  return (\n    <section data-slot=\"expenses-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      <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 the report\" loading={<Skel />} error={{ title: errorState?.title ?? \"The report could not be loaded\", description: errorState?.description ?? \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Expenses1, type Expenses1Props, type Expenses1Line, type Expenses1Category }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/expenses-1/expenses-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "expenses",
      "expense report",
      "receipts",
      "reimbursement",
      "travel",
      "finance",
      "approval",
      "spending",
      "claims"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "An expense report. <Expenses1 report lines={[{ id, date: '2026-10-13', merchant, category, amount, receipt: 'file.pdf', note }]} categories={[{ id, label, limit }]} currency='EUR' locale='en-GB' receiptOver={25} onChange={(lines) => ...} onSubmit={(lines, total) => Promise} />. The total and the category breakdown are worked out from the lines. A line over receiptOver without a receipt blocks Submit and is named in the message; a line over its category limit shows a warning but does not block. Adding a line validates the merchant and amount. Reject onSubmit to show the reason and stay open.",
      "whenToUse": [
        "Employee expense claims and reimbursements",
        "Trip or project cost reports"
      ],
      "whenNotToUse": [
        "A customer invoice (use invoice-1)",
        "Hours worked (use timesheet-1)"
      ],
      "composesWith": [
        "timesheet-1",
        "invoice-1",
        "app-shell-1",
        "upload-1"
      ],
      "a11y": [
        {
          "keys": "Tab, Enter",
          "action": "Each line's Attach receipt is a real file input labelled with the merchant; the add form fields all have visible labels"
        },
        {
          "keys": "Problems",
          "action": "'Needs a receipt: it is over €25.00' and 'Over the €75.00 limit for Meals' are written beside an icon on the line, and Submit names what is missing"
        },
        {
          "keys": "Breakdown",
          "action": "The category bar has a text equivalent listing each category and its amount"
        }
      ],
      "customization": [
        "receiptOver and each category's limit are plain numbers in the report's currency",
        "Currency and dates follow the locale",
        "Removing a line says which one and its amount to a screen reader"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "expenses-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton summary and list under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No expenses yet",
        "example": "expenses-1-empty",
        "trigger": "state=\"empty\" (or lines={[]})",
        "note": "Says to add what you paid for; emptyState sets the copy and can add a button."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "expenses-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A failed submit says nothing was lost, in place beside the button."
      }
    ],
    "examples": [
      "expenses-1-demo",
      "expenses-1-clean",
      "expenses-1-loading",
      "expenses-1-empty",
      "expenses-1-error"
    ],
    "url": "https://ui.ballmac.com/components/expenses-1"
  },
  "categories": [
    "blocks",
    "expenses"
  ],
  "type": "registry:block"
}