feat(sp28): Inbox ack orphans lane with Dismiss + Link to dropdown
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -1138,7 +1138,10 @@ async function listAckOrphans(kind: ClaimAckKind): Promise<AckOrphanRow[]> {
|
||||
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 : [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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