{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "metrics-analysis",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "Polarity-aware, materiality-filtered, versions tracked.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { formatDate } from \"@/lib/ledger/fiscal-period\";\nimport { budgetForVersion } from \"@/lib/ledger/forecast\";\nimport { formatMoney } from \"@/lib/ledger/format-money\";\nimport type { Polarity } from \"@/lib/ledger/money.types\";\nimport { InfoTip } from \"@/components/ledger/info-tip\";\nimport { Money } from \"@/components/ledger/money\";\nimport type { PeriodColumn, PeriodRow } from \"@/components/ledger/period-grid\";\nimport { PeriodGrid } from \"@/components/ledger/period-grid\";\nimport { Rule } from \"@/components/ledger/rule\";\n\n/** One figure set — a line, or a line cut to one member of a dimension. */\nexport type BvaFigures = {\n  actual: number | null;\n  budget: number | null;\n  forecast?: number | null;\n};\n\nexport type BvaLine = BvaFigures & {\n  id: string;\n  label: string;\n  level: number;\n  variant: \"line\" | \"subtotal\" | \"total\" | \"section\";\n  /**\n   * Which way is good, per line, and the reason this component is not a table.\n   *\n   * Polarity describes the figure **as stored**, not the concept behind it.\n   * This system holds costs as negatives, so an overspent month is a figure\n   * moving *down* and the row wants `standard`. It is required rather than\n   * defaulted because revenue under budget and payroll under budget are\n   * opposite outcomes, and a single favourable/unfavourable rule applied to\n   * both gets one of them exactly backwards.\n   */\n  polarity: Polarity;\n  /** The same three figures cut by dimension member id. */\n  byMember?: Record<string, BvaFigures>;\n};\n\n/**\n * A budget as it stood on a date.\n *\n * The original plan and each reforecast are versions of the same object, held\n * in one list rather than as \"budget\" plus \"reforecasts\": the moment you model\n * a reforecast as a different kind of thing from the plan, the question \"what\n * did we believe in February\" stops having a single answer.\n */\nexport type BudgetVersion = {\n  id: string;\n  /** \"FY26 Plan v1\", \"Q2 reforecast\". Named, because an unnamed budget is a rumour. */\n  name: string;\n  approvedOn: Date;\n  /**\n   * What this version budgeted, per line id. A line absent here falls back to\n   * the line's own `budget` — so a vintage only has to state what it changed.\n   */\n  budget?: Record<string, number | null>;\n};\n\nexport type BvaDimension = {\n  /** \"Department\", \"Region\", \"Product\". Shown as the switch's label. */\n  label: string;\n  members: { id: string; label: string }[];\n};\n\nexport interface BudgetVsActualProps {\n  className?: string;\n  currency: string;\n  /** Offering the dimension cut. Omit and the grid is the entity total. */\n  dimension?: BvaDimension;\n  lines: BvaLine[];\n  materiality: { absolute: number; relative: number };\n  periodLabel: string;\n  storageKey?: string;\n  /**\n   * Every version of the budget, oldest first. The last is the one in force\n   * and the one selected by default.\n   */\n  versions: BudgetVersion[];\n}\n\nconst ALL = \"__all__\";\n\n/**\n * Budget, actual and forecast as one grid rather than five columns.\n *\n * The variance is not a column here; it is a *view*. Actual, Δ and Δ% are the\n * same three figures asked three questions, and putting all of them on screen\n * at once is how a budget review becomes a wall of numbers nobody reads to the\n * end. The switch is in the toolbar, and the hover chip answers the question\n * for one line without leaving the value view at all.\n *\n * The comparison is declared, not positional: the actual column names budget\n * as its base. Hide the budget column to fit the screen and the variance still\n * measures against budget rather than quietly re-pointing at whatever column\n * ended up to its left.\n *\n * Two things make this more than a three-column table, and both are here\n * because a budget review goes wrong without them:\n *\n * 1. **The cut.** A favourable total is routinely two departments cancelling\n *    out, and a variance report that can only be read at the entity level is a\n *    report that will be believed for a quarter longer than it should be.\n * 2. **The vintage.** A reforecast that quietly replaces the plan makes every\n *    past month look better than it was called at the time. The versions are\n *    kept, the comparison base is chosen explicitly, and the walk from the\n *    original plan to the version in force is shown underneath — so \"we said\n *    we would land at 56 in November and we are calling 60.4 now\" is a\n *    sentence the grid can support.\n */\nexport function BudgetVsActual({\n  periodLabel,\n  lines,\n  currency,\n  materiality,\n  versions,\n  dimension,\n  storageKey,\n  className,\n}: BudgetVsActualProps) {\n  const current = versions[versions.length - 1];\n  const [versionId, setVersionId] = React.useState(current?.id ?? \"\");\n  const [member, setMember] = React.useState(ALL);\n\n  const selected = versions.find((v) => v.id === versionId) ?? current;\n\n  /* The figures for the cut in force. A member with nothing posted reads as\n     null throughout rather than zero — \"this department did not spend here\"\n     and \"this department spent nothing here\" are different findings, and only\n     one of them is a variance. */\n  const figuresFor = React.useCallback(\n    (line: BvaLine): BvaFigures =>\n      member === ALL\n        ? { actual: line.actual, budget: line.budget, forecast: line.forecast }\n        : (line.byMember?.[member] ?? { actual: null, budget: null, forecast: null }),\n    [member],\n  );\n\n  /* The budget in force for a line, under the selected vintage. */\n  const budgetFor = React.useCallback(\n    (line: BvaLine, figures: BvaFigures): number | null => {\n      /* A vintage restates entity-level budgets. Under a dimension cut it has\n         nothing to say about one department's share, so the cut's own budget\n         stands — inventing a split would be the component asserting an\n         allocation nobody approved. */\n      if (member !== ALL) return figures.budget;\n      return budgetForVersion(figures.budget, selected?.budget, line.id);\n    },\n    [member, selected],\n  );\n\n  const hasForecast = lines.some((l) => figuresFor(l).forecast != null);\n\n  const columns: PeriodColumn[] = [\n    {\n      hint: selected ? `${selected.name} · approved ${formatDate(selected.approvedOn)}` : undefined,\n      id: \"budget\",\n      kind: \"budget\",\n      label: \"Budget\",\n    },\n    { compare: \"budget\", id: \"actual\", kind: \"actual\", label: \"Actual\", locked: true },\n    ...(hasForecast\n      ? [{ compare: \"budget\", id: \"forecast\", kind: \"forecast\" as const, label: \"Forecast\" }]\n      : []),\n  ];\n\n  const rows: PeriodRow[] = lines.map((l) => {\n    const figures = figuresFor(l);\n    const section = l.variant === \"section\";\n    return {\n      id: l.id,\n      label: l.variant === \"total\" ? `${l.label}, grand total` : l.label,\n      level: l.level,\n      polarity: l.polarity,\n      values: [\n        section ? null : budgetFor(l, figures),\n        section ? null : figures.actual,\n        ...(hasForecast ? [section ? null : (figures.forecast ?? null)] : []),\n      ],\n      variant: l.variant,\n    };\n  });\n\n  /* The line the vintage walk is told about. The grand total if there is one,\n     otherwise the last line — a walk needs one measure, and picking the\n     bottom of the statement is the convention every board pack follows. */\n  const anchor = React.useMemo(\n    () => [...lines].reverse().find((l) => l.variant === \"total\") ?? lines[lines.length - 1],\n    [lines],\n  );\n\n  const showWalk = versions.length > 1 && anchor != null && member === ALL;\n\n  return (\n    <div className={cn(\"space-y-4\", className)}>\n      <PeriodGrid\n        caption={`Budget versus actual, ${periodLabel}${\n          member === ALL ? \"\" : ` · ${dimension?.members.find((m) => m.id === member)?.label}`\n        }`}\n        columns={columns}\n        currency={currency}\n        footnote={\n          <>\n            {periodLabel} · measured against{\" \"}\n            <span className=\"font-medium text-text-secondary\">{selected?.name}</span>\n            {selected ? <>, approved {formatDate(selected.approvedOn)}</> : null} · variances over{\" \"}\n            <Money size=\"xs\" tone=\"inherit\" value={{ amount: materiality.absolute, currency }} /> <em>and</em>{\" \"}\n            <span className=\"numeric\">{materiality.relative}%</span> are marked ▸\n            {member === ALL ? null : (\n              <> · one {dimension?.label.toLowerCase()} only, so these lines do not sum to the entity</>\n            )}\n          </>\n        }\n        labelHeader=\"Line\"\n        labelWidth={260}\n        materiality={materiality}\n        rows={rows}\n        storageKey={storageKey}\n        toolbar={\n          versions.length > 1 || dimension ? (\n            <div className=\"flex flex-wrap items-center gap-4\">\n              {versions.length > 1 ? (\n                <label className=\"flex items-center gap-2\">\n                  <span className=\"text-label font-medium text-text-secondary\">Compare to</span>\n                  <select\n                    className=\"h-8 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle\"\n                    onChange={(e) => setVersionId(e.target.value)}\n                    value={versionId}\n                  >\n                    {[...versions].reverse().map((v) => (\n                      <option key={v.id} value={v.id}>\n                        {v.name}\n                        {v.id === current?.id ? \" (in force)\" : \"\"}\n                      </option>\n                    ))}\n                  </select>\n                </label>\n              ) : null}\n              {dimension ? (\n                <label className=\"flex items-center gap-2\">\n                  <span className=\"text-label font-medium text-text-secondary\">{dimension.label}</span>\n                  <select\n                    className=\"h-8 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle\"\n                    onChange={(e) => setMember(e.target.value)}\n                    value={member}\n                  >\n                    <option value={ALL}>All — entity total</option>\n                    {dimension.members.map((m) => (\n                      <option key={m.id} value={m.id}>\n                        {m.label}\n                      </option>\n                    ))}\n                  </select>\n                </label>\n              ) : null}\n            </div>\n          ) : null\n        }\n      />\n\n      {showWalk ? (\n        <div className=\"rounded-xl border border-border bg-surface p-6\">\n          <h3 className=\"text-title-sm font-medium\">\n            What was believed, and when\n            <InfoTip about=\"Why every version is kept\">\n              A reforecast that replaces the plan makes every past month look better than it was called at the\n              time. Keeping the versions is what lets someone ask whether the number moved because the\n              business did, or because the target did.\n            </InfoTip>\n          </h3>\n          <Rule className=\"mt-3\" variant=\"subtotal\" />\n          <p className=\"mt-3 text-caption text-text-tertiary\">\n            {anchor.label}, {periodLabel}, as each version had it.\n          </p>\n          <ul className=\"mt-3 space-y-2\">\n            {versions.map((v, i) => {\n              const value = budgetForVersion(anchor.budget, v.budget, anchor.id);\n              const previous = versions[i - 1];\n              const previousValue =\n                previous == null ? null : budgetForVersion(anchor.budget, previous.budget, anchor.id);\n              /* The move from the version before it, as a plain signed\n                 amount.\n                 \n                 Deliberately *not* run through polarity and not called\n                 favourable or unfavourable. Favourability is a statement about\n                 an actual measured against a budget; a budget measured against\n                 an earlier budget is a decision, and a raised target is not\n                 good news to the person who has to hit it. The delta is the\n                 fact, and the reader supplies the judgement. */\n              const move = value == null || previousValue == null ? null : value - previousValue;\n              return (\n                <li className=\"flex items-baseline justify-between gap-4\" key={v.id}>\n                  <span className=\"text-body\">\n                    {v.name}\n                    <span className=\"ml-2 text-caption text-text-tertiary\">\n                      {formatDate(v.approvedOn)}\n                      {v.id === current?.id ? \" · in force\" : \"\"}\n                    </span>\n                  </span>\n                  <span className=\"flex items-baseline gap-3\">\n                    <Money accounting value={value == null ? null : { amount: value, currency }} />\n                    {/* Not coloured. This is a list of decisions, not a\n                        series, and there is no direction a component can\n                        assert about someone changing their own target. */}\n                    <span className=\"w-32 shrink-0 text-right text-caption numeric text-text-tertiary\">\n                      {move == null || move === 0 ? (\n                        \"—\"\n                      ) : (\n                        <>\n                          {move > 0 ? \"+\" : \"\"}\n                          {formatMoney({ amount: move, currency }, { accounting: false })}\n                        </>\n                      )}\n                    </span>\n                  </span>\n                </li>\n              );\n            })}\n          </ul>\n          <p className=\"mt-4 text-caption text-text-tertiary\">\n            The actual is one number. Which of these it is measured against is a choice, and the grid above\n            says which one it is making.\n          </p>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "path": "components/ledger/budget-vs-actual.tsx",
      "target": "components/ledger/budget-vs-actual.tsx",
      "type": "registry:component"
    }
  ],
  "name": "budget-vs-actual",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/cn",
    "@lopifi/fiscal-period",
    "@lopifi/forecast",
    "@lopifi/format-money",
    "@lopifi/info-tip",
    "@lopifi/money",
    "@lopifi/money.types",
    "@lopifi/period-grid",
    "@lopifi/rule"
  ],
  "title": "BudgetVsActual",
  "type": "registry:component"
}
