diff --git a/src/App.tsx b/src/App.tsx index cc16a51..70c9697 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { Remittances } from "@/pages/Remittances"; import { Providers } from "@/pages/Providers"; import { ActivityLog } from "@/pages/ActivityLog"; import { Upload } from "@/pages/Upload"; +import { ReconciliationPage } from "@/pages/Reconciliation"; function NotFound() { return ( @@ -28,6 +29,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/pages/Reconciliation.test.tsx b/src/pages/Reconciliation.test.tsx new file mode 100644 index 0000000..73bbba1 --- /dev/null +++ b/src/pages/Reconciliation.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment happy-dom +// Tell React this is an `act`-aware test environment so react-query's +// internal state updates flush through without noisy console warnings. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReconciliationPage } from "./Reconciliation"; +import { api } from "@/lib/api"; + +// Module-level mock: vitest hoists `vi.mock` calls above imports. Only the +// methods this page touches need to be stubbed; ApiError is re-exported so +// the page can branch on .status in its catch handler. +vi.mock("@/lib/api", () => ({ + api: { + listUnmatched: vi.fn(), + matchRemit: vi.fn(), + unmatchClaim: vi.fn(), + }, + ApiError: class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + } + }, +})); + +/** + * Minimal `render` helper using react-dom/client + act(). Mirrors the + * `renderHook` helper in `useReconciliation.test.ts` — see that file's + * comment for the rationale (project doesn't ship a DOM test library + * yet, and adding one just for these tests would inflate the dev-deps + * tree). Returns the rendered container so tests can assert against + * the live DOM via `container.textContent`. + */ +function renderIntoContainer(element: React.ReactElement): { + container: HTMLDivElement; + unmount: () => void; +} { + const container = document.createElement("div"); + document.body.appendChild(container); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + const root: Root = createRoot(container); + act(() => { + root.render( + React.createElement( + QueryClientProvider, + { client: qc }, + element + ) + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +/** + * Flush microtasks + react state updates until the predicate holds or we + * time out. react-query's fetch resolves asynchronously, so we have to + * await a tick before the rendered DOM reflects the mocked data. + */ +async function waitForText( + text: string, + timeoutMs = 2000 +): Promise { + const start = Date.now(); + while (!document.body.textContent?.includes(text)) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitForText: "${text}" did not appear within ${timeoutMs}ms`); + } + await act(async () => { + await Promise.resolve(); + }); + } +} + +describe("ReconciliationPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders both columns with unmatched rows when api returns data", async () => { + ( + api.listUnmatched as unknown as ReturnType + ).mockResolvedValue({ + claims: [ + { + id: "CLM-1", + patientControlNumber: "PCN-A", + serviceDate: "2026-06-01", + chargeAmount: 100, + providerNpi: "1234567890", + payerId: "P1", + state: "submitted", + }, + ], + remittances: [ + { + id: "CLP-1:b1", + payerClaimControlNumber: "PCN-A", + statusCode: "1", + totalCharge: 100, + totalPaid: 100, + adjustmentAmount: 0, + serviceDate: "2026-06-01", + isReversal: false, + batchId: "b1", + }, + ], + }); + + const { unmount } = renderIntoContainer( + React.createElement(ReconciliationPage) + ); + await waitForText("CLM-1"); + + // Both column headings + both rows should be present. + expect(document.body.textContent).toContain("CLM-1"); + expect(document.body.textContent).toContain("PCN-A"); + expect(document.body.textContent).toContain("Unmatched claims"); + expect(document.body.textContent).toContain("Unmatched remits"); + unmount(); + }); + + it("renders the empty state when nothing is unmatched", async () => { + ( + api.listUnmatched as unknown as ReturnType + ).mockResolvedValue({ claims: [], remittances: [] }); + + const { unmount } = renderIntoContainer( + React.createElement(ReconciliationPage) + ); + await waitForText("nothing pending"); + + expect(document.body.textContent).toContain("Reconciliation · nothing pending"); + expect(document.body.textContent).toContain("Every claim and remittance is paired."); + // No two-column selection chrome should render. + expect(document.body.textContent).not.toContain("Unmatched claims ("); + unmount(); + }); +}); diff --git a/src/pages/Reconciliation.tsx b/src/pages/Reconciliation.tsx new file mode 100644 index 0000000..d57ebd1 --- /dev/null +++ b/src/pages/Reconciliation.tsx @@ -0,0 +1,244 @@ +import { useState } from "react"; +import { GitMerge, CircleDashed, X } from "lucide-react"; +import { toast } from "sonner"; +import { useReconciliation } from "@/hooks/useReconciliation"; +import { ApiError } from "@/lib/api"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ErrorState } from "@/components/ui/error-state"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +/** + * Two-column manual reconciliation surface. The operator picks one row from + * the claims column, one from the remits column, then clicks "Match + * selected" to POST /api/reconciliation/match. The hook's invalidation + * cascade refreshes the unmatched bucket + claims + remits + activity feeds + * on success. + * + * Loading / error / empty states each early-return so the two-column + * selection JSX stays readable and doesn't have to defensively check + * `unmatched.data` everywhere. + */ +export function ReconciliationPage() { + const { unmatched, match } = useReconciliation(); + const [selectedClaim, setSelectedClaim] = useState(null); + const [selectedRemit, setSelectedRemit] = useState(null); + + if (unmatched.isLoading) { + return ( +
+
+
+ + Reconciliation +
+

+ Manual pairing +

+
+
+ + +
+
+ ); + } + + if (unmatched.isError) { + const detail = + unmatched.error instanceof Error + ? unmatched.error.message + : String(unmatched.error); + return ( +
+
+
+ + Reconciliation +
+

+ Manual pairing +

+
+ unmatched.refetch()} + /> +
+ ); + } + + const { claims, remittances } = unmatched.data ?? { + claims: [], + remittances: [], + }; + const empty = claims.length === 0 && remittances.length === 0; + + if (empty) { + return ( +
+
+
+ + Reconciliation +
+

+ Manual pairing +

+
+
+ } + eyebrow="Reconciliation · nothing pending" + message="Every claim and remittance is paired." + /> +
+
+ ); + } + + const handleMatch = async () => { + if (!selectedClaim || !selectedRemit) return; + try { + await match.mutateAsync({ claimId: selectedClaim, remitId: selectedRemit }); + toast.success(`Matched ${selectedClaim} ↔ ${selectedRemit}`); + setSelectedClaim(null); + setSelectedRemit(null); + } catch (e) { + // ApiError carries the HTTP status; 409 is the backend's "already + // matched" signal, so surface a friendlier message than the raw body. + const msg = + e instanceof ApiError && e.status === 409 + ? "Already matched — view the existing match." + : e instanceof Error + ? e.message + : String(e); + toast.error(msg); + } + }; + + const handleClear = () => { + setSelectedClaim(null); + setSelectedRemit(null); + }; + + return ( +
+
+
+ + Reconciliation +
+

+ Manual pairing +

+

+ Pick one claim and one remittance, then match them. +

+
+ +
+ {/* Claims column */} +
+

+ Unmatched claims ({claims.length}) +

+
+ {claims.map((c) => { + const active = selectedClaim === c.id; + return ( + + ); + })} +
+
+ + {/* Remits column */} +
+

+ Unmatched remits ({remittances.length}) +

+
+ {remittances.map((r) => { + const active = selectedRemit === r.id; + return ( + + ); + })} +
+
+
+ +
+ + + {selectedClaim || selectedRemit ? ( + + {selectedClaim ? `Claim ${selectedClaim}` : "No claim"} + {" · "} + {selectedRemit ? `Remit ${selectedRemit}` : "No remit"} + + ) : null} +
+
+ ); +}