{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "statements",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "The shared shell: hierarchy, periods, rules.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useScrollEdges } from \"@/hooks/ledger/use-scroll-edges\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { formatDate } from \"@/lib/ledger/fiscal-period\";\nimport { formatMoney } from \"@/lib/ledger/format-money\";\nimport type { AccountingBasis, Polarity } from \"@/lib/ledger/money.types\";\nimport { periodChange } from \"@/lib/ledger/period-change\";\nimport { ColumnManager, type ManagedColumn } from \"@/components/ledger/column-manager\";\nimport { ChevronRightIcon } from \"@/components/ledger/icons\";\nimport { Money } from \"@/components/ledger/money\";\nimport { Percent } from \"@/components/ledger/percent\";\nimport { Rule } from \"@/components/ledger/rule\";\nimport { VarianceCell } from \"@/components/ledger/variance-cell\";\n\nexport type StatementLine = {\n  id: string;\n  label: string;\n  /** 1–5. Deeper than 5 is a chart-of-accounts problem, not a UI problem. */\n  level: number;\n  variant: \"line\" | \"subtotal\" | \"total\" | \"section\" | \"margin\";\n  /** One entry per period column. `null` is \"we don't have it\"; 0 is zero. */\n  values: (number | null)[];\n  /** Percent rows are visually distinguished — a percentage in a column of\n      dollars must not look like dollars. */\n  percentValues?: (number | null)[];\n  polarity?: Polarity;\n  children?: StatementLine[];\n  /** Opt *out*. Every line with a figure is drillable by default — a figure\n      you cannot open is a figure you have to take on trust. Set false for\n      lines with nothing behind them (memo rows, computed ratios). */\n  drilldownable?: boolean;\n};\n\n/** Which figure was clicked. A line drills differently per period, so the\n    column travels with the line. */\nexport type DrilldownTarget = {\n  /** The resolved period label, as shown in the column header. */\n  period: string;\n  /** Index into `periods` / `line.values` — survives hidden columns. */\n  periodIndex: number;\n  value: number | null;\n};\n\nexport type StatementHeader = {\n  entity: string;\n  statement: string;\n  /** Resolved, never \"Current\". */\n  periodLabel: string;\n  basis: AccountingBasis;\n  framework: \"us-gaap\" | \"ifrs\";\n  currency: string;\n  /** \"in thousands\" — when presented scaled, the header says so. Not optional. */\n  scale?: \"units\" | \"thousands\" | \"millions\";\n  generatedAt: Date;\n  provenance?: string;\n};\n\nexport interface StatementTableProps {\n  className?: string;\n  /**\n   * Offer the reader the period columns to hide. A comparative statement with\n   * five periods and a variance pair is nine columns on a laptop, and the\n   * first thing anyone does with it is put four of them away.\n   */\n  columnManager?: boolean;\n  header: StatementHeader;\n  lines: StatementLine[];\n  /** Both thresholds must be exceeded for a variance to flag. */\n  materiality?: { absolute: number; relative: number };\n  /**\n   * Caps the scroller so the column headers actually freeze. Without a height\n   * the page scrolls instead, and `position: sticky` on a header inside a\n   * container that never scrolls is a header that never sticks — which is\n   * what a long balance sheet had until this existed.\n   */\n  maxHeight?: number;\n  onDrilldown?: (line: StatementLine, target: DrilldownTarget) => void;\n  /** Each column states its own resolved period. */\n  periods: string[];\n  restatedColumns?: number[];\n  roundingNote?: string;\n  showVariance?: boolean;\n}\n\nconst SCALE_LABEL = {\n  millions: \"in millions\",\n  thousands: \"in thousands\",\n  units: null,\n} as const;\n\n/**\n * The shared shell. P&L, balance sheet and cash flow are all configurations\n * of this.\n *\n * Rule lines span the numeric columns only, each period column\n * independently, gutters unruled. That is the detail that makes it read as\n * an accounting statement rather than a styled table.\n *\n * Expand/collapse is instant — this happens hundreds of times during a\n * close. The chevron rotates in 140ms; the rows do not slide or stagger.\n */\nexport function StatementTable({\n  header,\n  periods,\n  lines,\n  showVariance = false,\n  materiality,\n  onDrilldown,\n  roundingNote,\n  restatedColumns = [],\n  maxHeight,\n  columnManager = false,\n  className,\n}: StatementTableProps) {\n  const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());\n\n  const toggle = (id: string) => {\n    setCollapsed((prev) => {\n      const next = new Set(prev);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      // Expansion state belongs in the URL, so a shared link opens the same view.\n      try {\n        const params = new URLSearchParams(window.location.search);\n        if (next.size) params.set(\"collapsed\", [...next].join(\",\"));\n        else params.delete(\"collapsed\");\n        const q = params.toString();\n        window.history.replaceState(null, \"\", q ? `?${q}` : window.location.pathname);\n      } catch {\n        /* the view still works without a shareable URL */\n      }\n      return next;\n    });\n  };\n\n  const flat = React.useMemo(() => flatten(lines, collapsed), [lines, collapsed]);\n  const scaleLabel = header.scale ? SCALE_LABEL[header.scale] : null;\n\n  /* Column visibility is held as the full list rather than as a set of hidden\n     ids, so hiding a period never renumbers the values behind it — every row\n     still indexes into `periods`, and `visible` carries the source index. */\n  const [managed, setManaged] = React.useState<ManagedColumn[]>(() =>\n    periods.map((p, i) => ({ id: p, label: p, locked: i === 0, visible: true })),\n  );\n  React.useEffect(() => {\n    setManaged((current) => {\n      const seen = new Map(current.map((c) => [c.id, c]));\n      if (periods.length === current.length && periods.every((p) => seen.has(p))) return current;\n      return periods.map((p, i) => ({\n        id: p,\n        label: p,\n        locked: i === 0,\n        visible: seen.get(p)?.visible ?? true,\n      }));\n    });\n  }, [periods]);\n\n  const visible = React.useMemo(\n    () =>\n      managed\n        .filter((m) => m.visible)\n        .map((m) => ({ index: periods.indexOf(m.id), label: m.id }))\n        .filter((c) => c.index >= 0),\n    [managed, periods],\n  );\n\n  /* The freeze-line cues. One hook across the system — see\n     `useScrollEdges` for why this is an attribute rather than state. */\n  const scrollerRef = useScrollEdges<HTMLDivElement>();\n\n  return (\n    <div className={cn(\"w-full\", className)}>\n      {/* The statement header carries the full context. Required for print,\n          and required for the numbers to mean anything. */}\n      <header className=\"mb-4\">\n        <h2 className=\"font-display text-title-lg\">{header.entity}</h2>\n        <p className=\"text-title-sm font-medium\">{header.statement}</p>\n        <p className=\"text-body text-text-secondary\">{header.periodLabel}</p>\n        <p className=\"text-caption text-text-tertiary\">\n          {header.basis === \"cash\" ? \"Cash basis\" : \"Accrual basis\"} ·{\" \"}\n          {header.framework === \"ifrs\" ? \"IFRS\" : \"US GAAP\"} · {header.currency}\n          {scaleLabel ? ` · ${scaleLabel}` : \"\"} · Generated {formatDate(header.generatedAt)}\n          {header.provenance ? ` · ${header.provenance}` : \"\"}\n        </p>\n      </header>\n\n      {columnManager && periods.length > 1 ? (\n        <div className=\"mb-2 flex items-center gap-2\">\n          <ColumnManager columns={managed} onChange={setManaged} />\n          {managed.length - visible.length > 0 ? (\n            <button\n              className=\"text-caption text-text-secondary underline decoration-dotted underline-offset-2 hover:text-text-primary\"\n              onClick={() => setManaged((c) => c.map((m) => ({ ...m, visible: true })))}\n              type=\"button\"\n            >\n              {managed.length - visible.length} period\n              {managed.length - visible.length === 1 ? \"\" : \"s\"} hidden — show all\n            </button>\n          ) : null}\n        </div>\n      ) : null}\n\n      <div\n        className={cn(\n          \"ledger-statement-grid overflow-auto rounded-xl border border-border bg-surface scrollbar-thin\",\n        )}\n        ref={scrollerRef}\n        style={maxHeight ? { maxHeight } : undefined}\n      >\n        <table className=\"w-full min-w-[640px]\">\n          <caption className=\"sr-only\">\n            {header.statement} for {header.entity}, {header.periodLabel}, {header.basis} basis, in{\" \"}\n            {header.currency}\n          </caption>\n          {/* The freeze lives on the cells, not on the `thead`. A sticky\n              `thead` in a `border-collapse: separate` table offsets its row\n              from the scroller's top edge by the row's own leading, and the\n              rows underneath show through the gap — which is the one thing a\n              frozen header may not do. The cells stick individually instead;\n              the CSS is in `.ledger-statement-grid`. */}\n          <thead>\n            <tr className=\"border-b border-border-subtle\">\n              <th\n                className=\"sticky left-0 z-sticky min-w-[220px] bg-surface px-6 py-3 text-left text-label font-medium text-text-secondary\"\n                scope=\"col\"\n              >\n                Account\n              </th>\n              {visible.map(({ label: p, index: i }) => (\n                <th\n                  className=\"min-w-[120px] px-3 py-3 text-right text-label font-medium text-text-secondary\"\n                  key={p}\n                  scope=\"col\"\n                >\n                  {p}\n                  {restatedColumns.includes(i) ? (\n                    <span className=\"block text-caption font-normal text-warning\">Restated</span>\n                  ) : null}\n                </th>\n              ))}\n              {showVariance ? (\n                <>\n                  <th\n                    className=\"min-w-[120px] px-3 py-3 text-right text-label font-medium text-text-secondary\"\n                    scope=\"col\"\n                  >\n                    Change\n                  </th>\n                  <th\n                    className=\"min-w-[100px] px-6 py-3 text-right text-label font-medium text-text-secondary\"\n                    scope=\"col\"\n                  >\n                    %\n                  </th>\n                </>\n              ) : null}\n            </tr>\n          </thead>\n          <tbody>\n            {flat.map((line) => (\n              <StatementRowInner\n                collapsed={collapsed.has(line.id)}\n                currency={header.currency}\n                key={line.id}\n                line={line}\n                materiality={materiality}\n                onDrilldown={onDrilldown}\n                onToggle={() => toggle(line.id)}\n                periods={visible}\n                showVariance={showVariance}\n              />\n            ))}\n          </tbody>\n        </table>\n      </div>\n\n      {roundingNote ? <p className=\"mt-2 text-caption text-text-tertiary\">† {roundingNote}</p> : null}\n    </div>\n  );\n}\n\nfunction flatten(lines: StatementLine[], collapsed: Set<string>, out: StatementLine[] = []): StatementLine[] {\n  for (const line of lines) {\n    out.push(line);\n    if (line.children && !collapsed.has(line.id)) flatten(line.children, collapsed, out);\n  }\n  return out;\n}\n\n/** A period column that survived the column manager, with its source index. */\ntype VisibleColumn = { label: string; index: number };\n\nfunction StatementRowInner({\n  line,\n  periods,\n  currency,\n  collapsed,\n  onToggle,\n  showVariance,\n  materiality,\n  onDrilldown,\n}: {\n  line: StatementLine;\n  periods: VisibleColumn[];\n  currency: string;\n  collapsed: boolean;\n  onToggle: () => void;\n  showVariance?: boolean;\n  materiality?: { absolute: number; relative: number };\n  onDrilldown?: (line: StatementLine, target: DrilldownTarget) => void;\n}) {\n  const isTotal = line.variant === \"total\";\n  const isSubtotal = line.variant === \"subtotal\";\n  const isSection = line.variant === \"section\";\n  const isMargin = line.variant === \"margin\";\n  const hasChildren = Boolean(line.children?.length);\n\n  // The rule line is invisible to a screen reader, so the row header text\n  // carries the subtotal/total status.\n  const rowLabel = isTotal\n    ? `${line.label}, grand total`\n    : isSubtotal\n      ? `${line.label}, subtotal`\n      : line.label;\n\n  const indent = 24 * (line.level - 1);\n\n  return (\n    <>\n      {/* The single rule above a subtotal spans each period column\n          independently, with the gutters unruled. */}\n      {(isSubtotal || isTotal) && !isSection ? (\n        <tr aria-hidden=\"true\" data-row-variant={line.variant}>\n          <td className=\"sticky left-0 bg-surface\" />\n          {periods.map((p) => (\n            <td className=\"px-3\" key={p.label}>\n              <Rule variant=\"subtotal\" />\n            </td>\n          ))}\n          {showVariance ? (\n            <>\n              <td className=\"px-3\">\n                <Rule variant=\"subtotal\" />\n              </td>\n              <td className=\"px-6\" />\n            </>\n          ) : null}\n        </tr>\n      ) : null}\n\n      <tr\n        className={cn(\n          \"border-b border-border-subtle\",\n          isSection && \"border-t border-border-subtle\",\n          // Never both a rule and a background fill on a total row.\n          \"transition-colors duration-1 ease-standard hover:bg-surface-hover\",\n        )}\n        data-row-variant={line.variant}\n      >\n        <th\n          className={cn(\n            \"sticky left-0 z-sticky bg-[inherit] py-3 pr-4 text-left font-normal\",\n            isSection && \"text-title-sm font-medium\",\n            (isTotal || isSubtotal) && \"font-medium\",\n            isMargin && \"text-text-secondary\",\n          )}\n          data-level={line.level}\n          scope=\"row\"\n          style={{ paddingLeft: 24 + indent }}\n        >\n          <span className=\"flex items-center gap-1.5\">\n            {hasChildren ? (\n              <button\n                aria-controls={`${line.id}-children`}\n                aria-expanded={!collapsed}\n                aria-label={`${collapsed ? \"Expand\" : \"Collapse\"} ${line.label}`}\n                className=\"press grid size-5 shrink-0 place-items-center rounded-xs text-text-tertiary hover:bg-surface-hover\"\n                onClick={onToggle}\n                type=\"button\"\n              >\n                <ChevronRightIcon\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"size-3.5 transition-transform duration-2 ease-standard\",\n                    !collapsed && \"rotate-90\",\n                  )}\n                />\n              </button>\n            ) : (\n              <span aria-hidden=\"true\" className=\"size-5 shrink-0\" />\n            )}\n            <span className=\"no-hyphens\">{rowLabel}</span>\n          </span>\n        </th>\n\n        {periods.map((p, position) => {\n          const value = line.values[p.index] ?? null;\n          const percent = line.percentValues?.[p.index];\n          /* The comparison is the next column along, which in a comparative\n             statement is the older period — the one the reader is implicitly\n             asking \"against what\" about. */\n          const against = periods[position + 1];\n          const prior = against ? (line.values[against.index] ?? null) : null;\n          const change = periodChange(value, prior, line.polarity ?? \"standard\");\n\n          /* Any figure opens — the affordance is the figure itself, tinted on\n             hover, rather than a \"Drill down\" link repeated down the label\n             column. Percent and n/a cells have nothing behind them. */\n          const drillable =\n            Boolean(onDrilldown) && line.drilldownable !== false && !isSection && !isMargin && value !== null;\n\n          const figure = isSection ? null : isMargin && percent != null ? (\n            <Percent signed={false} value={{ kind: \"points\", value: percent }} />\n          ) : value === null ? (\n            <span className=\"numeric relative text-figure-sm text-text-tertiary\">\n              <span aria-hidden=\"true\">n/a</span>\n              <span className=\"sr-only select-none\">not available</span>\n            </span>\n          ) : (\n            <Money accounting size={isTotal ? \"md\" : \"sm\"} statement value={{ amount: value, currency }} />\n          );\n\n          return (\n            <td className={cn(\"group relative text-right\", drillable ? \"p-0\" : \"px-3 py-3\")} key={p.label}>\n              {drillable ? (\n                <button\n                  aria-label={`${line.label}, ${p.label} — show the entries behind this figure`}\n                  className=\"ledger-figure px-3 py-3\"\n                  onClick={() => onDrilldown?.(line, { period: p.label, periodIndex: p.index, value })}\n                  type=\"button\"\n                >\n                  {figure}\n                </button>\n              ) : (\n                figure\n              )}\n              {/* The hover answer. CSS-only, so a four-hundred-row statement\n                  does not re-render on every pointer move — and hidden from\n                  assistive technology, because the same change is already in\n                  the variance columns and in the row's own reading. */}\n              {!isSection && change.absolute != null && against ? (\n                <span aria-hidden=\"true\" className=\"ledger-grid-chip hidden text-caption group-hover:block\">\n                  <span\n                    className={cn(\n                      \"numeric font-medium\",\n                      change.direction === \"favourable\"\n                        ? \"text-positive\"\n                        : change.direction === \"unfavourable\"\n                          ? \"text-negative\"\n                          : \"text-text-secondary\",\n                    )}\n                  >\n                    {change.relative == null\n                      ? change.absolute === 0\n                        ? \"no change\"\n                        : `${change.absolute > 0 ? \"+\" : \"−\"}${formatMoney({\n                            amount: Math.abs(change.absolute),\n                            currency,\n                          })}`\n                      : `${change.relative > 0 ? \"+\" : change.relative < 0 ? \"−\" : \"\"}${Math.abs(\n                          change.relative,\n                        ).toFixed(1)}%`}\n                  </span>{\" \"}\n                  <span className=\"text-text-tertiary\">vs {against.label}</span>\n                </span>\n              ) : null}\n            </td>\n          );\n        })}\n\n        {showVariance ? (\n          <VarianceCell\n            currency={currency}\n            current={periods[0] ? (line.values[periods[0].index] ?? null) : null}\n            hidden={isSection}\n            materiality={materiality}\n            polarity={line.polarity ?? \"standard\"}\n            prior={periods[1] ? (line.values[periods[1].index] ?? null) : null}\n          />\n        ) : null}\n      </tr>\n\n      {/* The double rule below a grand total. Nothing is summed past here. */}\n      {isTotal ? (\n        <tr aria-hidden=\"true\">\n          <td className=\"sticky left-0 bg-surface\" />\n          {periods.map((p) => (\n            <td className=\"px-3\" key={p.label}>\n              <Rule variant=\"total\" />\n            </td>\n          ))}\n          {showVariance ? (\n            <>\n              <td className=\"px-3\">\n                <Rule variant=\"total\" />\n              </td>\n              <td className=\"px-6\" />\n            </>\n          ) : null}\n        </tr>\n      ) : null}\n    </>\n  );\n}\n",
      "path": "components/ledger/statement-table.tsx",
      "target": "components/ledger/statement-table.tsx",
      "type": "registry:component"
    }
  ],
  "name": "statement-table",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/cn",
    "@lopifi/column-manager",
    "@lopifi/fiscal-period",
    "@lopifi/format-money",
    "@lopifi/icons",
    "@lopifi/money",
    "@lopifi/money.types",
    "@lopifi/percent",
    "@lopifi/period-change",
    "@lopifi/rule",
    "@lopifi/use-scroll-edges",
    "@lopifi/variance-cell"
  ],
  "title": "StatementTable",
  "type": "registry:component"
}
