feat(sp28): Inbox ack orphans lane with Dismiss + Link to dropdown

This commit is contained in:
Nora
2026-07-02 11:44:53 -06:00
parent d8f3fb15f9
commit 1c367ddbe7
5 changed files with 715 additions and 1 deletions
+206
View File
@@ -563,4 +563,210 @@ describe("Inbox page", () => {
// the row but did not drill into the claim drawer.
expect(view.tracker.pathname).toBe("/inbox");
});
// -------------------------------------------------------------------------
// SP28: the new "ACK ORPHANS" lane (D7). Mirrors the payer-rejected
// lane shape; per-row "Dismiss" + "Link to…" actions; empty state
// when no orphans; live-tail integration drops a row that just got
// linked via the manual-match endpoint.
// -------------------------------------------------------------------------
/**
* URL-aware fetch mock for the inbox page. Routes `/api/inbox/lanes`
* to the supplied `lanes`, and `/api/inbox/ack-orphans?kind=<k>` to
* the matching subset of `orphans`. The hook fires three parallel
* requests (one per kind: 999 / 277ca / ta1), so the mock must
* filter by `kind` to avoid returning the same payload three times.
* Default orphan response is empty so tests that don't care about
* the new lane just get a clean "No orphan acks." placeholder.
*/
function makeInboxFetch(
lanes: Record<string, unknown>,
orphans: Array<{ kind: string } & Record<string, unknown>> = [],
) {
return vi.fn().mockImplementation(async (url: string) => {
if (url.includes("/api/inbox/ack-orphans")) {
// Pull the `kind` query param out of the URL so the mock
// returns only the orphans for the requested kind.
const m = /[?&]kind=([^&]+)/.exec(url);
const requestedKind = m?.[1] ?? "";
const filtered = orphans.filter((o) => o.kind === requestedKind);
return {
ok: true,
json: async () => ({ items: filtered, total: filtered.length }),
};
}
return { ok: true, json: async () => lanes };
});
}
it("test_ack_orphans_lane_renders_empty_state_when_no_orphans", async () => {
vi.stubGlobal("fetch", makeInboxFetch({
rejected: [], payer_rejected: [], candidates: [], unmatched: [], done_today: [],
}));
const view = renderInbox();
await waitFor(() => {
expect(view.container.textContent).toContain("ACK ORPHANS");
});
// Empty-state copy must surface — the lane's friendly "nothing
// here" treatment rather than a quiet section.
expect(view.container.textContent).toContain("No orphan acks.");
// No rows rendered.
expect(
view.container.querySelectorAll('[data-testid="ack-orphan-row"]').length
).toBe(0);
});
it("test_ack_orphans_lane_renders_one_row_per_orphan", async () => {
vi.stubGlobal(
"fetch",
makeInboxFetch(
{
rejected: [],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
},
[
{
id: 100,
kind: "999",
pcn: "991102989",
sourceBatchId: "b-orphan-1",
parsedAt: "2026-07-01T12:00:00Z",
candidates: [{ claimId: "CLM-1", score: 0.92, tier: "strong" }],
},
{
id: 200,
kind: "ta1",
pcn: "000000001",
sourceBatchId: "b-orphan-2",
parsedAt: "2026-07-01T11:00:00Z",
candidates: [],
},
],
),
);
const view = renderInbox();
await waitFor(() => {
// Wait until both rows are in the DOM.
expect(
view.container.querySelectorAll('[data-testid="ack-orphan-row"]')
.length,
).toBe(2);
});
// PCN is rendered verbatim — the operator's primary identifier.
expect(view.container.textContent).toContain("991102989");
expect(view.container.textContent).toContain("000000001");
// The kind badge is visible per row.
const badges = view.container.querySelectorAll(
'[data-testid="ack-orphan-kind-badge"]',
);
expect(badges.length).toBe(2);
// The header count is 2 (matches the row count).
expect(
view.container.querySelector('[data-testid="ack-orphans-count"]')
?.textContent,
).toBe("2");
});
it("test_ack_orphans_lane_dismiss_button_drops_row_optimistically", async () => {
const orphans = [
{
id: 300,
kind: "277ca",
pcn: "X-1",
sourceBatchId: "b-orphan-3",
parsedAt: "2026-07-01T12:00:00Z",
candidates: [],
},
];
vi.stubGlobal(
"fetch",
makeInboxFetch(
{
rejected: [],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
},
orphans,
),
);
const view = renderInbox();
await waitFor(() => {
expect(
view.container.querySelector('[data-testid="ack-orphan-row"]'),
).not.toBeNull();
});
// Click the dismiss button — the row drops optimistically.
const dismissBtn = view.container.querySelector(
'[data-testid="ack-orphan-dismiss"]',
) as HTMLButtonElement | null;
expect(dismissBtn).not.toBeNull();
await act(async () => {
dismissBtn!.click();
});
// Empty-state copy surfaces now.
await waitFor(() => {
expect(view.container.textContent).toContain("No orphan acks.");
});
});
it("test_ack_orphans_lane_link_to_dropdown_lists_candidates", async () => {
const orphans = [
{
id: 400,
kind: "999",
pcn: "Y-1",
sourceBatchId: "b-orphan-4",
parsedAt: "2026-07-01T12:00:00Z",
candidates: [
{ claimId: "CLM-A", score: 0.95, tier: "strong" },
{ claimId: "CLM-B", score: 0.62, tier: "weak" },
],
},
];
vi.stubGlobal(
"fetch",
makeInboxFetch(
{
rejected: [],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
},
orphans,
),
);
const view = renderInbox();
await waitFor(() => {
expect(
view.container.querySelector('[data-testid="ack-orphan-row"]'),
).not.toBeNull();
});
// Open the candidate list.
const toggle = view.container.querySelector(
'[data-testid="ack-orphan-link-toggle"]',
) as HTMLButtonElement | null;
expect(toggle).not.toBeNull();
await act(async () => {
toggle!.click();
});
await waitFor(() => {
expect(
view.container.querySelector('[data-testid="ack-orphan-candidate-list"]'),
).not.toBeNull();
});
// Two candidate options render — one per backend-supplied match.
const options = view.container.querySelectorAll(
'[data-testid="ack-orphan-candidate-option"]',
);
expect(options.length).toBe(2);
expect(options[0]?.getAttribute("data-claim-id")).toBe("CLM-A");
expect(options[1]?.getAttribute("data-claim-id")).toBe("CLM-B");
});
});
+28
View File
@@ -18,9 +18,11 @@ 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 { AckOrphansLane } from "@/components/inbox/AckOrphansLane";
import { RemitDrawer } from "@/components/RemitDrawer";
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { useInboxLanes } from "@/hooks/useInboxLanes";
import { useAckOrphans } from "@/hooks/useAckOrphans";
import {
exportInboxCsvUrl,
dismissCandidates,
@@ -44,6 +46,17 @@ function rowKey(row: LaneRow): string {
export default function Inbox() {
const { lanes, loading, error, refetch } = useInboxLanes();
// SP28: ack orphans lane (D7). The hook fires three parallel
// requests (one per ack kind: 999 / 277ca / ta1) and merges the
// results. The `refetch` is wired into the AckOrphansLane's
// per-row "Dismiss" / "Link to…" actions so the lane converges
// after a manual match.
const {
items: ackOrphans,
loading: ackOrphansLoading,
error: ackOrphansError,
refetch: refetchAckOrphans,
} = useAckOrphans();
// 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
@@ -305,6 +318,21 @@ export default function Inbox() {
}}
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
/>
{/*
SP28: ack-orphans lane (D7). Sits between payer-rejected
and candidates so the operator's eye-flow groups all
rejection-class triage items together (envelope → payer →
ack orphans) before the reconciliation opportunities.
Custom lane component (not the shared <Lane />) because
each row carries per-row "Dismiss" + "Link to…" actions
that don't fit the shared selection-checkbox model.
*/}
<AckOrphansLane
items={ackOrphans}
loading={ackOrphansLoading}
error={ackOrphansError}
refetch={refetchAckOrphans}
/>
<Lane
name="CANDIDATES"
accent="amber"