{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "accounting-core-gl",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "Hierarchy, statement mapping, unsupported-account flags.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useScrollEdges } from \"@/hooks/ledger/use-scroll-edges\";\nimport type { Account } from \"@/lib/ledger/accounting.types\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { FROZEN_LABEL } from \"@/lib/ledger/frozen-label\";\nimport type { MoneyValue } from \"@/lib/ledger/money.types\";\nimport { ChevronRightIcon, ExclamationTriangleIcon } from \"@/components/ledger/icons\";\nimport { Money } from \"@/components/ledger/money\";\n\nexport interface ChartOfAccountsProps {\n  accounts: Account[];\n  balances: Record<string, MoneyValue>;\n  className?: string;\n  /**\n   * Caps the scroller so the header and the account column can freeze. Without a\n   * height the page scrolls instead and there is nothing for the frozen\n   * header to stick to.\n   */\n  maxHeight?: number;\n}\n\n/**\n * Hierarchy capped at 5 levels — deeper is a chart-of-accounts problem, not\n * a UI problem.\n *\n * Accounts with no supporting-schedule mapping are surfaced, because that\n * absence is itself a finding.\n */\nexport function ChartOfAccounts({ accounts, balances, maxHeight = 520, className }: ChartOfAccountsProps) {\n  const scroller = useScrollEdges<HTMLDivElement>();\n  const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());\n\n  const byParent = React.useMemo(() => {\n    const map = new Map<string | undefined, Account[]>();\n    for (const a of accounts) {\n      const list = map.get(a.parentId) ?? [];\n      list.push(a);\n      map.set(a.parentId, list);\n    }\n    return map;\n  }, [accounts]);\n\n  const unsupported = accounts.filter(\n    (a) => (a.type === \"asset\" || a.type === \"liability\") && a.isPostable && !a.scheduleType,\n  );\n\n  const rows: { account: Account; depth: number }[] = [];\n  const walk = (parentId: string | undefined, depth: number) => {\n    if (depth > 5) return;\n    for (const a of byParent.get(parentId) ?? []) {\n      rows.push({ account: a, depth });\n      if (!collapsed.has(a.id)) walk(a.id, depth + 1);\n    }\n  };\n  walk(undefined, 0);\n\n  return (\n    <div className={cn(\"space-y-3\", className)}>\n      {unsupported.length > 0 ? (\n        <p className=\"flex items-start gap-2 rounded-sm bg-warning-subtle px-3 py-2 text-body\">\n          <ExclamationTriangleIcon aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0 text-warning\" />\n          <span>\n            <span className=\"numeric font-medium\">{unsupported.length}</span> balance-sheet accounts have no\n            supporting schedule mapped: {unsupported.map((a) => `${a.number} ${a.name}`).join(\", \")}. That\n            absence is itself a finding.\n          </span>\n        </p>\n      ) : null}\n\n      <div\n        className=\"ledger-frozen scrollbar-thin rounded-xl border border-border bg-surface\"\n        ref={scroller}\n        style={{ maxHeight }}\n      >\n        <table className=\"w-full min-w-[820px]\">\n          <caption className=\"sr-only\">Chart of accounts</caption>\n          <thead>\n            <tr className=\"border-b border-border-subtle\">\n              {[\"Account\", \"Type\", \"Normal\", \"Statement line\", \"Schedule\", \"Balance\"].map((h, i) => (\n                <th\n                  className={cn(\n                    \"px-3 py-3 text-label font-medium text-text-secondary\",\n                    i === 5 ? \"text-right pr-6\" : \"text-left\",\n                    i === 0 && `pl-6 ${FROZEN_LABEL}`,\n                  )}\n                  key={h}\n                  scope=\"col\"\n                >\n                  {h}\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody>\n            {rows.map(({ account: a, depth }) => {\n              const hasChildren = (byParent.get(a.id)?.length ?? 0) > 0;\n              return (\n                <tr className=\"border-b border-border-subtle\" key={a.id}>\n                  <th\n                    className={cn(FROZEN_LABEL, \"py-2 pr-3 text-left font-normal\")}\n                    data-level={depth + 1}\n                    scope=\"row\"\n                    style={{ paddingLeft: 24 + depth * 24 }}\n                  >\n                    <span className=\"flex items-center gap-1.5\">\n                      {hasChildren ? (\n                        <button\n                          aria-expanded={!collapsed.has(a.id)}\n                          aria-label={`${collapsed.has(a.id) ? \"Expand\" : \"Collapse\"} ${a.name}`}\n                          className=\"press grid size-5 place-items-center rounded-xs text-text-tertiary hover:bg-surface-hover\"\n                          onClick={() =>\n                            setCollapsed((prev) => {\n                              const next = new Set(prev);\n                              if (next.has(a.id)) next.delete(a.id);\n                              else next.add(a.id);\n                              return next;\n                            })\n                          }\n                          type=\"button\"\n                        >\n                          <ChevronRightIcon\n                            className={cn(\n                              \"size-3.5 transition-transform duration-2 ease-standard\",\n                              !collapsed.has(a.id) && \"rotate-90\",\n                            )}\n                          />\n                        </button>\n                      ) : (\n                        <span aria-hidden=\"true\" className=\"size-5\" />\n                      )}\n                      <span className=\"reference-numeric text-caption text-text-tertiary\">{a.number}</span>\n                      <span className={cn(\"text-body\", !a.isPostable && \"font-medium\")}>{a.name}</span>\n                      {a.isContra ? <span className=\"text-caption text-text-tertiary\">(contra)</span> : null}\n                    </span>\n                  </th>\n                  <td className=\"px-3 py-2 text-caption capitalize text-text-secondary\">{a.type}</td>\n                  <td className=\"px-3 py-2 text-caption text-text-secondary\">\n                    {a.normalBalance === \"debit\" ? \"Dr\" : \"Cr\"}\n                  </td>\n                  <td className=\"px-3 py-2 text-caption text-text-secondary\">{a.statementMapping}</td>\n                  <td className=\"px-3 py-2 text-caption text-text-secondary\">\n                    {a.scheduleType ?? <span className=\"text-warning\">Unsupported</span>}\n                  </td>\n                  <td className=\"py-2 pl-3 pr-6 text-right\">\n                    {balances[a.id] ? (\n                      <Money accounting value={balances[a.id]} />\n                    ) : (\n                      <span className=\"numeric relative text-figure-sm text-text-tertiary\">\n                        <span className=\"sr-only select-none\">not applicable</span>\n                      </span>\n                    )}\n                  </td>\n                </tr>\n              );\n            })}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n",
      "path": "components/ledger/chart-of-accounts.tsx",
      "target": "components/ledger/chart-of-accounts.tsx",
      "type": "registry:component"
    }
  ],
  "name": "chart-of-accounts",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/accounting.types",
    "@lopifi/cn",
    "@lopifi/frozen-label",
    "@lopifi/icons",
    "@lopifi/money",
    "@lopifi/money.types",
    "@lopifi/use-scroll-edges"
  ],
  "title": "ChartOfAccounts",
  "type": "registry:component"
}
