diff --git a/src/pages/Inbox.test.tsx b/src/pages/Inbox.test.tsx index 3329cd8..4f91c9d 100644 --- a/src/pages/Inbox.test.tsx +++ b/src/pages/Inbox.test.tsx @@ -1,10 +1,30 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import Inbox from "./Inbox"; import * as inboxApi from "@/lib/inbox-api"; import * as downloadModule from "@/lib/download"; +// SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched +// claim drilldown to /claims?claim=ID), so the render harness needs +// a Router context. We use a fresh QueryClient per test so the +// drawer's per-remit query doesn't leak cache between cases, and a +// MemoryRouter so `useNavigate` has a router to push into. +function renderInbox() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, retryDelay: 0 } }, + }); + return render( + + + + + , + ); +} + afterEach(() => { cleanup(); vi.unstubAllGlobals(); @@ -29,7 +49,7 @@ describe("Inbox page", () => { }), }), ); - const { container } = render(); + const { container } = renderInbox(); await waitFor(() => { expect(container.textContent).toContain("REJECTED"); expect(container.textContent).toContain("PAYER REJECTED"); @@ -65,7 +85,7 @@ describe("Inbox page", () => { }), }), ); - const { container } = render(); + const { container } = renderInbox(); await waitFor(() => { expect(container.textContent).toContain("1"); expect(container.textContent).toContain("items need eyes"); @@ -112,7 +132,7 @@ describe("Inbox page", () => { }); vi.stubGlobal("fetch", fetchMock); - const { container, getByTestId } = render(); + const { container, getByTestId } = renderInbox(); await waitFor(() => { expect(container.textContent).toContain("PR1"); }); @@ -202,7 +222,7 @@ describe("Inbox page", () => { .spyOn(downloadModule, "downloadBlob") .mockImplementation(() => {}); - const { container, getByText } = render(); + const { container, getByText } = renderInbox(); await waitFor(() => { expect(container.textContent).toContain("C1"); expect(container.textContent).toContain("C2"); @@ -245,4 +265,94 @@ describe("Inbox page", () => { expect(filename).toBe("resubmit-2-claims.zip"); expect(blob).toBeInstanceOf(Blob); }); + + it("SP21 Task 4.4: clicking a candidate row opens the RemitDrawer", async () => { + // Candidates are remits (payer_claim_control_number-keyed) — the + // row's `id` is the remit id, so clicking drills into the + // RemitDrawer via `?remit=ID`. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + rejected: [], + payer_rejected: [], + candidates: [ + { + id: "REM-7", + kind: "remit", + payer_claim_control_number: "REM-7", + charge_amount: 200, + payer_id: "P1", + rendering_provider_npi: "1234567890", + service_date: "2026-06-19", + candidates: [], + }, + ], + unmatched: [], + done_today: [], + }), + }), + ); + + const { container } = renderInbox(); + await waitFor(() => { + expect(container.textContent).toContain("REM-7"); + }); + + // No drawer yet — the URL is `/inbox` with no `?remit=`. + expect( + container.querySelector('[data-testid="remit-drawer"]') + ).toBeNull(); + + // Click the candidate row. The InboxRow renders as a with + // the remit id in its first cell; clicking that row bubbles to + // the Lane's onRowClick handler we wired in Task 4.4. + const cell = Array.from(container.querySelectorAll("td")).find( + (td) => td.textContent === "REM-7", + ); + const row = cell?.closest("tr"); + expect(row).toBeTruthy(); + await act(async () => { + fireEvent.click(row as HTMLElement); + }); + + // Drawer portals into document.body — check there, not container. + await waitFor(() => { + expect( + document.body.querySelector('[data-testid="remit-drawer"]'), + ).not.toBeNull(); + }); + }); + + it("SP21 Task 4.4: deep-link ?remit=ID opens the drawer on mount", async () => { + // Pre-set the URL so the hook reads REM-7 off `window.location.search` + // during its `useState` initializer — no click needed. The inbox + // already mounts the drawer, so a deep link to /inbox?remit=REM-7 + // should land with the drawer open. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + rejected: [], + payer_rejected: [], + candidates: [], + unmatched: [], + done_today: [], + }), + }), + ); + (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL( + "http://localhost/inbox?remit=REM-7", + ); + + renderInbox(); + + await waitFor(() => { + expect( + document.body.querySelector('[data-testid="remit-drawer"]'), + ).not.toBeNull(); + }); + }); }); diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx index 7a59e3d..27e3f8a 100644 --- a/src/pages/Inbox.tsx +++ b/src/pages/Inbox.tsx @@ -13,10 +13,13 @@ // --------------------------------------------------------------------------- import { useState } from "react"; +import { useNavigate } from "react-router-dom"; import { cn } from "@/lib/utils"; import { Lane, type LaneRow } from "@/components/inbox/Lane"; import { InboxHeader } from "@/components/inbox/InboxHeader"; import { BulkBar } from "@/components/inbox/BulkBar"; +import { RemitDrawer } from "@/components/RemitDrawer"; +import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState"; import { useInboxLanes } from "@/hooks/useInboxLanes"; import { exportInboxCsvUrl, @@ -40,6 +43,12 @@ function rowKey(row: LaneRow): string { export default function Inbox() { const { lanes, loading, error, refetch } = useInboxLanes(); + // SP21 Phase 4 Task 4.4: drill-down from inbox rows. The hook reads + // `?remit=` off `window.location.search` so opening a row from the + // /inbox URL pushes `?remit=ID` onto /inbox itself (it doesn't + // navigate to /remittances). The drawer just opens in-place. + const navigate = useNavigate(); + const { remitId, open, close } = useRemitDrawerUrlState(); const [selected, setSelected] = useState>({ rejected: [], payer_rejected: [], @@ -218,6 +227,22 @@ export default function Inbox() { className="min-h-screen" style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }} > + {/* SP21 Phase 4 Task 4.4: RemitDrawer mounts here so a row click + in the candidates / unmatched lanes drills into the parent + remit. The drawer portals into document.body (Radix Dialog), + so the surrounding dark surface is decorative — the drawer + overlays it when open. The hook reads `?remit=` off the URL, + so deep links restore the open remit on reload. */} + { + // No cheatsheet on the inbox surface — `?` is a no-op + // here, but the prop is required by the drawer's contract. + }} + /> {/* Fold — a thin amber rule + italic serif annotation that @@ -290,14 +315,29 @@ export default function Inbox() { name="CANDIDATES" accent="amber" rows={lanes.candidates} - onRowClick={() => {}} + // Candidates are remits waiting for a claim match — drill + // straight into the RemitDrawer for the source remit. + onRowClick={(row) => open(row.id)} onSelectionChange={(ids) => setLaneSelected("candidates", ids)} /> {}} + // Unmatched is a mixed bucket (kind === "claim" | "remit" per + // the InboxClaimRow union). Defensive branch — today the + // backend only emits "claim" rows here, but the type allows + // both, and the next data-source change shouldn't require a + // page edit. + onRowClick={(row) => { + if (row.kind === "remit") { + open(row.id); + } else if (row.kind === "claim") { + navigate( + `/claims?claim=${encodeURIComponent(row.id)}`, + ); + } + }} onSelectionChange={(ids) => setLaneSelected("unmatched", ids)} />