{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "ledger-tables",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "Two-pane matcher. The match animation.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/ledger/use-media-query\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { formatDateShort } from \"@/lib/ledger/fiscal-period\";\nimport type { MoneyValue } from \"@/lib/ledger/money.types\";\nimport { Button } from \"@/components/ledger/button\";\nimport { CheckIcon, LinkIcon } from \"@/components/ledger/icons\";\nimport { Money } from \"@/components/ledger/money\";\n\nexport type ReconLine = {\n  id: string;\n  date: Date;\n  description: string;\n  amount: MoneyValue;\n  matched?: boolean;\n};\n\nexport type Suggestion = {\n  bankIds: string[];\n  bookIds: string[];\n  /** The basis, not a bare score. */\n  basis: string;\n};\n\nexport interface ReconciliationViewProps {\n  bankLines: ReconLine[];\n  bankTotal: MoneyValue;\n  bookLines: ReconLine[];\n  bookTotal: MoneyValue;\n  className?: string;\n  /** The number the user is trying to get to zero. It never scrolls away. */\n  difference: MoneyValue;\n  onMatch?: (bankIds: string[], bookIds: string[]) => void;\n  suggestions: Suggestion[];\n}\n\n/**\n * Two panes: bank statement lines, book entries. Many-to-one and one-to-many\n * are both supported, because real reconciliation is rarely 1:1.\n *\n * The match animation is one of the four sanctioned moments: highlight →\n * move together → merge with a check. Bulk match runs the sequence once for\n * the group, staggered at 40ms and capped at 240ms.\n */\nexport function ReconciliationView({\n  bankLines,\n  bookLines,\n  suggestions,\n  bankTotal,\n  bookTotal,\n  difference,\n  onMatch,\n  className,\n}: ReconciliationViewProps) {\n  const [bankSel, setBankSel] = React.useState<string[]>([]);\n  const [bookSel, setBookSel] = React.useState<string[]>([]);\n  const [matching, setMatching] = React.useState<string[]>([]);\n  const reduced = useReducedMotion();\n\n  const runMatch = (bankIds: string[], bookIds: string[]) => {\n    if (reduced) {\n      onMatch?.(bankIds, bookIds);\n      return;\n    }\n    setMatching([...bankIds, ...bookIds]);\n    setTimeout(() => {\n      setMatching([]);\n      setBankSel([]);\n      setBookSel([]);\n      onMatch?.(bankIds, bookIds);\n    }, 520);\n  };\n\n  const settled = Number(difference.amount) === 0;\n\n  return (\n    <div className={cn(\"space-y-4\", className)}>\n      {/* The remaining difference is always visible. */}\n      <div\n        className={cn(\n          \"sticky top-0 z-sticky flex flex-wrap items-center justify-between gap-4 rounded-md border px-4 py-3\",\n          settled ? \"border-positive bg-positive-subtle\" : \"border-border bg-surface\",\n        )}\n      >\n        <span className=\"flex items-baseline gap-4\">\n          <span className=\"text-caption text-text-secondary\">\n            Bank <Money accounting size=\"xs\" tone=\"inherit\" value={bankTotal} />\n          </span>\n          <span className=\"text-caption text-text-secondary\">\n            Books <Money accounting size=\"xs\" tone=\"inherit\" value={bookTotal} />\n          </span>\n        </span>\n        <span className=\"flex items-baseline gap-2\">\n          <span className=\"text-label font-medium\">Difference</span>\n          <Money\n            accounting\n            className={settled ? \"text-positive\" : \"text-negative\"}\n            size=\"md\"\n            value={difference}\n          />\n        </span>\n      </div>\n\n      {suggestions.length > 0 ? (\n        <div className=\"rounded-md border border-border bg-surface p-4\">\n          <h3 className=\"text-label font-medium text-text-secondary\">Suggested matches</h3>\n          <ul className=\"mt-2 space-y-2\">\n            {suggestions.map((s, i) => (\n              <li className=\"flex flex-wrap items-center gap-3\" key={i}>\n                <LinkIcon aria-hidden=\"true\" className=\"size-4 shrink-0 text-text-tertiary\" />\n                <span className=\"flex-1 text-body\">{s.basis}</span>\n                <Button onClick={() => runMatch(s.bankIds, s.bookIds)} size=\"sm\" variant=\"primary\">\n                  Accept\n                </Button>\n              </li>\n            ))}\n          </ul>\n        </div>\n      ) : null}\n\n      <div className=\"grid gap-4 lg:grid-cols-2\">\n        <Pane\n          lines={bankLines}\n          matching={matching}\n          onToggle={(id) => setBankSel((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]))}\n          selected={bankSel}\n          title=\"Bank statement\"\n        />\n        <Pane\n          lines={bookLines}\n          matching={matching}\n          onToggle={(id) => setBookSel((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]))}\n          selected={bookSel}\n          title=\"Book entries\"\n        />\n      </div>\n\n      <Button\n        disabled={bankSel.length === 0 || bookSel.length === 0}\n        onClick={() => runMatch(bankSel, bookSel)}\n        variant=\"primary\"\n      >\n        Match {bankSel.length} bank {bankSel.length === 1 ? \"line\" : \"lines\"} to {bookSel.length} book{\" \"}\n        {bookSel.length === 1 ? \"entry\" : \"entries\"}\n      </Button>\n    </div>\n  );\n}\n\nfunction Pane({\n  title,\n  lines,\n  selected,\n  onToggle,\n  matching,\n}: {\n  title: string;\n  lines: ReconLine[];\n  selected: string[];\n  onToggle: (id: string) => void;\n  matching: string[];\n}) {\n  return (\n    <section className=\"overflow-hidden rounded-xl border border-border bg-surface\">\n      <h3 className=\"border-b border-border-subtle px-4 py-3 text-label font-medium text-text-secondary\">\n        {title}\n      </h3>\n      <ul>\n        {lines.map((l, i) => {\n          const isMatching = matching.includes(l.id);\n          return (\n            <li\n              className={cn(\n                \"flex items-center gap-3 border-b border-border-subtle px-4 py-3 last:border-b-0\",\n                selected.includes(l.id) && \"bg-surface-selected\",\n                l.matched && \"opacity-60\",\n              )}\n              key={l.id}\n              style={\n                isMatching\n                  ? {\n                      animation: `ledger-match 320ms var(--ease-out) ${Math.min(i * 40, 240)}ms both`,\n                    }\n                  : undefined\n              }\n            >\n              <input\n                aria-label={`Select ${l.description}`}\n                checked={selected.includes(l.id)}\n                className=\"size-4 accent-[var(--accent)]\"\n                disabled={l.matched}\n                onChange={() => onToggle(l.id)}\n                type=\"checkbox\"\n              />\n              <span className=\"numeric w-16 shrink-0 text-caption text-text-secondary\">\n                {formatDateShort(l.date)}\n              </span>\n              <span className=\"min-w-0 flex-1 truncate text-body\">{l.description}</span>\n              {l.matched ? (\n                <CheckIcon aria-label=\"Matched\" className=\"size-4 shrink-0 text-positive\" />\n              ) : null}\n              <Money accounting value={l.amount} />\n            </li>\n          );\n        })}\n      </ul>\n      <style>{`@keyframes ledger-match {\n        0% { background-color: var(--accent-subtle); transform: none; }\n        60% { background-color: var(--accent-subtle); transform: translateX(4px); }\n        100% { background-color: var(--positive-subtle); transform: none; }\n      }`}</style>\n    </section>\n  );\n}\n",
      "path": "components/ledger/reconciliation-view.tsx",
      "target": "components/ledger/reconciliation-view.tsx",
      "type": "registry:block"
    }
  ],
  "name": "reconciliation-view",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/button",
    "@lopifi/cn",
    "@lopifi/fiscal-period",
    "@lopifi/icons",
    "@lopifi/money",
    "@lopifi/money.types",
    "@lopifi/use-media-query"
  ],
  "title": "ReconciliationView",
  "type": "registry:block"
}
