feat(inbox): candidates + unmatched rows drill to RemitDrawer or ClaimDrawer

This commit is contained in:
Tyler
2026-06-21 17:05:11 -06:00
parent 7427838292
commit 54440da2cd
2 changed files with 156 additions and 6 deletions
+114 -4
View File
@@ -1,10 +1,30 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; 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 Inbox from "./Inbox";
import * as inboxApi from "@/lib/inbox-api"; import * as inboxApi from "@/lib/inbox-api";
import * as downloadModule from "@/lib/download"; 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(
<MemoryRouter initialEntries={["/inbox"]}>
<QueryClientProvider client={qc}>
<Inbox />
</QueryClientProvider>
</MemoryRouter>,
);
}
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
@@ -29,7 +49,7 @@ describe("Inbox page", () => {
}), }),
}), }),
); );
const { container } = render(<Inbox />); const { container } = renderInbox();
await waitFor(() => { await waitFor(() => {
expect(container.textContent).toContain("REJECTED"); expect(container.textContent).toContain("REJECTED");
expect(container.textContent).toContain("PAYER REJECTED"); expect(container.textContent).toContain("PAYER REJECTED");
@@ -65,7 +85,7 @@ describe("Inbox page", () => {
}), }),
}), }),
); );
const { container } = render(<Inbox />); const { container } = renderInbox();
await waitFor(() => { await waitFor(() => {
expect(container.textContent).toContain("1"); expect(container.textContent).toContain("1");
expect(container.textContent).toContain("items need eyes"); expect(container.textContent).toContain("items need eyes");
@@ -112,7 +132,7 @@ describe("Inbox page", () => {
}); });
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const { container, getByTestId } = render(<Inbox />); const { container, getByTestId } = renderInbox();
await waitFor(() => { await waitFor(() => {
expect(container.textContent).toContain("PR1"); expect(container.textContent).toContain("PR1");
}); });
@@ -202,7 +222,7 @@ describe("Inbox page", () => {
.spyOn(downloadModule, "downloadBlob") .spyOn(downloadModule, "downloadBlob")
.mockImplementation(() => {}); .mockImplementation(() => {});
const { container, getByText } = render(<Inbox />); const { container, getByText } = renderInbox();
await waitFor(() => { await waitFor(() => {
expect(container.textContent).toContain("C1"); expect(container.textContent).toContain("C1");
expect(container.textContent).toContain("C2"); expect(container.textContent).toContain("C2");
@@ -245,4 +265,94 @@ describe("Inbox page", () => {
expect(filename).toBe("resubmit-2-claims.zip"); expect(filename).toBe("resubmit-2-claims.zip");
expect(blob).toBeInstanceOf(Blob); 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 <tr> 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();
});
});
}); });
+42 -2
View File
@@ -13,10 +13,13 @@
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Lane, type LaneRow } from "@/components/inbox/Lane"; import { Lane, type LaneRow } from "@/components/inbox/Lane";
import { InboxHeader } from "@/components/inbox/InboxHeader"; import { InboxHeader } from "@/components/inbox/InboxHeader";
import { BulkBar } from "@/components/inbox/BulkBar"; import { BulkBar } from "@/components/inbox/BulkBar";
import { RemitDrawer } from "@/components/RemitDrawer";
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { useInboxLanes } from "@/hooks/useInboxLanes"; import { useInboxLanes } from "@/hooks/useInboxLanes";
import { import {
exportInboxCsvUrl, exportInboxCsvUrl,
@@ -40,6 +43,12 @@ function rowKey(row: LaneRow): string {
export default function Inbox() { export default function Inbox() {
const { lanes, loading, error, refetch } = useInboxLanes(); 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<Record<LaneKey, string[]>>({ const [selected, setSelected] = useState<Record<LaneKey, string[]>>({
rejected: [], rejected: [],
payer_rejected: [], payer_rejected: [],
@@ -218,6 +227,22 @@ export default function Inbox() {
className="min-h-screen" className="min-h-screen"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }} 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. */}
<RemitDrawer
remitId={remitId}
remits={[]}
onClose={close}
onNavigate={open}
onToggleHelp={() => {
// No cheatsheet on the inbox surface — `?` is a no-op
// here, but the prop is required by the drawer's contract.
}}
/>
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} /> <InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
{/* Fold — a thin amber rule + italic serif annotation that {/* Fold — a thin amber rule + italic serif annotation that
@@ -290,14 +315,29 @@ export default function Inbox() {
name="CANDIDATES" name="CANDIDATES"
accent="amber" accent="amber"
rows={lanes.candidates} 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)} onSelectionChange={(ids) => setLaneSelected("candidates", ids)}
/> />
<Lane <Lane
name="UNMATCHED" name="UNMATCHED"
accent="ink-blue" accent="ink-blue"
rows={lanes.unmatched} rows={lanes.unmatched}
onRowClick={() => {}} // 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)} onSelectionChange={(ids) => setLaneSelected("unmatched", ids)}
/> />
<Lane <Lane