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 (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}
+
+ );
+}