diff --git a/src/pages/Inbox.test.tsx b/src/pages/Inbox.test.tsx
index 4f91c9d..5ca40a0 100644
--- a/src/pages/Inbox.test.tsx
+++ b/src/pages/Inbox.test.tsx
@@ -1,7 +1,8 @@
// @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 { useEffect, useState } from "react";
+import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Inbox from "./Inbox";
import * as inboxApi from "@/lib/inbox-api";
@@ -12,17 +13,46 @@ import * as downloadModule from "@/lib/download";
// 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.
+//
+// Phase 5 Task 5.4: MemoryRouter doesn't update window.location, so
+// the navigation assertion uses a small LocationTracker component
+// mounted under the same router that records the current pathname +
+// search after each render. Tests then read `tracker.last` instead
+// of `window.location`.
+function LocationTracker({ on }: { on: (pathname: string, search: string) => void }) {
+ const loc = useLocation();
+ useEffect(() => {
+ on(loc.pathname, loc.search);
+ }, [loc.pathname, loc.search, on]);
+ return null;
+}
+
function renderInbox() {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
});
- return render(
+ // Capture the last location seen so tests can assert on navigation
+ // without poking at window.location (MemoryRouter doesn't touch it).
+ const captured = { pathname: "/inbox", search: "" };
+ const view = render(
+ {
+ captured.pathname = pathname;
+ captured.search = search;
+ }}
+ />
,
);
+ // Attach the tracker so individual tests can read it. Note: the
+ // captured value updates asynchronously after navigation, but since
+ // LocationTracker runs in the same render pass as the navigate,
+ // tests should `waitFor` it.
+ (view as unknown as { tracker: typeof captured }).tracker = captured;
+ return view as ReturnType & { tracker: typeof captured };
}
afterEach(() => {
@@ -355,4 +385,161 @@ describe("Inbox page", () => {
).not.toBeNull();
});
});
+
+ it("SP21 Task 5.4: clicking a rejected claim row navigates to /claims?claim=ID", async () => {
+ // Task 5.4 wires the rejected lane's onRowClick to navigate to
+ // the ClaimDrawer via `?claim=ID` on the /claims route. The
+ // MemoryRouter (initial /inbox) lets us observe the URL change
+ // via the LocationTracker helper mounted in the render harness.
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ rejected: [
+ {
+ id: "REJ1",
+ kind: "claim",
+ payer_claim_control_number: "REJ1",
+ charge_amount: 175,
+ payer_id: "P1",
+ provider_npi: "1234567890",
+ state: "rejected",
+ rejection_reason: "999 reject",
+ service_date: null,
+ score: null,
+ },
+ ],
+ payer_rejected: [],
+ candidates: [],
+ unmatched: [],
+ done_today: [],
+ }),
+ }),
+ );
+
+ const view = renderInbox();
+ await waitFor(() => {
+ expect(view.container.textContent).toContain("REJ1");
+ });
+
+ // Click the rejected row. The InboxRow renders the claim id as
+ // its primary text cell; clicking that row bubbles up to the
+ // Lane's onRowClick handler we wired in Task 5.4.
+ const cell = Array.from(view.container.querySelectorAll("td")).find(
+ (td) => td.textContent === "REJ1",
+ );
+ const row = cell?.closest("tr");
+ expect(row).toBeTruthy();
+ await act(async () => {
+ fireEvent.click(row as HTMLElement);
+ });
+
+ // MemoryRouter navigates to /claims?claim=REJ1 — verify the
+ // tracker observed the new pathname + search.
+ await waitFor(() => {
+ expect(view.tracker.pathname).toBe("/claims");
+ expect(view.tracker.search).toBe("?claim=REJ1");
+ });
+ });
+
+ it("SP21 Task 5.4: clicking a done_today claim row navigates to /claims?claim=ID", async () => {
+ // done_today rows are claim-shaped — same drill pattern as the
+ // rejected lane. Verifies that the wiring covers the trailing
+ // "shipped today" lane too, not just the error lanes.
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ rejected: [],
+ payer_rejected: [],
+ candidates: [],
+ unmatched: [],
+ done_today: [
+ {
+ id: "DONE1",
+ kind: "claim",
+ patient_control_number: "DONE1",
+ charge_amount: 88,
+ payer_id: "P1",
+ provider_npi: "1234567890",
+ state: "submitted",
+ service_date_from: "2026-06-21",
+ },
+ ],
+ }),
+ }),
+ );
+
+ const view = renderInbox();
+ await waitFor(() => {
+ expect(view.container.textContent).toContain("DONE1");
+ });
+
+ const cell = Array.from(view.container.querySelectorAll("td")).find(
+ (td) => td.textContent === "DONE1",
+ );
+ const row = cell?.closest("tr");
+ expect(row).toBeTruthy();
+ await act(async () => {
+ fireEvent.click(row as HTMLElement);
+ });
+
+ await waitFor(() => {
+ expect(view.tracker.pathname).toBe("/claims");
+ expect(view.tracker.search).toBe("?claim=DONE1");
+ });
+ });
+
+ it("SP21 Task 5.4: clicking the row checkbox does not bubble to onRowClick", async () => {
+ // Per the plan's §self-review #5, the Lane's RowCheckbox already
+ // calls e.stopPropagation() — verify that's still the case by
+ // clicking the checkbox and confirming the URL didn't change to
+ // /claims. (If stopPropagation regressed, the row click handler
+ // would fire and navigate.)
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ rejected: [
+ {
+ id: "REJ1",
+ kind: "claim",
+ payer_claim_control_number: "REJ1",
+ charge_amount: 175,
+ payer_id: "P1",
+ provider_npi: "1234567890",
+ state: "rejected",
+ rejection_reason: "999 reject",
+ service_date: null,
+ score: null,
+ },
+ ],
+ payer_rejected: [],
+ candidates: [],
+ unmatched: [],
+ done_today: [],
+ }),
+ }),
+ );
+
+ const view = renderInbox();
+ await waitFor(() => {
+ expect(view.container.textContent).toContain("REJ1");
+ });
+
+ const checkbox = view.container.querySelector(
+ 'input[type="checkbox"][aria-label="Select REJ1"]',
+ ) as HTMLInputElement;
+ expect(checkbox).toBeTruthy();
+ await act(async () => {
+ checkbox.click();
+ });
+
+ // URL should still be /inbox — clicking the checkbox selected
+ // the row but did not drill into the claim drawer.
+ expect(view.tracker.pathname).toBe("/inbox");
+ });
});
diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx
index 27e3f8a..ff41bb8 100644
--- a/src/pages/Inbox.tsx
+++ b/src/pages/Inbox.tsx
@@ -295,7 +295,16 @@ export default function Inbox() {
name="REJECTED"
accent="oxblood"
rows={lanes.rejected}
- onRowClick={() => {}}
+ // SP21 Phase 5 Task 5.4: rejected claims drill into the
+ // ClaimDrawer. All rows here are claims (kind === "claim"),
+ // so the branch is straightforward — the type union still
+ // requires the defensive check, but a row click on a claim
+ // here is always a claim drill.
+ onRowClick={(row) => {
+ if (row.kind === "claim") {
+ navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
+ }
+ }}
onSelectionChange={(ids) => setLaneSelected("rejected", ids)}
/>
{/*
@@ -308,7 +317,13 @@ export default function Inbox() {
name="PAYER REJECTED"
accent="oxblood"
rows={lanes.payer_rejected}
- onRowClick={() => {}}
+ // SP21 Phase 5 Task 5.4: payer-rejected claims also drill
+ // into the ClaimDrawer — same shape as the rejected lane.
+ onRowClick={(row) => {
+ if (row.kind === "claim") {
+ navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
+ }
+ }}
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
/>
{}}
+ // SP21 Phase 5 Task 5.4: done_today rows are also claim
+ // drills — same as rejected / payer_rejected. The drawer
+ // surfaces the claim's current state + recent history,
+ // which is exactly what an operator wants when reviewing
+ // "what got shipped today".
+ onRowClick={(row) => {
+ if (row.kind === "claim") {
+ navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
+ }
+ }}
onSelectionChange={(ids) => setLaneSelected("done_today", ids)}
/>