{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "ledger-tables",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "Double-entry with debit/credit columns and a balance check.",
  "files": [
    {
      "content": "import * as React from \"react\";\nimport { useScrollEdges } from \"@/hooks/ledger/use-scroll-edges\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { formatDateShort } from \"@/lib/ledger/fiscal-period\";\nimport { byMonthDescending, groupRows, monthBucket, type RowGroup } from \"@/lib/ledger/group-rows\";\nimport type { MoneyValue } from \"@/lib/ledger/money.types\";\nimport { CheckIcon, ExclamationTriangleIcon } from \"@/components/ledger/icons\";\nimport { Money } from \"@/components/ledger/money\";\nimport { Rule } from \"@/components/ledger/rule\";\n\nexport type LedgerLine = {\n  id: string;\n  date: Date;\n  account: string;\n  accountNumber: string;\n  memo?: string;\n  /** Two separate columns. Never one signed column. */\n  debit?: MoneyValue;\n  credit?: MoneyValue;\n};\n\nexport interface LedgerTableProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Whether the sign convention in force is stated in the UI, not assumed. */\n  basisNote?: string;\n  /**\n   * Cut the lines into groups, each with its own header and debit/credit\n   * subtotals.\n   *\n   * `undefined` is a flat ledger, which is what this should open as — a\n   * general ledger is posted in date order and that order is information.\n   * Grouping by account is the second thing anyone does with it, and grouping\n   * by month is how a year of one account gets read.\n   *\n   * The subtotals are summed from the lines in each group. That is safe here\n   * in a way it would not be for the grand totals — which stay passed in,\n   * because they are a claim about the whole set and only the backend knows\n   * the whole set.\n   */\n  groupBy?: LedgerGroupKey;\n  lines: LedgerLine[];\n  /**\n   * Caps the scroller so the header and the date/account pair can freeze.\n   * Without a height the page scrolls instead and there is nothing for the\n   * frozen header to stick to.\n   */\n  maxHeight?: number;\n  totalCredit: MoneyValue;\n  /** Passed in, already computed. */\n  totalDebit: MoneyValue;\n}\n\n/** What a ledger can be cut by. Two, and both are questions people ask. */\nexport type LedgerGroupKey = \"account\" | \"month\";\n\nexport const LEDGER_GROUPS: { key: LedgerGroupKey; label: string; hint: string }[] = [\n  { hint: \"Debits and credits per account\", key: \"account\", label: \"Account\" },\n  { hint: \"The year, one month at a time\", key: \"month\", label: \"Month\" },\n];\n\n/**\n * Double-entry. Debit and credit as two separate columns — collapsing them\n * destroys the reader's ability to verify, which is the entire purpose of\n * double-entry.\n *\n * The balance check is displayed, never assumed. A failing check is a\n * prominent error state, not a silent discrepancy.\n */\n/* The account column freezes to the right of the date column, so its sticky\n   offset is that column's width. Declared once rather than at four call\n   sites, because the two have to agree or the pane shears. */\nconst FREEZE_ACCOUNT = { \"--ledger-freeze-left\": \"92px\" } as React.CSSProperties;\n\nexport const LedgerTable = React.forwardRef<HTMLDivElement, LedgerTableProps>(\n  ({ className, lines, totalDebit, totalCredit, basisNote, maxHeight = 520, groupBy, ...props }, ref) => {\n    const scroller = useScrollEdges<HTMLDivElement>();\n\n    /* Debit and credit are subtotalled separately and never netted — the whole\n       reason they are two columns is that the reader verifies them against\n       each other, and a group header showing one signed number takes that\n       away. `amountOf` is left off for the same reason: there is no single\n       \"amount\" on a ledger line to sum. */\n    const groups = React.useMemo(() => {\n      if (!groupBy) return null;\n      return groupRows(lines, {\n        compare: groupBy === \"month\" ? byMonthDescending : undefined,\n        keyOf: (l) =>\n          groupBy === \"account\"\n            ? { key: l.accountNumber, label: `${l.accountNumber} ${l.account}` }\n            : monthBucket(l.date),\n      });\n    }, [lines, groupBy]);\n\n    const balanced = Number(totalDebit.amount) === Number(totalCredit.amount);\n    const difference = Number(totalDebit.amount) - Number(totalCredit.amount);\n\n    return (\n      <div className={cn(\"rounded-xl border border-border bg-surface\", className)} ref={ref} {...props}>\n        {/* Outside the scroller. A basis note that scrolls away from the\n            figures it qualifies is a basis note nobody reads. */}\n        <div className=\"px-6 pb-4 pt-6\">\n          <p className=\"text-title-sm font-medium\">General ledger detail</p>\n          {basisNote ? <p className=\"text-caption text-text-secondary\">{basisNote}</p> : null}\n        </div>\n        <div className=\"ledger-frozen scrollbar-thin\" ref={scroller} style={{ maxHeight }}>\n          <table className=\"w-full min-w-[720px]\">\n            <caption className=\"sr-only\">General ledger detail{basisNote ? `. ${basisNote}` : \"\"}</caption>\n            <thead>\n              <tr className=\"border-b border-border-subtle\">\n                <th\n                  className=\"w-[92px] px-6 py-3 text-left text-label font-medium text-text-secondary\"\n                  data-frozen=\"left inner\"\n                  scope=\"col\"\n                >\n                  Date\n                </th>\n                <th\n                  className=\"px-3 py-3 text-left text-label font-medium text-text-secondary\"\n                  data-frozen=\"left\"\n                  scope=\"col\"\n                  style={FREEZE_ACCOUNT}\n                >\n                  Account\n                </th>\n                <th className=\"px-3 py-3 text-right text-label font-medium text-text-secondary\" scope=\"col\">\n                  Debit\n                </th>\n                <th className=\"px-6 py-3 text-right text-label font-medium text-text-secondary\" scope=\"col\">\n                  Credit\n                </th>\n              </tr>\n            </thead>\n            <tbody>\n              {groups\n                ? groups.map((group) => (\n                    <React.Fragment key={group.key}>\n                      <LedgerGroupHeader currency={totalDebit.currency} group={group} />\n                      {group.rows.map((l) => (\n                        <tr className=\"border-b border-border-subtle\" key={l.id}>\n                          <td\n                            className=\"numeric px-6 py-3 text-figure-sm text-text-secondary\"\n                            data-frozen=\"left inner\"\n                          >\n                            {formatDateShort(l.date)}\n                          </td>\n                          <th className=\"px-3 py-3 text-left font-normal\" scope=\"row\" style={FREEZE_ACCOUNT}>\n                            <span className=\"reference-numeric text-caption text-text-tertiary\">\n                              {l.accountNumber}\n                            </span>{\" \"}\n                            <span className=\"text-body\">{l.account}</span>\n                            {l.memo ? (\n                              <span className=\"block text-caption text-text-secondary\">{l.memo}</span>\n                            ) : null}\n                          </th>\n                          <td className=\"px-3 py-3 text-right align-top\">\n                            {l.debit ? <Money accounting value={l.debit} /> : null}\n                          </td>\n                          <td className=\"px-6 py-3 text-right align-top\">\n                            {l.credit ? <Money accounting value={l.credit} /> : null}\n                          </td>\n                        </tr>\n                      ))}\n                    </React.Fragment>\n                  ))\n                : lines.map((l) => (\n                    <tr className=\"border-b border-border-subtle\" key={l.id}>\n                      <td\n                        className=\"numeric px-6 py-3 text-figure-sm text-text-secondary\"\n                        data-frozen=\"left inner\"\n                      >\n                        {formatDateShort(l.date)}\n                      </td>\n                      <th className=\"px-3 py-3 text-left font-normal\" scope=\"row\" style={FREEZE_ACCOUNT}>\n                        <span className=\"reference-numeric text-caption text-text-tertiary\">\n                          {l.accountNumber}\n                        </span>{\" \"}\n                        <span className=\"text-body\">{l.account}</span>\n                        {l.memo ? (\n                          <span className=\"block text-caption text-text-secondary\">{l.memo}</span>\n                        ) : null}\n                      </th>\n                      <td className=\"px-3 py-3 text-right align-top\">\n                        {l.debit ? <Money accounting value={l.debit} /> : null}\n                      </td>\n                      <td className=\"px-6 py-3 text-right align-top\">\n                        {l.credit ? <Money accounting value={l.credit} /> : null}\n                      </td>\n                    </tr>\n                  ))}\n            </tbody>\n            <tfoot>\n              <tr>\n                <td colSpan={2} data-frozen=\"left inner\" />\n                <td className=\"px-3\">\n                  <Rule variant=\"subtotal\" />\n                </td>\n                <td className=\"px-6\">\n                  <Rule variant=\"subtotal\" />\n                </td>\n              </tr>\n              <tr>\n                <th\n                  className=\"px-6 py-3 text-left text-label font-medium\"\n                  colSpan={2}\n                  data-frozen=\"left inner\"\n                  scope=\"row\"\n                >\n                  Totals\n                </th>\n                <td className=\"px-3 py-3 text-right\">\n                  <Money accounting size=\"md\" value={totalDebit} />\n                </td>\n                <td className=\"px-6 py-3 text-right\">\n                  <Money accounting size=\"md\" value={totalCredit} />\n                </td>\n              </tr>\n              <tr>\n                <td colSpan={2} data-frozen=\"left inner\" />\n                <td className=\"px-3\">\n                  <Rule variant=\"total\" />\n                </td>\n                <td className=\"px-6\">\n                  <Rule variant=\"total\" />\n                </td>\n              </tr>\n            </tfoot>\n          </table>\n        </div>\n\n        <div\n          className={cn(\n            \"m-6 mt-4 flex items-center gap-2 rounded-sm px-3 py-2\",\n            balanced ? \"bg-positive-subtle text-positive\" : \"bg-negative-subtle text-negative\",\n          )}\n          role={balanced ? undefined : \"alert\"}\n        >\n          {balanced ? (\n            <CheckIcon aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n          ) : (\n            <ExclamationTriangleIcon aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n          )}\n          <span className=\"text-body\">\n            {balanced ? (\n              \"Debits equal credits.\"\n            ) : (\n              <>\n                Out of balance by{\" \"}\n                <Money\n                  tone=\"inherit\"\n                  value={{ amount: Math.abs(difference), currency: totalDebit.currency }}\n                />\n                . This entry cannot post.\n              </>\n            )}\n          </span>\n        </div>\n      </div>\n    );\n  },\n);\nLedgerTable.displayName = \"LedgerTable\";\n\n/**\n * A group's own line, with its two subtotals in the columns they belong to.\n *\n * `colSpan={2}` over the frozen pair, marked `inner` so the seam is drawn once\n * at the outer edge and not through the middle of the frozen columns.\n */\nfunction LedgerGroupHeader({ group, currency }: { group: RowGroup<LedgerLine>; currency: string }) {\n  const debit = group.rows.reduce((sum, l) => sum + Number(l.debit?.amount ?? 0), 0);\n  const credit = group.rows.reduce((sum, l) => sum + Number(l.credit?.amount ?? 0), 0);\n  return (\n    <tr className=\"border-b border-border bg-surface-sunken\">\n      <th\n        className=\"px-6 py-2 text-left text-label font-medium\"\n        colSpan={2}\n        data-frozen=\"left inner\"\n        scope=\"colgroup\"\n      >\n        {group.label}{\" \"}\n        <span className=\"numeric font-normal text-text-tertiary\">{group.count.toLocaleString()}</span>\n      </th>\n      <td className=\"px-3 py-2 text-right\">\n        <Money accounting value={{ amount: debit, currency }} />\n      </td>\n      <td className=\"px-6 py-2 text-right\">\n        <Money accounting value={{ amount: credit, currency }} />\n      </td>\n    </tr>\n  );\n}\n",
      "path": "components/ledger/ledger-table.tsx",
      "target": "components/ledger/ledger-table.tsx",
      "type": "registry:component"
    }
  ],
  "name": "ledger-table",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/cn",
    "@lopifi/fiscal-period",
    "@lopifi/group-rows",
    "@lopifi/icons",
    "@lopifi/money",
    "@lopifi/money.types",
    "@lopifi/rule",
    "@lopifi/use-scroll-edges"
  ],
  "title": "LedgerTable",
  "type": "registry:component"
}
