feat(inbox): candidates + unmatched rows drill to RemitDrawer or ClaimDrawer
This commit is contained in:
+114
-4
@@ -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(
|
||||
<MemoryRouter initialEntries={["/inbox"]}>
|
||||
<QueryClientProvider client={qc}>
|
||||
<Inbox />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
@@ -29,7 +49,7 @@ describe("Inbox page", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const { container } = render(<Inbox />);
|
||||
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(<Inbox />);
|
||||
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(<Inbox />);
|
||||
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(<Inbox />);
|
||||
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 <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
@@ -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<Record<LaneKey, string[]>>({
|
||||
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. */}
|
||||
<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} />
|
||||
|
||||
{/* 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)}
|
||||
/>
|
||||
<Lane
|
||||
name="UNMATCHED"
|
||||
accent="ink-blue"
|
||||
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)}
|
||||
/>
|
||||
<Lane
|
||||
|
||||
Reference in New Issue
Block a user