diff --git a/src/components/ClaimDrawer/ClaimDrawer.tsx b/src/components/ClaimDrawer/ClaimDrawer.tsx
index b01264d..98d0c74 100644
--- a/src/components/ClaimDrawer/ClaimDrawer.tsx
+++ b/src/components/ClaimDrawer/ClaimDrawer.tsx
@@ -1,8 +1,9 @@
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { ApiError } from "@/lib/api";
import { useClaimDetail } from "@/hooks/useClaimDetail";
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
+import { useLineReconciliation } from "@/hooks/useLineReconciliation";
import { ClaimDrawerHeader } from "./ClaimDrawerHeader";
import { ValidationPanel } from "./ValidationPanel";
import { ServiceLinesTable } from "./ServiceLinesTable";
@@ -11,6 +12,7 @@ import { PartiesGrid } from "./PartiesGrid";
import { RawSegmentsPanel } from "./RawSegmentsPanel";
import { MatchedRemitCard } from "./MatchedRemitCard";
import { StateHistoryTimeline } from "./StateHistoryTimeline";
+import { LineReconciliationTab } from "./LineReconciliationTab";
import { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
import { ClaimDrawerError } from "./ClaimDrawerError";
@@ -71,6 +73,12 @@ export function ClaimDrawer({
onToggleHelp,
}: ClaimDrawerProps) {
const { data, isLoading, isError, error, refetch } = useClaimDetail(claimId);
+ // SP7: tab state. The Line Reconciliation tab lazy-fetches the
+ // per-line projection when first activated.
+ const [activeTab, setActiveTab] = useState<"details" | "line-reconciliation">("details");
+ const lr = useLineReconciliation(
+ activeTab === "line-reconciliation" ? claimId : null
+ );
// j/k navigation: derive next/prev IDs based on the current claim's
// index in the parent list. Wrap around at both ends. If the current
@@ -157,17 +165,84 @@ export function ClaimDrawer({
data-testid="claim-drawer-content"
>
-
-
-
-
-
- {data.matchedRemittance ? (
-
- ) : null}
-
-
+
+
+
+ {activeTab === "line-reconciliation" ? (
+ lr.loading ? (
+
+ Loading line reconciliation…
+
+ ) : lr.error ? (
+
+ Failed to load line reconciliation.
+
+ ) : lr.data ? (
+
+ ) : null
+ ) : (
+
+
+
+
+
+ {data.matchedRemittance ? (
+
+ ) : null}
+
+
+
+ )}
) : null}
diff --git a/src/components/ClaimDrawer/index.ts b/src/components/ClaimDrawer/index.ts
index 49cb4e4..e78e7da 100644
--- a/src/components/ClaimDrawer/index.ts
+++ b/src/components/ClaimDrawer/index.ts
@@ -11,3 +11,4 @@ export { MatchedRemitCard } from "./MatchedRemitCard";
export { StateHistoryTimeline } from "./StateHistoryTimeline";
export { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
export { ClaimDrawerError } from "./ClaimDrawerError";
+export { LineReconciliationTab } from "./LineReconciliationTab";
diff --git a/src/hooks/useLineReconciliation.test.ts b/src/hooks/useLineReconciliation.test.ts
new file mode 100644
index 0000000..cfcab1a
--- /dev/null
+++ b/src/hooks/useLineReconciliation.test.ts
@@ -0,0 +1,56 @@
+// @vitest-environment happy-dom
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, renderHook, waitFor } from "@testing-library/react";
+import { useLineReconciliation } from "./useLineReconciliation";
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe("useLineReconciliation", () => {
+ it("fetches the line reconciliation data on mount", async () => {
+ const fixture = {
+ claimId: "CLM-1",
+ summary: {
+ billedTotal: "100",
+ paidTotal: "80",
+ adjustmentTotal: "20",
+ matchedLines: 1,
+ totalLines: 1,
+ },
+ lines: [],
+ };
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => fixture,
+ })
+ );
+ const { result } = renderHook(() => useLineReconciliation("CLM-1"));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.data).toEqual(fixture);
+ });
+
+ it("returns null when claimId is null", () => {
+ const { result } = renderHook(() => useLineReconciliation(null));
+ expect(result.current.data).toBeNull();
+ expect(result.current.loading).toBe(false);
+ });
+
+ it("exposes an error when fetch fails", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({}),
+ })
+ );
+ const { result } = renderHook(() => useLineReconciliation("CLM-1"));
+ await waitFor(() => expect(result.current.error).not.toBeNull());
+ });
+});
diff --git a/src/hooks/useLineReconciliation.ts b/src/hooks/useLineReconciliation.ts
new file mode 100644
index 0000000..a7fe621
--- /dev/null
+++ b/src/hooks/useLineReconciliation.ts
@@ -0,0 +1,49 @@
+import { useEffect, useState } from "react";
+import type { LineReconciliationResponse } from "@/types";
+
+/**
+ * Data hook for the ClaimDrawer's Line Reconciliation tab.
+ *
+ * Calls GET /api/claims/{claimId}/line-reconciliation on mount and
+ * when claimId changes. When claimId is null, returns a no-data state.
+ */
+export function useLineReconciliation(claimId: string | null) {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(claimId !== null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (claimId === null) {
+ setData(null);
+ setLoading(false);
+ setError(null);
+ return;
+ }
+ let cancelled = false;
+ setLoading(true);
+ const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
+ fetch(`${baseUrl}/api/claims/${claimId}/line-reconciliation`)
+ .then(async (r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return (await r.json()) as LineReconciliationResponse;
+ })
+ .then((json) => {
+ if (cancelled) return;
+ setData(json);
+ setError(null);
+ })
+ .catch((e) => {
+ if (cancelled) return;
+ setError(e as Error);
+ })
+ .finally(() => {
+ if (cancelled) return;
+ setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [claimId]);
+
+ return { data, loading, error };
+}