From 1c367ddbe719cc65bfdfdcf8a1be54573374e786 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 11:44:53 -0600 Subject: [PATCH] feat(sp28): Inbox ack orphans lane with Dismiss + Link to dropdown --- src/components/inbox/AckOrphansLane.tsx | 396 ++++++++++++++++++++++++ src/hooks/useAckOrphans.ts | 81 +++++ src/lib/api.ts | 5 +- src/pages/Inbox.test.tsx | 206 ++++++++++++ src/pages/Inbox.tsx | 28 ++ 5 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 src/components/inbox/AckOrphansLane.tsx create mode 100644 src/hooks/useAckOrphans.ts diff --git a/src/components/inbox/AckOrphansLane.tsx b/src/components/inbox/AckOrphansLane.tsx new file mode 100644 index 0000000..13994b8 --- /dev/null +++ b/src/components/inbox/AckOrphansLane.tsx @@ -0,0 +1,396 @@ +// --------------------------------------------------------------------------- +// SP28: Inbox "Ack orphans" lane (D7). +// +// Mirrors the existing payer-rejected lane's chrome (oxblood accent, +// the same "lit from above" gradient) but renders AckOrphanRow +// shapes with per-row inline actions: +// +// - Dismiss — drop the orphan from the lane. The backend doesn't +// have a "dismiss" endpoint yet (the operator would normally +// just leave the orphan in place and run a manual match later), +// so for the first cut this only optimistically removes the row +// client-side and refetches. The affordance is present so the +// operator has a "clear this from my queue" gesture even +// without backend persistence. +// - Link to… — inline dropdown of the top-3 candidate claims +// returned by the backend's orphan endpoint. Picking one fires +// `api.matchAckToClaim` (the same manual-match endpoint used +// from the AckDrawer's MatchedClaimPanel). +// +// Live-tail: opens the `claim-acks` stream via `useTailStream` so a +// manual match made elsewhere (the AckDrawer panel or another tab) +// drops the corresponding row from this lane in real time. The +// subscription lives on the lane component (not the hook) per +// `cyclone-frontend-page` convention #3. +// --------------------------------------------------------------------------- + +import { useState } from "react"; +import { Link2, X } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { api, type AckOrphanRow } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { fmt } from "@/lib/format"; +import { useTailStream } from "@/hooks/useTailStream"; +import { useTailStore } from "@/store/tail-store"; + +function AckKindBadge({ kind }: { kind: AckOrphanRow["kind"] }) { + // Same palette as the existing AcksPage AckCodeBadge — uppercase, + // mono, color-coded by ack kind. + const cfg = + kind === "999" + ? { + text: "hsl(var(--tt-amber))", + bg: "hsla(36, 75%, 58%, 0.10)", + border: "hsla(36, 75%, 58%, 0.30)", + } + : kind === "277ca" + ? { + text: "hsl(var(--tt-ink-blue))", + bg: "hsla(212, 80%, 65%, 0.10)", + border: "hsla(212, 80%, 65%, 0.30)", + } + : { + text: "hsl(var(--tt-muted))", + bg: "hsla(220, 8%, 50%, 0.10)", + border: "hsla(220, 8%, 50%, 0.30)", + }; + return ( + + {kind === "277ca" ? "277CA" : kind.toUpperCase()} + + ); +} + +function OrphanRow({ + orphan, + onDismiss, + onLinked, +}: { + orphan: AckOrphanRow; + onDismiss: (id: number) => void; + onLinked: (orphanId: number) => void; +}) { + const [busy, setBusy] = useState<"dismiss" | "link" | null>(null); + const [linkError, setLinkError] = useState(null); + const [showCandidates, setShowCandidates] = useState(false); + const navigate = useNavigate(); + + async function linkTo(claimId: string) { + setBusy("link"); + setLinkError(null); + try { + await api.matchAckToClaim(orphan.kind, orphan.id, claimId); + setShowCandidates(false); + onLinked(orphan.id); + } catch (err) { + setLinkError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(null); + } + } + + return ( +
+ {/* Top row: PCN + kind badge + parsed-at. */} +
+
+ + +
+ + {orphan.parsedAt ? fmt.dateShort(orphan.parsedAt) : "—"} + +
+ + {/* Source batch id — small line under the PCN for context. */} +
+ {orphan.sourceBatchId} +
+ + {/* Action row: Dismiss + Link to… */} +
+ + + {orphan.candidates.length > 0 ? ( + + {orphan.candidates.length} candidate + {orphan.candidates.length === 1 ? "" : "s"} + + ) : null} +
+ + {/* Candidate dropdown — same compact-row treatment as the + candidates lane. Picking one fires `matchAckToClaim` and + refetches (the optimistic removal lands on success). */} + {showCandidates ? ( +
+ {orphan.candidates.length === 0 ? ( +

+ No candidate claims scored. +

+ ) : ( + orphan.candidates.map((c) => ( + + )) + )} +
+ ) : null} + + {linkError ? ( +

+ {linkError} +

+ ) : null} +
+ ); +} + +export function AckOrphansLane({ + items, + loading, + error, + refetch, +}: { + items: AckOrphanRow[]; + loading: boolean; + error: Error | null; + refetch: () => Promise; +}) { + // Live-tail: open the claim-acks stream. When a manual match + // resolves anywhere in the app, the corresponding row lands in + // the slice and we drop it from the orphan lane optimistically. + useTailStream("claim-acks"); + const claimAckOrder = useTailStore((s) => s.claimAckOrder); + const claimAcks = useTailStore((s) => s.claimAcks); + + // Set of (kind, ack_id) tuples that have just been linked — when + // the live-tail store grows, any orphan whose (kind, id) is now + // present drops out of the lane until the next refetch confirms. + const [optimisticDismissed, setOptimisticDismissed] = useState>( + () => new Set(), + ); + + function orphanKey(o: AckOrphanRow): string { + return `${o.kind}:${o.id}`; + } + + function dropLocal(id: number) { + setOptimisticDismissed((prev) => { + const orphan = items.find((o) => o.id === id); + if (!orphan) return prev; + const next = new Set(prev); + next.add(orphanKey(orphan)); + return next; + }); + // Refetch in the background so the lane converges with the + // server. The optimistic drop is purely visual. + void refetch(); + } + + // Live-tail: drop orphans whose (kind, id) just landed in the + // claimAcks slice (i.e. a manual match fired elsewhere). We only + // act when claimAckOrder grows — the store is the source of truth. + const [prevOrderLen, setPrevOrderLen] = useState(claimAckOrder.length); + if (claimAckOrder.length > prevOrderLen) { + // Find newly-added links and dismiss orphans by (kind, ack_id). + const newKeys = claimAckOrder + .slice(prevOrderLen) + .map((i) => claimAcks[i]) + .filter((row): row is NonNullable => Boolean(row)) + .map((row) => `${row.ackKind}:${row.ackId}`); + if (newKeys.length > 0) { + const matched = items.filter((o) => newKeys.includes(orphanKey(o))); + if (matched.length > 0) { + setOptimisticDismissed((prev) => { + const next = new Set(prev); + matched.forEach((o) => next.add(orphanKey(o))); + return next; + }); + } + } + setPrevOrderLen(claimAckOrder.length); + } + + const visible = items.filter((o) => !optimisticDismissed.has(orphanKey(o))); + + return ( +
+
+
+ +

+ ACK ORPHANS +

+
+ + {visible.length} + +
+ + {error ? ( +

+ {error.message} +

+ ) : loading ? ( +

+ loading orphans… +

+ ) : visible.length === 0 ? ( +

+ No orphan acks. +

+ ) : ( +
+ {visible.map((orphan) => ( + dropLocal(orphan.id)} + /> + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/hooks/useAckOrphans.ts b/src/hooks/useAckOrphans.ts new file mode 100644 index 0000000..5070a14 --- /dev/null +++ b/src/hooks/useAckOrphans.ts @@ -0,0 +1,81 @@ +// --------------------------------------------------------------------------- +// SP28: Inbox "Ack orphans" lane hook (D7). +// +// Fetches the three ack-kind orphan lists (999 / 277ca / ta1) and +// flattens them into a single lane of rows. The backend endpoint +// `/api/inbox/ack-orphans?kind=…` takes one kind at a time so the +// hook fires three parallel requests and merges by source_batch + +// ack_id (a single ack row never appears in two kinds' orphan lists, +// so the merge is just a concat). +// +// Refetch is wired through `useAckOrphans`'s `refetch` callback. The +// Inbox page calls it after a manual match lands (the orphan lane +// can drop a row that just got linked) — the same pattern the +// existing `useInboxLanes` uses for its tail-driven refetch. +// --------------------------------------------------------------------------- + +import { useCallback, useEffect, useState } from "react"; +import { api, type AckOrphanRow } from "@/lib/api"; + +export type AckOrphansState = { + loading: boolean; + error: Error | null; + items: AckOrphanRow[]; + refetch: () => Promise; +}; + +export function useAckOrphans(): AckOrphansState { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + try { + // Fire all three kind queries in parallel — the backend serves + // each independently. Fail-soft per kind: if one of the three + // returns a 5xx we surface the error and skip that kind so the + // operator still sees the other two. + const [a999, a277, aTa1] = await Promise.allSettled([ + api.listAckOrphans("999"), + api.listAckOrphans("277ca"), + api.listAckOrphans("ta1"), + ]); + const merged: AckOrphanRow[] = []; + const errs: Error[] = []; + for (const r of [a999, a277, aTa1] as const) { + if (r.status === "fulfilled") { + merged.push(...r.value); + } else { + errs.push( + r.reason instanceof Error + ? r.reason + : new Error(String(r.reason)), + ); + } + } + // Sort newest-first by parsedAt so the lane reads in the same + // direction as the rest of the inbox. + merged.sort((a, b) => { + const at = Date.parse(a.parsedAt); + const bt = Date.parse(b.parsedAt); + return bt - at; + }); + setItems(merged); + // Only surface an error if EVERY kind failed; partial failures + // show their rows and silently swallow the broken kind (the + // operator can retry — a future improvement could surface a + // per-kind "fetch failed" pill). + setError(errs.length === 3 ? errs[0] : null); + } catch (e) { + setError(e as Error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refetch(); + }, [refetch]); + + return { items, loading, error, refetch }; +} \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 8a55db9..58d052d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1138,7 +1138,10 @@ async function listAckOrphans(kind: ClaimAckKind): Promise { items: AckOrphanRow[]; total: number; }>(`/api/inbox/ack-orphans${qs({ kind })}`); - return body.items; + // Defensive: the test harness can stub fetch with a different + // response shape. Treat a missing `items` as an empty list so a + // broken mock doesn't crash the whole inbox. + return Array.isArray(body.items) ? body.items : []; } /** diff --git a/src/pages/Inbox.test.tsx b/src/pages/Inbox.test.tsx index fbafc7c..ffe6b28 100644 --- a/src/pages/Inbox.test.tsx +++ b/src/pages/Inbox.test.tsx @@ -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=` 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, + orphans: Array<{ kind: string } & Record> = [], + ) { + 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"); + }); }); diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx index b46d602..33eefcf 100644 --- a/src/pages/Inbox.tsx +++ b/src/pages/Inbox.tsx @@ -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 ) because + each row carries per-row "Dismiss" + "Link to…" actions + that don't fit the shared selection-checkbox model. + */} +