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)}
/>