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>
);
}