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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user