{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "ledger-tables",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "AR/AP buckets, 0-30-60-90+.",
  "files": [
    {
      "content": "import * as React from \"react\";\nimport { useScrollEdges } from \"@/hooks/ledger/use-scroll-edges\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { CheckIcon, ExclamationTriangleIcon } from \"@/components/ledger/icons\";\nimport { Money } from \"@/components/ledger/money\";\nimport { Rule } from \"@/components/ledger/rule\";\n\nexport const AGING_BUCKETS = [\"current\", \"1-30\", \"31-60\", \"61-90\", \"90+\"] as const;\nexport type Bucket = (typeof AGING_BUCKETS)[number];\n\nexport type AgingRow = {\n  id: string;\n  name: string;\n  /** Minor units per bucket. */\n  buckets: Record<Bucket, number>;\n  total: number;\n};\n\nexport interface AgingTableProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  columnTotals: Record<Bucket, number>;\n  currency: string;\n  grandTotal: number;\n  kind: \"receivable\" | \"payable\";\n  /**\n   * Caps the scroller so the header and the party column can freeze. Without\n   * a height the page scrolls instead and there is nothing for the frozen\n   * header to stick to.\n   */\n  maxHeight?: number;\n  rows: AgingRow[];\n}\n\nconst LABELS: Record<Bucket, string> = {\n  \"1-30\": \"1–30 days\",\n  \"31-60\": \"31–60 days\",\n  \"61-90\": \"61–90 days\",\n  \"90+\": \"90+ days\",\n  current: \"Current\",\n};\n\n/**\n * The intersection cell is proven both ways — rows and columns must agree,\n * and the component asserts it.\n *\n * Bucket severity uses a sequential lightness ramp within one hue, not a red\n * gradient, so it survives greyscale and the signal palette.\n */\nexport const AgingTable = React.forwardRef<HTMLDivElement, AgingTableProps>(\n  ({ className, rows, currency, kind, columnTotals, grandTotal, maxHeight = 520, ...props }, ref) => {\n    const scroller = useScrollEdges<HTMLDivElement>();\n    const rowSum = rows.reduce((s, r) => s + r.total, 0);\n    const colSum = AGING_BUCKETS.reduce((s, b) => s + columnTotals[b], 0);\n    const proven = rowSum === grandTotal && colSum === grandTotal;\n\n    return (\n      <div className={cn(\"rounded-xl border border-border bg-surface\", className)} ref={ref} {...props}>\n        {/* The caption lives outside the scroller: a title that scrolls away\n            from the table it titles is a title nobody reads. */}\n        <p className=\"px-6 pt-6 text-title-sm font-medium\">\n          {kind === \"receivable\" ? \"Accounts receivable aging\" : \"Accounts payable aging\"}\n        </p>\n        <div className=\"ledger-frozen scrollbar-thin mt-4\" ref={scroller} style={{ maxHeight }}>\n          <table className=\"w-full min-w-[760px]\">\n            <caption className=\"sr-only\">\n              {kind === \"receivable\" ? \"Accounts receivable aging\" : \"Accounts payable aging\"}\n            </caption>\n            <thead>\n              <tr className=\"border-b border-border-subtle\">\n                <th className=\"px-6 py-3 text-left text-label font-medium text-text-secondary\" scope=\"col\">\n                  {kind === \"receivable\" ? \"Customer\" : \"Vendor\"}\n                </th>\n                {AGING_BUCKETS.map((b, i) => (\n                  <th\n                    /* Primary, not secondary: the deepest tint drops secondary\n                       text to 4.4:1. */\n                    className=\"px-3 py-3 text-right text-label font-medium text-text-primary\"\n                    key={b}\n                    scope=\"col\"\n                    style={{\n                      // Sequential lightness within one hue — magnitude, not alarm.\n                      background: `oklch(${0.985 - i * 0.028} ${0.004 + i * 0.012} 265)`,\n                    }}\n                  >\n                    {LABELS[b]}\n                  </th>\n                ))}\n                <th className=\"px-6 py-3 text-right text-label font-medium text-text-secondary\" scope=\"col\">\n                  Total\n                </th>\n              </tr>\n            </thead>\n            <tbody>\n              {rows.map((r) => (\n                <tr className=\"border-b border-border-subtle\" key={r.id}>\n                  <th className=\"px-6 py-3 text-left text-body font-normal\" scope=\"row\">\n                    {r.name}\n                  </th>\n                  {AGING_BUCKETS.map((b) => (\n                    <td className=\"px-3 py-3 text-right\" key={b}>\n                      {r.buckets[b] === 0 ? (\n                        <span className=\"numeric relative text-figure-sm text-text-tertiary\">\n                          <span aria-hidden=\"true\">—</span>\n                          <span className=\"sr-only select-none\">zero</span>\n                        </span>\n                      ) : (\n                        <Money accounting value={{ amount: r.buckets[b], currency }} />\n                      )}\n                    </td>\n                  ))}\n                  <td className=\"px-6 py-3 text-right\">\n                    <Money accounting value={{ amount: r.total, currency }} />\n                  </td>\n                </tr>\n              ))}\n            </tbody>\n            <tfoot>\n              <tr>\n                <td data-frozen=\"left\" />\n                {AGING_BUCKETS.map((b) => (\n                  <td className=\"px-3\" key={b}>\n                    <Rule variant=\"subtotal\" />\n                  </td>\n                ))}\n                <td className=\"px-6\">\n                  <Rule variant=\"subtotal\" />\n                </td>\n              </tr>\n              <tr>\n                <th className=\"px-6 py-3 text-left text-label font-medium\" scope=\"row\">\n                  Total\n                </th>\n                {AGING_BUCKETS.map((b) => (\n                  <td className=\"px-3 py-3 text-right\" key={b}>\n                    <Money accounting size=\"md\" value={{ amount: columnTotals[b], currency }} />\n                  </td>\n                ))}\n                <td className=\"px-6 py-3 text-right\">\n                  <Money accounting size=\"md\" value={{ amount: grandTotal, currency }} />\n                </td>\n              </tr>\n              <tr>\n                <td colSpan={6} />\n                <td className=\"px-6\">\n                  <Rule variant=\"total\" />\n                </td>\n              </tr>\n            </tfoot>\n          </table>\n        </div>\n\n        <p\n          className={cn(\n            \"m-6 mt-4 flex items-center gap-2 rounded-sm px-3 py-2 text-body\",\n            proven ? \"bg-positive-subtle text-positive\" : \"bg-negative-subtle text-negative\",\n          )}\n          role={proven ? undefined : \"alert\"}\n        >\n          {proven ? (\n            <CheckIcon aria-hidden=\"true\" className=\"size-4\" />\n          ) : (\n            <ExclamationTriangleIcon aria-hidden=\"true\" className=\"size-4\" />\n          )}\n          {proven\n            ? \"Row totals and column totals agree with the grand total.\"\n            : \"Row totals and column totals disagree — this aging cannot be relied on.\"}\n        </p>\n      </div>\n    );\n  },\n);\nAgingTable.displayName = \"AgingTable\";\n",
      "path": "components/ledger/aging-table.tsx",
      "target": "components/ledger/aging-table.tsx",
      "type": "registry:component"
    }
  ],
  "name": "aging-table",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/cn",
    "@lopifi/icons",
    "@lopifi/money",
    "@lopifi/rule",
    "@lopifi/use-scroll-edges"
  ],
  "title": "AgingTable",
  "type": "registry:component"
}
