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
+396
View File
@@ -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 (
<span
className="inline-flex items-center rounded-sm border px-1.5 py-0.5 mono text-[9.5px] font-semibold uppercase tracking-[0.16em]"
style={{
color: cfg.text,
backgroundColor: cfg.bg,
borderColor: cfg.border,
}}
data-testid="ack-orphan-kind-badge"
data-kind={kind}
>
{kind === "277ca" ? "277CA" : kind.toUpperCase()}
</span>
);
}
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<string | null>(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 (
<div
className="flex flex-col gap-2 px-3 py-2.5"
style={{ borderBottom: "1px solid hsl(220 8% 14%)" }}
data-testid="ack-orphan-row"
data-orphan-id={orphan.id}
>
{/* Top row: PCN + kind badge + parsed-at. */}
<div className="flex items-baseline justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<AckKindBadge kind={orphan.kind} />
<button
type="button"
onClick={() => {
// Drill into the AckDrawer — the ack_id is encoded as
// ?ack=<id>. The Acks page mounts the AckDrawer so
// navigating there mirrors the rest of the inbox's
// cross-page drill behavior.
navigate(`/acks?ack=${encodeURIComponent(String(orphan.id))}`);
}}
className="mono truncate text-[12px] text-foreground hover:text-[color:var(--tt-amber)] transition-colors"
title={`${orphan.pcn} · source batch ${orphan.sourceBatchId}`}
data-testid="ack-orphan-pcn"
>
{orphan.pcn || "—"}
</button>
</div>
<span className="mono text-[10px] text-[color:var(--tt-muted)]">
{orphan.parsedAt ? fmt.dateShort(orphan.parsedAt) : "—"}
</span>
</div>
{/* Source batch id — small line under the PCN for context. */}
<div className="mono text-[10px] text-[color:var(--tt-muted)] truncate">
{orphan.sourceBatchId}
</div>
{/* Action row: Dismiss + Link to… */}
<div className="flex items-center gap-1.5 flex-wrap">
<button
type="button"
onClick={() => {
setBusy("dismiss");
onDismiss(orphan.id);
}}
disabled={busy !== null}
data-testid="ack-orphan-dismiss"
className={cn(
"inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 mono text-[10px] uppercase tracking-[0.14em] font-semibold transition-colors",
"border-[color:var(--tt-muted)]/60 bg-transparent text-[color:var(--tt-muted)] hover:bg-[color:var(--tt-oxblood)]/10 hover:border-[color:var(--tt-oxblood)]/60 hover:text-[color:var(--tt-oxblood)] disabled:opacity-50",
)}
>
<X className="h-3 w-3" strokeWidth={1.75} aria-hidden />
Dismiss
</button>
<button
type="button"
onClick={() => setShowCandidates((v) => !v)}
disabled={busy !== null}
data-testid="ack-orphan-link-toggle"
className={cn(
"inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 mono text-[10px] uppercase tracking-[0.14em] font-semibold transition-colors",
showCandidates
? "border-[color:var(--tt-amber)] bg-[color:var(--tt-amber)]/10 text-[color:var(--tt-amber)]"
: "border-[color:var(--tt-amber)]/60 bg-transparent text-[color:var(--tt-amber)] hover:bg-[color:var(--tt-amber)]/10 disabled:opacity-50",
)}
>
<Link2 className="h-3 w-3" strokeWidth={1.75} aria-hidden />
{showCandidates ? "Cancel" : "Link to…"}
</button>
{orphan.candidates.length > 0 ? (
<span className="ml-auto mono text-[10px] text-[color:var(--tt-muted)]">
{orphan.candidates.length} candidate
{orphan.candidates.length === 1 ? "" : "s"}
</span>
) : null}
</div>
{/* Candidate dropdown — same compact-row treatment as the
candidates lane. Picking one fires `matchAckToClaim` and
refetches (the optimistic removal lands on success). */}
{showCandidates ? (
<div
className="flex flex-col gap-1 rounded-sm border p-1.5"
style={{
background: "hsla(36, 75%, 58%, 0.04)",
borderColor: "hsla(36, 75%, 58%, 0.30)",
}}
data-testid="ack-orphan-candidate-list"
>
{orphan.candidates.length === 0 ? (
<p className="mono text-[10.5px] text-[color:var(--tt-muted)] px-1.5 py-1">
No candidate claims scored.
</p>
) : (
orphan.candidates.map((c) => (
<button
key={c.claimId}
type="button"
disabled={busy !== null}
onClick={() => void linkTo(c.claimId)}
className={cn(
"flex items-center gap-2 rounded-sm px-1.5 py-1 text-left transition-colors hover:bg-[color:var(--tt-amber)]/10 disabled:opacity-50",
)}
data-testid="ack-orphan-candidate-option"
data-claim-id={c.claimId}
>
<span className="mono text-[11px] text-foreground truncate">
{c.claimId}
</span>
<span className="ml-auto mono text-[10px] text-[color:var(--tt-muted)]">
score {c.score.toFixed(2)}
</span>
</button>
))
)}
</div>
) : null}
{linkError ? (
<p
className="mono text-[10.5px] text-[color:var(--tt-oxblood)]"
data-testid="ack-orphan-link-error"
>
{linkError}
</p>
) : null}
</div>
);
}
export function AckOrphansLane({
items,
loading,
error,
refetch,
}: {
items: AckOrphanRow[];
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}) {
// 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<Set<string>>(
() => 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<typeof row> => 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 (
<section
className="flex-1 min-w-[280px] flex flex-col rounded-md overflow-hidden"
style={{
// Same "lit from above" gradient as the other lanes, with an
// oxblood hairline border to signal "this needs operator
// attention" without competing with the dashboard amber.
background:
"linear-gradient(180deg, hsl(220 18% 11.5%) 0%, hsl(220 18% 9.5%) 55%, hsl(220 18% 8.5%) 100%)",
border: "1px solid hsl(220 8% 18%)",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
}}
data-lane="ack-orphans"
>
<header
className="px-3 py-2.5 flex items-baseline justify-between gap-2"
style={{
borderBottom: "1px solid hsl(220 8% 16%)",
background:
"linear-gradient(180deg, hsl(220 16% 11%) 0%, hsl(220 18% 9%) 100%)",
}}
>
<div className="flex items-center gap-2">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-full"
style={{
background: "var(--tt-oxblood)",
boxShadow: "0 0 8px var(--tt-oxblood)",
}}
/>
<h2
className="mono uppercase"
style={{
color: "var(--tt-ink)",
fontSize: 11,
letterSpacing: "0.18em",
fontWeight: 600,
}}
>
ACK ORPHANS
</h2>
</div>
<span
className="mono tabular-nums"
style={{
color: "var(--tt-amber)",
fontSize: 18,
fontWeight: 600,
lineHeight: 1,
}}
data-testid="ack-orphans-count"
>
{visible.length}
</span>
</header>
{error ? (
<p
className="px-4 py-6 mono text-center"
style={{ color: "var(--tt-oxblood)", fontSize: 11 }}
data-testid="ack-orphans-error"
>
{error.message}
</p>
) : loading ? (
<p
className="px-4 py-6 mono text-center"
style={{ color: "var(--tt-muted)", fontSize: 11 }}
data-testid="ack-orphans-loading"
>
loading orphans
</p>
) : visible.length === 0 ? (
<p
className="px-4 py-6 mono text-center"
style={{ color: "var(--tt-muted)", fontSize: 11, letterSpacing: "0.06em" }}
data-testid="ack-orphans-empty"
>
No orphan acks.
</p>
) : (
<div className="w-full">
{visible.map((orphan) => (
<OrphanRow
key={orphanKey(orphan)}
orphan={orphan}
onDismiss={dropLocal}
onLinked={() => dropLocal(orphan.id)}
/>
))}
</div>
)}
</section>
);
}
+81
View File
@@ -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<void>;
};
export function useAckOrphans(): AckOrphansState {
const [items, setItems] = useState<AckOrphanRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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 };
}
+4 -1
View File
@@ -1138,7 +1138,10 @@ async function listAckOrphans(kind: ClaimAckKind): Promise<AckOrphanRow[]> {
items: AckOrphanRow[]; items: AckOrphanRow[];
total: number; total: number;
}>(`/api/inbox/ack-orphans${qs({ kind })}`); }>(`/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 : [];
} }
/** /**
+206
View File
@@ -563,4 +563,210 @@ describe("Inbox page", () => {
// the row but did not drill into the claim drawer. // the row but did not drill into the claim drawer.
expect(view.tracker.pathname).toBe("/inbox"); 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 { 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 { AckOrphansLane } from "@/components/inbox/AckOrphansLane";
import { RemitDrawer } from "@/components/RemitDrawer"; import { RemitDrawer } from "@/components/RemitDrawer";
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState"; import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { useInboxLanes } from "@/hooks/useInboxLanes"; import { useInboxLanes } from "@/hooks/useInboxLanes";
import { useAckOrphans } from "@/hooks/useAckOrphans";
import { import {
exportInboxCsvUrl, exportInboxCsvUrl,
dismissCandidates, dismissCandidates,
@@ -44,6 +46,17 @@ 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();
// 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 // 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 // `?remit=` off `window.location.search` so opening a row from the
// /inbox URL pushes `?remit=ID` onto /inbox itself (it doesn't // /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)} 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 <Lane
name="CANDIDATES" name="CANDIDATES"
accent="amber" accent="amber"