feat(frontend): /reconciliation page with two-column matching UI
This commit is contained in:
@@ -7,6 +7,7 @@ import { Remittances } from "@/pages/Remittances";
|
|||||||
import { Providers } from "@/pages/Providers";
|
import { Providers } from "@/pages/Providers";
|
||||||
import { ActivityLog } from "@/pages/ActivityLog";
|
import { ActivityLog } from "@/pages/ActivityLog";
|
||||||
import { Upload } from "@/pages/Upload";
|
import { Upload } from "@/pages/Upload";
|
||||||
|
import { ReconciliationPage } from "@/pages/Reconciliation";
|
||||||
|
|
||||||
function NotFound() {
|
function NotFound() {
|
||||||
return (
|
return (
|
||||||
@@ -28,6 +29,7 @@ export default function App() {
|
|||||||
<Route path="providers" element={<Providers />} />
|
<Route path="providers" element={<Providers />} />
|
||||||
<Route path="activity" element={<ActivityLog />} />
|
<Route path="activity" element={<ActivityLog />} />
|
||||||
<Route path="upload" element={<Upload />} />
|
<Route path="upload" element={<Upload />} />
|
||||||
|
<Route path="reconciliation" element={<ReconciliationPage />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<typeof vi.fn>
|
||||||
|
).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<typeof vi.fn>
|
||||||
|
).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [selectedRemit, setSelectedRemit] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (unmatched.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-fade-in">
|
||||||
|
<header>
|
||||||
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||||
|
<span className="inline-block h-px w-6 bg-border" />
|
||||||
|
Reconciliation
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[22px] font-semibold tracking-tight">
|
||||||
|
Manual pairing
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Skeleton variant="default" height={320} />
|
||||||
|
<Skeleton variant="default" height={320} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unmatched.isError) {
|
||||||
|
const detail =
|
||||||
|
unmatched.error instanceof Error
|
||||||
|
? unmatched.error.message
|
||||||
|
: String(unmatched.error);
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-fade-in">
|
||||||
|
<header>
|
||||||
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||||
|
<span className="inline-block h-px w-6 bg-border" />
|
||||||
|
Reconciliation
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[22px] font-semibold tracking-tight">
|
||||||
|
Manual pairing
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<ErrorState
|
||||||
|
message="Couldn't load unmatched claims and remittances."
|
||||||
|
detail={detail}
|
||||||
|
onRetry={() => unmatched.refetch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { claims, remittances } = unmatched.data ?? {
|
||||||
|
claims: [],
|
||||||
|
remittances: [],
|
||||||
|
};
|
||||||
|
const empty = claims.length === 0 && remittances.length === 0;
|
||||||
|
|
||||||
|
if (empty) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-fade-in">
|
||||||
|
<header>
|
||||||
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||||
|
<span className="inline-block h-px w-6 bg-border" />
|
||||||
|
Reconciliation
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[22px] font-semibold tracking-tight">
|
||||||
|
Manual pairing
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<div className="surface rounded-xl">
|
||||||
|
<EmptyState
|
||||||
|
icon={<CircleDashed className="h-4 w-4" strokeWidth={1.5} />}
|
||||||
|
eyebrow="Reconciliation · nothing pending"
|
||||||
|
message="Every claim and remittance is paired."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-8 animate-fade-in">
|
||||||
|
<header>
|
||||||
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||||
|
<span className="inline-block h-px w-6 bg-border" />
|
||||||
|
Reconciliation
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[22px] font-semibold tracking-tight">
|
||||||
|
Manual pairing
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||||||
|
Pick one claim and one remittance, then match them.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Claims column */}
|
||||||
|
<div className="surface rounded-xl border border-border/60 p-4 space-y-2">
|
||||||
|
<h2 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Unmatched claims ({claims.length})
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{claims.map((c) => {
|
||||||
|
const active = selectedClaim === c.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedClaim(c.id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left p-3 rounded-md border transition-colors",
|
||||||
|
active
|
||||||
|
? "border-accent bg-accent/5"
|
||||||
|
: "border-border/60 hover:border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="display num text-sm">{c.id}</div>
|
||||||
|
<div className="font-mono text-xs text-muted-foreground">
|
||||||
|
{c.patientControlNumber}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{c.serviceDate ?? "—"} · ${c.chargeAmount.toFixed(2)} · NPI{" "}
|
||||||
|
{c.providerNpi ?? "—"}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remits column */}
|
||||||
|
<div className="surface rounded-xl border border-border/60 p-4 space-y-2">
|
||||||
|
<h2 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Unmatched remits ({remittances.length})
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{remittances.map((r) => {
|
||||||
|
const active = selectedRemit === r.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedRemit(r.id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left p-3 rounded-md border transition-colors",
|
||||||
|
active
|
||||||
|
? "border-accent bg-accent/5"
|
||||||
|
: "border-border/60 hover:border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-mono text-sm flex items-center gap-2">
|
||||||
|
{r.payerClaimControlNumber}
|
||||||
|
{r.isReversal ? (
|
||||||
|
<span className="text-[10px] text-warning uppercase tracking-wider">
|
||||||
|
Reversal
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Status {r.statusCode} · ${r.totalPaid.toFixed(2)} paid · $
|
||||||
|
{r.adjustmentAmount.toFixed(2)} adj
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleMatch}
|
||||||
|
disabled={!selectedClaim || !selectedRemit || match.isPending}
|
||||||
|
>
|
||||||
|
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Match selected
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={(!selectedClaim && !selectedRemit) || match.isPending}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Clear selection
|
||||||
|
</Button>
|
||||||
|
{selectedClaim || selectedRemit ? (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
{selectedClaim ? `Claim ${selectedClaim}` : "No claim"}
|
||||||
|
{" · "}
|
||||||
|
{selectedRemit ? `Remit ${selectedRemit}` : "No remit"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user