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[];
|
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 : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user