{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "metrics-analysis",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "Spend by category on three bases. Polarity derived per row from the sign convention.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport type { Polarity } from \"@/lib/ledger/money.types\";\nimport { magnitudeShare, periodChange } from \"@/lib/ledger/period-change\";\nimport { InfoTip } from \"@/components/ledger/info-tip\";\nimport { Money } from \"@/components/ledger/money\";\nimport { Percent } from \"@/components/ledger/percent\";\nimport type { PeriodColumn, PeriodRow } from \"@/components/ledger/period-grid\";\nimport { PeriodGrid } from \"@/components/ledger/period-grid\";\n\nexport interface ExpenseCategory {\n  /** The approved budget for the same periods, if there is one. */\n  budget?: (number | null)[];\n  /** Committed but unbilled — an open PO the period has not seen yet. */\n  committed?: (number | null)[];\n  id: string;\n  label: string;\n  level?: number;\n  /** The owner who will be asked about the variance. */\n  owner?: string;\n  /** Actual spend per period. `null` is \"not posted\", never 0. */\n  values: (number | null)[];\n}\n\nexport type ExpenseBasis = \"actual\" | \"budget\" | \"variance\";\n\nexport interface ExpenseTrackerProps {\n  actualThrough?: number;\n  categories: ExpenseCategory[];\n  className?: string;\n  currency: string;\n  materiality?: { absolute: number; relative: number };\n  periods: string[];\n  /** Revenue for the same periods, so spend can be read as a share of it. */\n  revenue?: (number | null)[];\n  /**\n   * How spend is held. `negative` is this system's convention — a cost renders\n   * as `($220,000.00)` — and it is the default.\n   *\n   * It exists because polarity describes the figure as stored, not the concept\n   * behind it, and that is the easiest thing in this file to get backwards: a\n   * heavier month held as a negative is a figure moving *down*. The component\n   * derives the polarity from this rather than asking the caller to reason it\n   * out per row, because every row here is an expense and the answer is the\n   * same for all of them.\n   */\n  sign?: \"negative\" | \"positive\";\n  storageKey?: string;\n}\n\nconst BASES: { id: ExpenseBasis; label: string; hint: string }[] = [\n  { hint: \"What was posted\", id: \"actual\", label: \"Actual\" },\n  { hint: \"What was approved\", id: \"budget\", label: \"Budget\" },\n  { hint: \"Actual less budget, by period\", id: \"variance\", label: \"Variance\" },\n];\n\n/**\n * Spend by category, period by period, on whichever basis is being asked\n * about.\n *\n * Expense rows are `polarity=\"inverted\"` throughout, and that single line is\n * the reason this component exists as something other than a table: an\n * increase in spend is not good news, and a grid that paints it green is a\n * grid that has told the reader the opposite of the truth. Under-budget is\n * favourable; over-budget is not; the colours follow, and the sign is always\n * present so the colour is never carrying it alone.\n *\n * Three bases rather than three tables. Actual, budget and variance are the\n * same categories asked three questions, and putting all of them on screen at\n * once produces a nine-column grid nobody reads past the third column.\n *\n * Committed-but-unbilled is shown as a note under the total rather than\n * folded into it. A purchase order is not an expense — accruing it silently\n * into the month's spend is a real cutoff error, and showing it nowhere is\n * how a category with a booked but unbilled six figures looks under budget\n * right up until the invoice lands.\n */\nexport function ExpenseTracker({\n  periods,\n  categories,\n  currency,\n  sign = \"negative\",\n  revenue,\n  actualThrough,\n  materiality,\n  storageKey,\n  className,\n}: ExpenseTrackerProps) {\n  const [basis, setBasis] = React.useState<ExpenseBasis>(\"actual\");\n  /* Costs held as negatives: a heavier month is a smaller figure, so down is\n     bad and the row is `standard`. Held as positive magnitudes, up is bad and\n     the row is `inverted`. Either way, more spend reads as unfavourable —\n     which is the only outcome that matters. */\n  const polarity: Polarity = sign === \"negative\" ? \"standard\" : \"inverted\";\n  const lastActual = actualThrough ?? periods.length - 1;\n  const hasBudget = categories.some((c) => c.budget != null);\n  const effectiveBasis: ExpenseBasis = hasBudget ? basis : \"actual\";\n\n  const valuesFor = React.useCallback(\n    (category: ExpenseCategory): (number | null)[] =>\n      periods.map((_, i) => {\n        const actual = category.values[i] ?? null;\n        const budget = category.budget?.[i] ?? null;\n        if (effectiveBasis === \"actual\") return actual;\n        if (effectiveBasis === \"budget\") return budget;\n        /* A variance needs both sides. Missing either one is not a variance\n           of the amount that is present — it is an unanswerable question, and\n           `null` is how this system says so. */\n        return actual == null || budget == null ? null : actual - budget;\n      }),\n    [periods, effectiveBasis],\n  );\n\n  const columns: PeriodColumn[] = React.useMemo(\n    () =>\n      periods.map((p, i) => ({\n        id: p,\n        kind: i > lastActual ? \"forecast\" : \"actual\",\n        label: p,\n        locked: i === lastActual,\n      })),\n    [periods, lastActual],\n  );\n\n  const totals = React.useMemo(\n    () =>\n      periods.map((_, i) => {\n        const present = categories.map((c) => valuesFor(c)[i]).filter((v): v is number => v != null);\n        return present.length ? present.reduce((a, b) => a + b, 0) : null;\n      }),\n    [periods, categories, valuesFor],\n  );\n\n  const rows: PeriodRow[] = React.useMemo(() => {\n    const out: PeriodRow[] = categories.map((c) => ({\n      id: c.id,\n      label: c.label,\n      level: c.level ?? 1,\n      note: c.owner,\n      /* Every expense row carries it, without exception. */\n      polarity,\n      values: valuesFor(c),\n    }));\n\n    out.push({\n      derived: true,\n      id: \"__total\",\n      label: effectiveBasis === \"variance\" ? \"Total variance\" : \"Total operating expense\",\n      polarity,\n      values: totals,\n      variant: \"total\",\n    });\n\n    if (revenue && effectiveBasis !== \"variance\") {\n      out.push({\n        derived: true,\n        id: \"__of-revenue\",\n        label: \"As a share of revenue\",\n        /* The share is a magnitude, so a rising share is always the bad\n           direction regardless of how the underlying spend is signed. */\n        polarity: \"inverted\",\n        unit: \"percent\",\n        values: totals.map((v, i) => {\n          /* A magnitude: operating expense of −220,000 against revenue of\n             412,000 is 53.4% of revenue. Nobody says a cost is negative\n             fifty-three percent of revenue. */\n          const share = magnitudeShare(v, revenue[i] ?? null);\n          return share == null ? null : Math.round(share * 100);\n        }),\n        variant: \"ratio\",\n      });\n    }\n\n    return out;\n  }, [categories, valuesFor, totals, revenue, effectiveBasis, polarity]);\n\n  /* ── The movers ───────────────────────────────────────────────────\n     The three categories that moved most between the last two posted\n     columns, by absolute amount rather than by percentage. A category that\n     doubled from 400 to 800 is not the story of a month in which one went\n     from 180,000 to 240,000, and ranking by percentage puts it at the top. */\n  const movers = React.useMemo(() => {\n    if (lastActual < 1) return [];\n    return categories\n      .map((c) => {\n        const values = valuesFor(c);\n        const change = periodChange(values[lastActual] ?? null, values[lastActual - 1] ?? null, polarity);\n        return { change, label: c.label, owner: c.owner };\n      })\n      .filter((m) => m.change.absolute != null && m.change.absolute !== 0)\n      .sort((a, b) => Math.abs(b.change.absolute ?? 0) - Math.abs(a.change.absolute ?? 0))\n      .slice(0, 3);\n  }, [categories, valuesFor, lastActual, polarity]);\n\n  const committedTotal = React.useMemo(() => {\n    const present = categories\n      .map((c) => c.committed?.[lastActual] ?? null)\n      .filter((v): v is number => v != null);\n    return present.length ? present.reduce((a, b) => a + b, 0) : null;\n  }, [categories, lastActual]);\n\n  return (\n    <div className={cn(\"space-y-4\", className)}>\n      <PeriodGrid\n        actualThrough={actualThrough}\n        caption={`Operating expense by category, ${effectiveBasis} basis`}\n        columns={columns}\n        currency={currency}\n        footnote={\n          <span className=\"flex flex-wrap items-center gap-x-4 gap-y-1\">\n            <span>\n              Every row carries its polarity, derived from how spend is signed here — heavier spend reads as\n              unfavourable and a variance under budget reads as favourable, whichever convention the figures\n              arrive in.\n            </span>\n            {committedTotal != null ? (\n              <span className=\"flex items-center gap-1\">\n                Committed but unbilled in {periods[lastActual]}:{\" \"}\n                <Money size=\"xs\" tone=\"inherit\" value={{ amount: committedTotal, currency }} />\n                <InfoTip about=\"Committed but unbilled\">\n                  Open purchase orders. Not an expense until the goods or service arrive, so it is not in the\n                  total above — but a category that looks under budget with this outstanding is not under\n                  budget.\n                </InfoTip>\n              </span>\n            ) : null}\n          </span>\n        }\n        labelHeader=\"Category\"\n        labelWidth={240}\n        materiality={materiality}\n        rows={rows}\n        shareBase=\"__total\"\n        storageKey={storageKey}\n        toolbar={\n          hasBudget ? (\n            <div\n              aria-label=\"Basis\"\n              className=\"inline-flex overflow-hidden rounded-sm border border-border\"\n              role=\"group\"\n            >\n              {BASES.map((b) => (\n                <button\n                  aria-pressed={effectiveBasis === b.id}\n                  className={cn(\n                    \"h-8 border-r border-border px-2.5 text-label transition-colors duration-1 ease-standard last:border-r-0\",\n                    effectiveBasis === b.id\n                      ? \"bg-accent text-accent-fg\"\n                      : \"bg-surface text-text-secondary hover:bg-surface-hover\",\n                  )}\n                  key={b.id}\n                  onClick={() => setBasis(b.id)}\n                  title={b.hint}\n                  type=\"button\"\n                >\n                  {b.label}\n                </button>\n              ))}\n            </div>\n          ) : null\n        }\n      />\n\n      {movers.length > 0 && effectiveBasis === \"actual\" ? (\n        <div className=\"rounded-xl border border-border bg-surface p-4\">\n          <h3 className=\"text-label font-medium text-text-secondary\">\n            Largest movements into {periods[lastActual]}\n            <InfoTip about=\"How movers are ranked\">\n              Ranked by amount, not by percentage. A line that doubled from 400 to 800 is not the story of a\n              month in which another went from 180,000 to 240,000.\n            </InfoTip>\n          </h3>\n          <ul className=\"mt-2 space-y-1\">\n            {movers.map((m) => (\n              <li className=\"flex items-baseline justify-between gap-3 text-body\" key={m.label}>\n                <span>\n                  {m.label}\n                  {m.owner ? <span className=\"ml-2 text-caption text-text-tertiary\">{m.owner}</span> : null}\n                </span>\n                <span\n                  className={cn(\n                    \"flex items-baseline gap-2\",\n                    m.change.direction === \"unfavourable\" ? \"text-negative\" : \"text-positive\",\n                  )}\n                >\n                  <Money accounting tone=\"inherit\" value={{ amount: m.change.absolute ?? 0, currency }} />\n                  <Percent\n                    className=\"text-inherit\"\n                    value={m.change.relative == null ? null : { kind: \"relative\", value: m.change.relative }}\n                  />\n                </span>\n              </li>\n            ))}\n          </ul>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "path": "components/ledger/expense-tracker.tsx",
      "target": "components/ledger/expense-tracker.tsx",
      "type": "registry:component"
    }
  ],
  "name": "expense-tracker",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/cn",
    "@lopifi/info-tip",
    "@lopifi/money",
    "@lopifi/money.types",
    "@lopifi/percent",
    "@lopifi/period-change",
    "@lopifi/period-grid"
  ],
  "title": "ExpenseTracker",
  "type": "registry:component"
}
