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