feat(sp28): Inbox ack orphans lane with Dismiss + Link to dropdown
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user