From 2569ff99a0fd89929b6ce4923ff5c4262bfa998a Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 19:47:49 -0600 Subject: [PATCH] feat(sp7): LineReconciliationTab component --- .../LineReconciliationTab.test.tsx | 118 +++++++++++++ .../ClaimDrawer/LineReconciliationTab.tsx | 159 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 src/components/ClaimDrawer/LineReconciliationTab.test.tsx create mode 100644 src/components/ClaimDrawer/LineReconciliationTab.tsx diff --git a/src/components/ClaimDrawer/LineReconciliationTab.test.tsx b/src/components/ClaimDrawer/LineReconciliationTab.test.tsx new file mode 100644 index 0000000..c371202 --- /dev/null +++ b/src/components/ClaimDrawer/LineReconciliationTab.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render } from "@testing-library/react"; +import { LineReconciliationTab } from "./LineReconciliationTab"; +import type { LineReconciliationResponse } from "@/types"; + +afterEach(() => cleanup()); + +const matchedAll: LineReconciliationResponse = { + claimId: "CLM-1", + summary: { + billedTotal: "200.00", + paidTotal: "160.00", + adjustmentTotal: "40.00", + matchedLines: 2, + totalLines: 2, + }, + lines: [ + { + claimServiceLine: { + lineNumber: 1, + procedureQualifier: "HC", + procedureCode: "99213", + modifiers: [], + charge: "100.00", + units: "1", + unitType: "UN", + serviceDate: "2026-06-01", + }, + serviceLinePayment: { + id: 1, + lineNumber: 1, + procedureQualifier: "HC", + procedureCode: "99213", + modifiers: [], + charge: "100.00", + payment: "80.00", + units: "1", + unitType: "UN", + serviceDate: "2026-06-01", + }, + status: "matched", + adjustments: [ + { groupCode: "CO", reasonCode: "45", amount: "20.00" }, + ], + }, + ], +}; + +describe("LineReconciliationTab", () => { + it("renders summary totals", () => { + const { container } = render(); + expect(container.textContent).toMatch(/billed/i); + expect(container.textContent).toMatch(/paid/i); + expect(container.textContent).toMatch(/adjustments/i); + }); + + it("renders full-match summary line", () => { + const { container } = render(); + expect(container.textContent).toMatch(/matched 2 of 2 lines/i); + }); + + it("renders partial-match summary line when lines are unmatched", () => { + const partial: LineReconciliationResponse = { + ...matchedAll, + summary: { ...matchedAll.summary, matchedLines: 1, totalLines: 2 }, + lines: [ + matchedAll.lines[0]!, + { + claimServiceLine: { + lineNumber: 2, + procedureQualifier: "HC", + procedureCode: "99214", + modifiers: [], + charge: "100.00", + units: "1", + unitType: "UN", + serviceDate: "2026-06-01", + }, + serviceLinePayment: null, + status: "unmatched_837_only", + adjustments: [], + }, + ], + }; + const { container } = render(); + expect(container.textContent).toMatch(/matched 1 of 2 lines/i); + }); + + it("renders unmatched 835 line with oxblood accent", () => { + const with835Only: LineReconciliationResponse = { + ...matchedAll, + lines: [ + matchedAll.lines[0]!, + { + claimServiceLine: null, + serviceLinePayment: { + id: 2, + lineNumber: 2, + procedureQualifier: "HC", + procedureCode: "90837", + modifiers: [], + charge: "100.00", + payment: "70.00", + units: "1", + unitType: "UN", + serviceDate: "2026-06-01", + }, + status: "unmatched_835_only", + adjustments: [], + }, + ], + }; + const { container } = render(); + expect(container.textContent).toMatch(/90837/); + expect(container.textContent).toMatch(/no 837 line matched/i); + }); +}); diff --git a/src/components/ClaimDrawer/LineReconciliationTab.tsx b/src/components/ClaimDrawer/LineReconciliationTab.tsx new file mode 100644 index 0000000..abb7690 --- /dev/null +++ b/src/components/ClaimDrawer/LineReconciliationTab.tsx @@ -0,0 +1,159 @@ +import { fmt } from "@/lib/format"; +import type { + LineReconciliationResponse, + LineReconciliationRow, +} from "@/types"; + +/** + * Per-line reconciliation tab for the ClaimDrawer (SP7 §6.2). + * + * Two-column layout: left = "Billed (837 SV1)", right = "Adjudicated + * (835 SVC)". Each row is a service line, matched when both sides + * are present; unmatched 835 lines appear at the bottom with an + * oxblood accent rail. Per-line CAS adjustments are listed under each + * row. + */ +export function LineReconciliationTab({ + data, +}: { + data: LineReconciliationResponse; +}) { + const { summary, lines } = data; + const allMatched = summary.matchedLines === summary.totalLines; + const leftRows = lines.filter((l) => l.status !== "unmatched_835_only"); + const rightRows = lines; + + return ( +
+
+
+ + + Billed + + {fmt.usdPrecise(Number(summary.billedTotal))} + + + + Paid + + {fmt.usdPrecise(Number(summary.paidTotal))} + + + + Adjustments + + {fmt.usdPrecise(Number(summary.adjustmentTotal))} + +
+ + {allMatched + ? `matched ${summary.matchedLines} of ${summary.totalLines} lines` + : `matched ${summary.matchedLines} of ${summary.totalLines} lines (${summary.totalLines - summary.matchedLines} unmatched)`} + +
+ +
+
+

+ Billed (837 SV1) +

+ {leftRows.map((row) => ( + + ))} +
+
+

+ Adjudicated (835 SVC) +

+ {rightRows.map((row) => ( + + ))} +
+
+
+ ); +} + +function rowKey(row: LineReconciliationRow, side: "left" | "right"): string { + const ln = + row.claimServiceLine?.lineNumber ?? + row.serviceLinePayment?.lineNumber ?? + -1; + return `${side}-${row.status}-${ln}`; +} + +function LineCard({ + row, + side, +}: { + row: LineReconciliationRow; + side: "left" | "right"; +}) { + const cl = row.claimServiceLine; + const svc = row.serviceLinePayment; + const isUnmatched835 = row.status === "unmatched_835_only"; + const accentColor = isUnmatched835 ? "var(--tt-oxblood)" : "var(--tt-ink-blue)"; + const procCode = cl?.procedureCode ?? svc?.procedureCode ?? "—"; + const lineNumber = cl?.lineNumber ?? svc?.lineNumber ?? null; + + return ( +
+
+
+ {procCode} + {lineNumber !== null ? ( + + #{lineNumber} + + ) : null} + {isUnmatched835 ? ( + + no 837 line matched + + ) : null} + {row.status === "unmatched_837_only" ? ( + + no 835 line adjudicated this line + + ) : null} +
+
+ {side === "left" && cl ? {fmt.usdPrecise(Number(cl.charge))} : null} + {side === "right" && svc ? {fmt.usdPrecise(Number(svc.payment))} : null} +
+
+ {row.adjustments.length > 0 ? ( +
    + {row.adjustments.map((a, i) => ( +
  • + {a.groupCode} · {a.reasonCode} · {fmt.usdPrecise(Number(a.amount))} +
  • + ))} +
+ ) : null} +
+ ); +}