{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "payables-ap",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "Line-level variances, not header totals.",
  "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 type { MoneyValue } from \"@/lib/ledger/money.types\";\nimport { Button } from \"@/components/ledger/button\";\nimport { CheckIcon, ExclamationTriangleIcon } from \"@/components/ledger/icons\";\nimport { Money } from \"@/components/ledger/money\";\n\nexport type MatchLine = {\n  id: string;\n  description: string;\n  po: { quantity: number; unitPrice: MoneyValue; total: MoneyValue };\n  receipt: { quantity: number } | null;\n  invoice: { quantity: number; unitPrice: MoneyValue; total: MoneyValue };\n};\n\nexport type Tolerance = { quantityPercent: number; pricePercent: number };\n\nexport type MatchDisposition = \"accept-with-reason\" | \"request-credit\" | \"partial-pay\" | \"reject\";\n\nexport interface ThreeWayMatchProps {\n  className?: string;\n  lines: MatchLine[];\n  /** Caps the scroller so the grouped header and the line column can freeze. */\n  maxHeight?: number;\n  onDisposition?: (lineId: string, disposition: MatchDisposition, reason: string) => void;\n  tolerance: Tolerance;\n}\n\n/**\n * PO, goods receipt and invoice side by side, **line by line**. A header\n * total that matches while the lines don't is how overbilling hides.\n *\n * A variance requires a disposition — never a silent pass.\n */\nexport function ThreeWayMatch({\n  lines,\n  tolerance,\n  onDisposition,\n  maxHeight = 520,\n  className,\n}: ThreeWayMatchProps) {\n  const scroller = useScrollEdges<HTMLDivElement>();\n  return (\n    <div className={cn(\"space-y-3\", className)}>\n      <p className=\"text-caption text-text-secondary\">\n        Tolerances in force: quantity ±<span className=\"numeric\">{tolerance.quantityPercent}%</span>, price ±\n        <span className=\"numeric\">{tolerance.pricePercent}%</span>. Matching is line by line, not on header\n        totals.\n      </p>\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-[900px]\">\n          <caption className=\"sr-only\">Three-way match by line</caption>\n          <thead>\n            <tr className=\"border-b border-border-subtle\">\n              <th\n                className=\"py-3 pl-6 pr-3 text-left text-label font-medium text-text-secondary\"\n                rowSpan={2}\n                scope=\"col\"\n              >\n                Line\n              </th>\n              <th\n                className=\"border-l border-border-subtle px-3 py-2 text-center text-label font-medium text-text-secondary\"\n                colSpan={2}\n                scope=\"col\"\n              >\n                Purchase order\n              </th>\n              <th\n                className=\"border-l border-border-subtle px-3 py-2 text-center text-label font-medium text-text-secondary\"\n                scope=\"col\"\n              >\n                Receipt\n              </th>\n              <th\n                className=\"border-l border-border-subtle px-3 py-2 text-center text-label font-medium text-text-secondary\"\n                colSpan={2}\n                scope=\"col\"\n              >\n                Invoice\n              </th>\n              <th\n                className=\"border-l border-border-subtle py-3 pl-3 pr-6 text-left text-label font-medium text-text-secondary\"\n                rowSpan={2}\n                scope=\"col\"\n              >\n                Variance\n              </th>\n            </tr>\n            <tr className=\"border-b border-border-subtle\">\n              {[\n                { key: \"po-qty\", label: \"Qty\" },\n                { key: \"po-price\", label: \"Unit price\" },\n                { key: \"received-qty\", label: \"Qty received\" },\n                { key: \"invoice-qty\", label: \"Qty\" },\n                { key: \"invoice-price\", label: \"Unit price\" },\n              ].map(({ key, label }, i) => (\n                <th\n                  className={cn(\n                    \"px-3 py-2 text-right text-caption text-text-secondary\",\n                    (i === 0 || i === 2 || i === 3) && \"border-l border-border-subtle\",\n                  )}\n                  key={key}\n                  scope=\"col\"\n                >\n                  {label}\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody>\n            {lines.map((l) => {\n              const receivedQty = l.receipt?.quantity ?? 0;\n              const qtyVariance = l.invoice.quantity - receivedQty;\n              const qtyOut =\n                Math.abs(qtyVariance) / Math.max(1, receivedQty) > tolerance.quantityPercent / 100;\n              const priceDelta = Number(l.invoice.unitPrice.amount) - Number(l.po.unitPrice.amount);\n              const priceOut =\n                Math.abs(priceDelta) / Math.max(1, Number(l.po.unitPrice.amount)) >\n                tolerance.pricePercent / 100;\n              const clean = !qtyOut && !priceOut && l.receipt !== null;\n\n              return (\n                <React.Fragment key={l.id}>\n                  <tr className={cn(\"border-b border-border-subtle\", !clean && \"bg-negative-subtle\")}>\n                    <th className=\"py-3 pl-6 pr-3 text-left text-body font-normal\" scope=\"row\">\n                      {l.description}\n                    </th>\n                    <td className=\"numeric border-l border-border-subtle px-3 py-3 text-right text-figure-sm\">\n                      {l.po.quantity}\n                    </td>\n                    <td className=\"px-3 py-3 text-right\">\n                      <Money accounting value={l.po.unitPrice} />\n                    </td>\n                    <td className=\"numeric border-l border-border-subtle px-3 py-3 text-right text-figure-sm\">\n                      {l.receipt ? receivedQty : <span className=\"text-warning\">not received</span>}\n                    </td>\n                    <td className=\"numeric border-l border-border-subtle px-3 py-3 text-right text-figure-sm\">\n                      {l.invoice.quantity}\n                    </td>\n                    <td className=\"px-3 py-3 text-right\">\n                      <Money accounting value={l.invoice.unitPrice} />\n                    </td>\n                    <td className=\"border-l border-border-subtle py-3 pl-3 pr-6 text-left\">\n                      {clean ? (\n                        <span className=\"inline-flex items-center gap-1 text-caption text-positive\">\n                          <CheckIcon aria-hidden=\"true\" className=\"size-3.5\" />\n                          Within tolerance\n                        </span>\n                      ) : (\n                        <span className=\"inline-flex items-start gap-1 text-caption text-negative\">\n                          <ExclamationTriangleIcon aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\" />\n                          <span>\n                            {qtyOut ? (\n                              <span className=\"block\">\n                                Quantity out by <span className=\"numeric\">{qtyVariance}</span>\n                              </span>\n                            ) : null}\n                            {priceOut ? (\n                              <span className=\"block\">\n                                Unit price out by{\" \"}\n                                <Money\n                                  accounting\n                                  size=\"xs\"\n                                  tone=\"inherit\"\n                                  value={{\n                                    amount: priceDelta,\n                                    currency: l.po.unitPrice.currency,\n                                  }}\n                                />\n                              </span>\n                            ) : null}\n                            {!l.receipt ? <span className=\"block\">No goods receipt</span> : null}\n                          </span>\n                        </span>\n                      )}\n                    </td>\n                  </tr>\n                  {!clean ? (\n                    <tr className=\"border-b border-border-subtle bg-negative-subtle\">\n                      <td className=\"py-2 pl-6 pr-6\" colSpan={7}>\n                        <DispositionRow lineId={l.id} onDisposition={onDisposition} />\n                      </td>\n                    </tr>\n                  ) : null}\n                </React.Fragment>\n              );\n            })}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n\nfunction DispositionRow({\n  lineId,\n  onDisposition,\n}: {\n  lineId: string;\n  onDisposition?: (id: string, d: MatchDisposition, reason: string) => void;\n}) {\n  const [disposition, setDisposition] = React.useState<MatchDisposition | \"\">(\"\");\n  const [reason, setReason] = React.useState(\"\");\n\n  return (\n    <div className=\"flex flex-wrap items-center gap-2\">\n      <span className=\"text-caption font-medium\">This variance needs a disposition:</span>\n      <select\n        aria-label=\"Variance disposition\"\n        className=\"h-8 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle\"\n        onChange={(e) => setDisposition(e.target.value as MatchDisposition)}\n        value={disposition}\n      >\n        <option value=\"\">Choose…</option>\n        <option value=\"accept-with-reason\">Accept with a reason</option>\n        <option value=\"request-credit\">Request a vendor credit</option>\n        <option value=\"partial-pay\">Partial-pay the matched portion</option>\n        <option value=\"reject\">Reject the invoice</option>\n      </select>\n      <input\n        aria-label=\"Disposition reason\"\n        className=\"h-8 flex-1 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle\"\n        onChange={(e) => setReason(e.target.value)}\n        placeholder=\"Reason\"\n        value={reason}\n      />\n      <Button\n        disabled={!disposition || reason.trim().length < 3}\n        onClick={() => onDisposition?.(lineId, disposition as MatchDisposition, reason)}\n        size=\"sm\"\n        variant=\"primary\"\n      >\n        Record\n      </Button>\n    </div>\n  );\n}\n",
      "path": "components/ledger/three-way-match.tsx",
      "target": "components/ledger/three-way-match.tsx",
      "type": "registry:component"
    }
  ],
  "name": "three-way-match",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/button",
    "@lopifi/cn",
    "@lopifi/icons",
    "@lopifi/money",
    "@lopifi/money.types",
    "@lopifi/use-scroll-edges"
  ],
  "title": "ThreeWayMatch",
  "type": "registry:component"
}
