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
|
// @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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user