Files
cyclone/src/pages/Acks.tsx
T

927 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useMemo, useState } from "react";
import { CheckCircle2, Download, Link2, Mail, ShieldCheck } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { Card, CardContent } from "@/components/ui/card";
import { KpiCard } from "@/components/KpiCard";
import { PageHeader } from "@/components/PageHeader";
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
import { AckDrawer } from "@/components/AckDrawer";
import { Pagination } from "@/components/ui/pagination";
import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState";
import { useAcks } from "@/hooks/useAcks";
import { useTa1Acks } from "@/hooks/useTa1Acks";
import { useMergedTail } from "@/hooks/useMergedTail";
import { useTailStream } from "@/hooks/useTailStream";
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
import { TailStatusPill } from "@/components/TailStatusPill";
import { api } from "@/lib/api";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { Ack, Ta1Ack } from "@/types";
const PAGE_SIZE = 50;
/**
* 999 ACK register. The page reads the persisted 999 Implementation
* Acknowledgments produced in response to 837P ingests (or parsed
* from inbound 999 uploads) and exposes a single j/k-navigable
* table. A click drills into the AckDrawer via `?ack=ID` URL state.
*
* Chrome language matches the rest of Cyclone: dark instrument
* surface, PageHeader hero with a ghost editorial watermark,
* KpiCard strip, single Card-wrapped register, hairline-separated
* keyboard cheatsheet affordance. The page is *not* a paper
* register — it sits next to Upload / Reconciliation as another
* instrument view of the same data.
*/
export function Acks() {
const [page, setPage] = useState(1);
const { data, isLoading, isError, error, refetch } = useAcks({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
});
// SP25: subscribe to /api/acks/stream so new 999 acks (whether from
// the SFTP poller or a manual upload) land in the table the moment
// they hit the database. The status surfaces in the hero pill so the
// operator can see whether the connection is healthy.
const {
status: tailStatus,
lastEventAt: tailLastEventAt,
forceReconnect,
} = useTailStream("acks");
// Merge the page's base list with the live-tail delta. The hook
// dedupes by id (first write wins), so a snapshot replay on
// reconnect can't double-count an existing row.
const mergedItems = useMergedTail("acks", data?.items ?? []);
// Display order: rejected rows float to the top, then newest-first
// by id. The server returns id-DESC so accepted rows already
// follow that order — we only need to bubble the rejections up
// (the operator flagged on 2026-07-02 that 5 rejections were
// buried inside 1,156 accepted acks and hard to find). All 5 fit
// on page 1 since they're a tiny fraction of the total — the
// operator never needs to paginate to find them.
const items = useMemo(() => {
return [...mergedItems].sort((a, b) => {
const ra = a.rejectedCount > 0 ? 1 : 0;
const rb = b.rejectedCount > 0 ? 1 : 0;
if (ra !== rb) return rb - ra; // rejected first
return b.id - a.id; // newest id first within each group
});
}, [mergedItems]);
const totalCount = data?.total ?? 0;
// Server-side aggregates — sum over the *full* ACK set, not just the
// visible page. Without this, the KPI strip silently under-reports
// once the row count exceeds PAGE_SIZE (the "silent failure" the
// operator flagged on 2026-06-29 when 1056 acks showed as 100).
const aggregates = data?.aggregates;
// SP21 Phase 5 Task 5.3: drill-down from an acks row into
// AckDrawer. The hook reads `?ack=` off `window.location.search`
// so deep links restore the open ack on reload.
const { ackId, open, close } = useAckDrawerUrlState();
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
const moveNext = useCallback(() => {
setSelectedIndex((i) => {
if (items.length === 0) return null;
if (i === null) return 0;
return (i + 1) % items.length;
});
}, [items.length]);
const movePrev = useCallback(() => {
setSelectedIndex((i) => {
if (items.length === 0) return null;
if (i === null) return items.length - 1;
return (i - 1 + items.length) % items.length;
});
}, [items.length]);
useRowKeyboard({
enabled: !helpOpen && items.length > 0,
onNext: moveNext,
onPrev: movePrev,
onClose: () => setHelpOpen(false),
onToggleHelp: () => setHelpOpen((v) => !v),
});
// -----------------------------------------------------------------
// Aggregate metrics for the KPI strip + hero copy.
// Sourced from the server-side `aggregates` field so the KPI strip
// reflects every persisted 999, not just the visible page. If
// `aggregates` hasn't loaded yet (first page), fall back to summing
// the visible items so the cards still display a meaningful value.
// -----------------------------------------------------------------
const totals = aggregates ?? {
accepted: items.reduce((s, a) => s + a.acceptedCount, 0),
rejected: items.reduce((s, a) => s + a.rejectedCount, 0),
received: items.reduce((s, a) => s + a.receivedCount, 0),
};
const acceptRate =
totals.received > 0
? Math.round((totals.accepted / totals.received) * 1000) / 10
: 0;
// The ghost watermark carries the on-file count, scaled like the
// Dashboard/Upload/Reconciliation watermarks (clamp 72140px, 4.5%
// opacity, right-anchored). Use the server-reported total (not the
// page length) so the watermark tells the truth when there are more
// than PAGE_SIZE rows on file.
const watermark = totalCount > 0 ? totalCount.toLocaleString() : "999";
// Tone for the status pill: green when nothing rejected, amber when
// the register holds a partial/rejected payload.
// `anyRejected` is a "page" property, not a "totals" property — the
// status pill summarizes the visible register, so it's OK to scope
// it to the current page.
const anyRejected = items.some(
(a) => a.ackCode === "R" || a.ackCode === "E",
);
// -----------------------------------------------------------------
// Stagger choreography — hero lands first, KPI strip at 140ms,
// register at 240ms, footer at 320ms. Total < 500ms.
// -----------------------------------------------------------------
const heroDelay = 0;
const kpiDelay = 140;
const tableDelay = 240;
const footerDelay = 320;
return (
<>
<KeyboardCheatsheet
open={helpOpen}
onClose={() => setHelpOpen(false)}
/>
{/* SP21 Phase 5 Task 5.3: AckDrawer mount. Row click drills
into the matching ack; the drawer portals into document.body
(Radix Dialog), so the surrounding dark surface stays put
while the drawer is open. Deep links via /acks?ack=ID
restore the open ack on reload. */}
<AckDrawer ackId={ackId} onClose={close} />
<div className="space-y-6 lg:space-y-10">
{/* =================================================================
HERO — dark editorial header
================================================================= */}
<section
className="relative animate-fade-in overflow-hidden"
style={{ animationDelay: `${heroDelay}ms` }}
>
{/* Ghost editorial watermark — anchors the page's scale. */}
<div
aria-hidden="true"
className="pointer-events-none select-none absolute -right-4 top-1/2 -translate-y-1/2 whitespace-nowrap display text-foreground"
style={{
fontSize: "clamp(72px, 12vw, 140px)",
letterSpacing: "-0.04em",
opacity: 0.045,
lineHeight: 1,
}}
>
{watermark}
</div>
<div className="relative z-10 flex items-end justify-between gap-4 sm:gap-6 flex-wrap">
<div className="min-w-0">
<PageHeader
eyebrow={`999 ACKs · EDI reference · ${totalCount.toLocaleString()} on file`}
title={
<>
Acknowledgments,{" "}
<span className="italic text-muted-foreground/85">
received.
</span>
</>
}
subtitle={
<>
999 Implementation Acknowledgments generated in response
to 837P ingests, or parsed from inbound 999 uploads. Each
row below is a single transaction set with its accept /
reject counts and the original X12 to download.
</>
}
/>
</div>
<div
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[11px] mono uppercase tracking-[0.12em] backdrop-blur",
anyRejected
? "border-[hsl(var(--warning)/0.4)] bg-[hsl(var(--warning)/0.08)] text-[hsl(var(--warning))]"
: "border-[hsl(var(--success)/0.4)] bg-[hsl(var(--success)/0.08)] text-[hsl(var(--success))]"
)}
>
<span
className={cn(
"relative inline-flex h-1.5 w-1.5 rounded-full",
anyRejected
? "bg-[hsl(var(--warning))]"
: "bg-[hsl(var(--success))]"
)}
>
<span
className={cn(
"absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping",
anyRejected
? "bg-[hsl(var(--warning))]"
: "bg-[hsl(var(--success))]"
)}
/>
</span>
<ShieldCheck className="h-3.5 w-3.5" strokeWidth={1.75} />
{anyRejected ? "Mixed envelope" : "Envelope accepted"}
</div>
{/* SP25: live-tail connection status. Sits next to the
envelope-accepted pill so the operator can see at a
glance whether the stream is healthy without having to
open the drawer. Reconnect button only renders when
the stream is in a visibly-failed state. */}
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
</div>
{/* Keyboard hint — same rhythm as Reconciliation, sits under
the header so the j/k affordance reads on first scan. */}
<div className="relative z-10 mt-5 flex items-center gap-3 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span>Navigate</span>
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80">
j
</kbd>
<span className="text-muted-foreground/40">/</span>
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80">
k
</kbd>
<span className="text-muted-foreground/40">·</span>
<span>Press</span>
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80">
?
</kbd>
<span>for shortcuts</span>
</div>
</section>
{/* =================================================================
KPI STRIP — four standard KpiCards, accent tokens instead of
paper-tinted custom tiles. Each card carries one hairline-
width left accent in its signal color.
================================================================= */}
<section
aria-label="Aggregate ACK metrics"
className="grid gap-3.5 grid-cols-2 md:grid-cols-4 animate-fade-in-up"
style={{ animationDelay: `${kpiDelay}ms` }}
>
<KpiCard
label="Accepted"
value={fmt.num(totals.accepted)}
accent="success"
hint="from the 999 envelope"
/>
<KpiCard
label="Rejected"
value={fmt.num(totals.rejected)}
accent="destructive"
hint="reject segments in 999"
/>
<KpiCard
label="Received"
value={fmt.num(totals.received)}
accent="default"
hint="total segments seen"
/>
<KpiCard
label="Accept rate"
value={
totals.received > 0 ? `${acceptRate.toFixed(1)}%` : "—"
}
accent={acceptRate >= 95 ? "success" : "warning"}
hint="accepted / received"
/>
</section>
{/* =================================================================
REGISTER — single Card wrapping the table. Same chrome
language as the rest of the app: hairline header, eyebrow
+ display title + sidecar kbd hint, dark-tone table.
================================================================= */}
<section
aria-label="ACK register"
className="animate-fade-in-up"
style={{ animationDelay: `${tableDelay}ms` }}
>
<Card>
<CardContent className="p-6 lg:p-7 space-y-5">
{/* Section header */}
<div className="flex items-end justify-between gap-6 flex-wrap">
<div className="min-w-0">
<div className="eyebrow flex items-center gap-2 mb-2">
<span className="inline-block h-px w-6 bg-foreground/20" />
The register
</div>
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
All <span className="italic">999s</span>, newest first.
</h2>
</div>
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
Each row is a single 999. The accept / reject / partial
code, the source batch it answers, and the original
transaction set to download.
</p>
</div>
<div className="pt-5 border-t border-border/40">
{isError ? (
<ErrorState
message="Couldn't load ACKs from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
) : isLoading ? (
<div className="rounded-md border border-border/60 bg-card/40 p-4 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 bg-card/40">
<EmptyState
eyebrow="999 ACKs · awaiting first 837 with ack=true"
message="Upload an 837P with ?ack=true, or parse a 999 file directly to populate this list."
/>
</div>
) : (
<div className="rounded-md border border-border/60 overflow-hidden bg-card/40">
<Table>
<TableHeader>
<TableRow>
<TableHead
className="w-12"
aria-label="Status"
/>
<TableHead>ID</TableHead>
<TableHead>Source Batch</TableHead>
{/* SP28: "Claims" column. The badge carries
the distinct-claim link count (per-AK2
granularity — D1 — so a 999 with two AK2s
and one linked claim still shows "1").
Empty cell means orphan (no auto-link
resolved); on click the operator can run
a manual match from the AckDrawer. */}
<TableHead>Claims</TableHead>
<TableHead className="text-right">
Accepted
</TableHead>
<TableHead className="text-right">
Rejected
</TableHead>
<TableHead className="text-right">
Received
</TableHead>
<TableHead>ACK Code</TableHead>
<TableHead>Parsed</TableHead>
<TableHead
className="w-20"
aria-label="Download"
/>
</TableRow>
</TableHeader>
<TableBody>
{items.map((a, idx) => {
const isSelected = selectedIndex === idx;
return (
<TableRow
key={a.id}
data-row-index={idx}
data-state={isSelected ? "selected" : undefined}
aria-selected={isSelected}
onClick={() => open(String(a.id))}
className={cn(
"cursor-pointer",
isSelected &&
"bg-accent/[0.06] shadow-[inset_2px_0_0_0_hsl(var(--accent))] hover:bg-accent/[0.06]"
)}
>
<TableCell>
<CheckCircle2
className={cn(
"h-3.5 w-3.5",
a.ackCode === "A"
? "text-[hsl(var(--success))]"
: a.ackCode === "R"
? "text-destructive"
: "text-[hsl(var(--warning))]"
)}
strokeWidth={1.75}
aria-hidden
/>
</TableCell>
<TableCell className="display mono text-[13px] text-foreground">
{a.id}
</TableCell>
<TableCell className="font-mono text-[12px] text-muted-foreground truncate max-w-[180px]">
{a.patientControlNumber ? (
<>
<span className="text-foreground">
{a.patientControlNumber}
</span>
<span className="text-muted-foreground/60">
{" "}· {a.sourceBatchId}
</span>
</>
) : (
a.sourceBatchId
)}
</TableCell>
<TableCell>
<ClaimsBadge count={a.linkedClaimIds?.length ?? 0} />
</TableCell>
<TableCell className="text-right display mono text-[hsl(var(--success))]">
{a.acceptedCount}
</TableCell>
<TableCell className="text-right display mono text-destructive">
{a.rejectedCount}
</TableCell>
<TableCell className="text-right display mono text-muted-foreground">
{a.receivedCount}
</TableCell>
<TableCell>
<AckCodeBadge code={a.ackCode} />
</TableCell>
<TableCell className="mono text-[12.5px] text-muted-foreground/70">
{a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"}
</TableCell>
<TableCell>
<DownloadButton
id={a.id}
sourceBatchId={a.sourceBatchId}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{totalCount > PAGE_SIZE ? (
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={totalCount}
onPageChange={setPage}
/>
) : null}
</div>
</CardContent>
</Card>
</section>
{/* =================================================================
TA1 ENVELOPE ACKS — per-interchange acknowledgments.
A TA1 (X12 Interchange Acknowledgment) is one row per
inbound ISA/IEA envelope — the lower-level sibling of the
999 (per-batch). Colorado Medicaid's Gainwell MFT currently
only ships 999s, but the section stays in the surface so
the operator can see TA1s as soon as one shows up. Kept as
a quieter, narrower section than the 999 register so the
999s remain the page's primary instrument.
================================================================= */}
<Ta1AcksSection />
{/* =================================================================
FOOTER — single hairline-separated status row. Replaces the
warm-paper "End of register" treatment with a quiet
instrument-style status line in the same rhythm as the
other pages.
================================================================= */}
<section
aria-label="Register status"
className="animate-fade-in-up flex items-center justify-between gap-4 flex-wrap pt-5 border-t border-border/40"
style={{ animationDelay: `${footerDelay}ms` }}
>
<div className="flex items-center gap-2 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--accent))]" />
Cyclone · 999 ACKs
</div>
<div className="flex items-center gap-3 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span>
{totalCount.toLocaleString()}{" "}
{totalCount === 1 ? "row" : "rows"} on file
</span>
<span className="text-muted-foreground/30" aria-hidden>
·
</span>
<span>
accepted {fmt.num(totals.accepted)} · rejected{" "}
{fmt.num(totals.rejected)}
</span>
</div>
<button
type="button"
onClick={() => setHelpOpen(true)}
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60 hover:text-foreground transition-colors"
>
Shortcuts
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80">
?
</kbd>
</button>
</section>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// AckCodeBadge — dark-theme pill for the 999 accept / reject / partial code.
// A → success, R → destructive, anything else (E / P) → warning.
// ---------------------------------------------------------------------------
function AckCodeBadge({ code }: { code: Ack["ackCode"] }) {
const color =
code === "A"
? {
text: "hsl(var(--success))",
bg: "hsl(var(--success) / 0.10)",
border: "hsl(var(--success) / 0.30)",
}
: code === "R"
? {
text: "hsl(var(--destructive))",
bg: "hsl(var(--destructive) / 0.10)",
border: "hsl(var(--destructive) / 0.30)",
}
: {
text: "hsl(var(--warning))",
bg: "hsl(var(--warning) / 0.10)",
border: "hsl(var(--warning) / 0.30)",
};
return (
<span
className="inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
style={{
color: color.text,
backgroundColor: color.bg,
borderColor: color.border,
}}
>
{code}
</span>
);
}
// ---------------------------------------------------------------------------
// DownloadButton — fetches the raw 999 via api.getAck and streams it to
// disk as a text blob. Styled to match the rest of the dark surface.
// ---------------------------------------------------------------------------
function DownloadButton({
id,
sourceBatchId,
}: {
id: number;
sourceBatchId: string;
}) {
const [busy, setBusy] = useState(false);
const onClick = async () => {
if (busy) return;
setBusy(true);
try {
const detail = await api.getAck(id);
const raw =
(detail as unknown as { raw_999_text?: string }).raw_999_text ?? "";
if (raw) {
downloadBlob(`ack-${sourceBatchId}.999`, raw);
}
} catch (err) {
console.error("download 999 failed", err);
} finally {
setBusy(false);
}
};
return (
<button
type="button"
onClick={(e) => {
// The row's onClick drills into AckDrawer; this button is a
// nested control and we want the click to NOT bubble into the
// row's onClick handler (matches the precedent set by
// DrillableCell onClick in `src/components/drill/DrillableCell.tsx:39`).
e.stopPropagation();
onClick();
}}
disabled={busy}
aria-label="Download 999"
className={cn(
"inline-flex items-center gap-1.5 rounded-sm border border-border/60 bg-card/40 px-2 py-1 mono text-[10.5px] uppercase tracking-[0.14em] font-semibold text-muted-foreground hover:text-foreground hover:bg-card/80 transition-colors",
busy && "opacity-50 cursor-not-allowed"
)}
>
<Download className="h-3 w-3" strokeWidth={1.75} />
999
</button>
);
}
function downloadBlob(filename: string, content: string) {
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// ---------------------------------------------------------------------------
// Ta1AcksSection — per-interchange TA1 register.
//
// Deliberately quieter than the 999 register above: smaller KPI strip,
// single-card table, and an empty state that explains the
// Colorado-specific context (TA1s aren't shipped today, so the
// section is empty unless Gainwell starts sending them).
// ---------------------------------------------------------------------------
function Ta1AcksSection() {
const { data, isLoading, isError, error, refetch } = useTa1Acks({ limit: 50 });
// SP25: same live-tail triplet as the 999 register above, just
// bound to /api/ta1-acks/stream and the ta1Acks store slice.
const {
status: tailStatus,
lastEventAt: tailLastEventAt,
forceReconnect,
} = useTailStream("ta1_acks");
const items = useMergedTail("ta1_acks", data?.items ?? []);
const totals = items.reduce(
(acc, t) => {
acc[t.ackCode] = (acc[t.ackCode] ?? 0) + 1;
return acc;
},
{} as Record<string, number>,
);
return (
<section
aria-label="TA1 envelope acknowledgments"
className="animate-fade-in-up"
>
<Card>
<CardContent className="p-6 lg:p-7 space-y-5">
<div className="flex items-end justify-between gap-6 flex-wrap">
<div className="min-w-0">
<div className="eyebrow flex items-center gap-2 mb-2">
<span className="inline-block h-px w-6 bg-foreground/20" />
Envelope acks
</div>
<h2 className="display text-[22px] leading-[1.05] tracking-[-0.02em]">
TA1 <span className="italic">envelopes</span>, newest first.
</h2>
</div>
<div className="flex items-center gap-3 flex-wrap">
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
One row per inbound ISA/IEA interchange the
envelope-level sibling of the 999. Gainwell's Colorado
MFT does not ship TA1s today; this section surfaces them
when they appear.
</p>
{/* SP25: live-tail status for the TA1 stream. Quietly
placed after the description so the eyebrow / display
title remains the dominant scan target. */}
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
</div>
</div>
<div className="grid gap-3 grid-cols-2 md:grid-cols-4 pt-2">
<KpiCard
label="On file"
value={fmt.num(items.length)}
accent="default"
hint="persisted TA1s"
/>
<KpiCard
label="Accepted"
value={fmt.num(totals.A ?? 0)}
accent="success"
hint="ack_code = A"
/>
<KpiCard
label="Envelope errors"
value={fmt.num(totals.E ?? 0)}
accent="warning"
hint="ack_code = E"
/>
<KpiCard
label="Rejected"
value={fmt.num(totals.R ?? 0)}
accent="destructive"
hint="ack_code = R"
/>
</div>
<div className="pt-5 border-t border-border/40">
{isError ? (
<ErrorState
message="Couldn't load TA1 acks from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
) : isLoading ? (
<div className="rounded-md border border-border/60 bg-card/40 p-4 space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 bg-card/40">
<EmptyState
eyebrow="TA1 envelopes · none on file"
message="Gainwell's Colorado MFT does not ship TA1 files today. When one does arrive (or you upload one), it will land here."
/>
</div>
) : (
<div className="rounded-md border border-border/60 overflow-hidden bg-card/40">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" aria-label="Status" />
<TableHead>Control #</TableHead>
{/* SP28: TA1 acks also surface a "Claims" badge
(it's really a Batches badge TA1 is
envelope-level and links to originating
Batch rows per D4 but the operator-facing
column header is "Claims" to match the 999
register's vocabulary). Distinct-batch
count, just like the 999 column. */}
<TableHead>Batches</TableHead>
<TableHead>Ack</TableHead>
<TableHead>Note</TableHead>
<TableHead>Interchange date</TableHead>
<TableHead>Sender Receiver</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((t) => (
<TableRow key={t.id}>
<TableCell>
<Mail
className={cn(
"h-3.5 w-3.5",
t.ackCode === "A"
? "text-[hsl(var(--success))]"
: t.ackCode === "R"
? "text-destructive"
: "text-[hsl(var(--warning))]",
)}
strokeWidth={1.75}
aria-hidden
/>
</TableCell>
<TableCell className="display mono text-[13px] text-foreground">
{t.controlNumber || "—"}
</TableCell>
<TableCell>
<ClaimsBadge count={t.linkedClaimIds?.length ?? 0} />
</TableCell>
<TableCell>
<Ta1CodeBadge code={t.ackCode} />
</TableCell>
<TableCell className="mono text-[12px] text-muted-foreground">
{t.noteCode ?? "—"}
</TableCell>
<TableCell className="mono text-[12.5px] text-muted-foreground/70">
{t.interchangeDate ? fmt.dateShort(t.interchangeDate) : "—"}
{t.interchangeTime ? (
<span className="text-muted-foreground/50"> · {t.interchangeTime}</span>
) : null}
</TableCell>
<TableCell className="mono text-[12px] text-muted-foreground truncate max-w-[260px]">
{t.senderId ?? "?"} {t.receiverId ?? "?"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</CardContent>
</Card>
</section>
);
}
// ---------------------------------------------------------------------------
// Ta1CodeBadge — same A/E/R → success/warning/destructive mapping as the
// 999 AckCodeBadge above. Kept as a separate component so the TA1
// surface can evolve independently (e.g. add a tooltip explaining the
// X12 005010X231A1 note code table) without churning the 999 surface.
// ---------------------------------------------------------------------------
function Ta1CodeBadge({ code }: { code: Ta1Ack["ackCode"] }) {
const color =
code === "A"
? {
text: "hsl(var(--success))",
bg: "hsl(var(--success) / 0.10)",
border: "hsl(var(--success) / 0.30)",
}
: code === "R"
? {
text: "hsl(var(--destructive))",
bg: "hsl(var(--destructive) / 0.10)",
border: "hsl(var(--destructive) / 0.30)",
}
: {
text: "hsl(var(--warning))",
bg: "hsl(var(--warning) / 0.10)",
border: "hsl(var(--warning) / 0.30)",
};
return (
<span
className="inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
style={{
color: color.text,
backgroundColor: color.bg,
borderColor: color.border,
}}
>
{code}
</span>
);
}
// ---------------------------------------------------------------------------
// ClaimsBadge — SP28. Cell content for the "Claims" column on the 999
// register and the "Batches" column on the TA1 register.
//
// The badge carries the distinct-claim link count (per-AK2 granularity
// — D1 — so a 999 with two AK2s and one linked claim still shows "1").
//
// - count == 0 → amber "Orphan" pill. The ack has no resolved links;
// the operator can drill in and run a manual match.
// - count == 1 → muted mono text "{count} claim". Single-link case;
// the operator can click through to the matched ClaimDrawer.
// - count > 1 → success "{count} claims" badge. Multiple distinct
// claims resolved — usually a 999 with several AK2s each pointing
// to a different claim batch.
// ---------------------------------------------------------------------------
function ClaimsBadge({ count }: { count: number }) {
if (count === 0) {
return (
<span
className="inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
style={{
color: "hsl(var(--warning))",
backgroundColor: "hsl(var(--warning) / 0.10)",
borderColor: "hsl(var(--warning) / 0.30)",
}}
data-testid="claims-badge-orphan"
title="No auto-link resolved — click to manually match"
>
<Link2 className="h-3 w-3" strokeWidth={1.75} aria-hidden />
Orphan
</span>
);
}
if (count === 1) {
return (
<span
className="mono text-[11.5px] text-foreground"
data-testid="claims-badge-single"
data-claims-count="1"
>
1 claim
</span>
);
}
return (
<span
className="inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
style={{
color: "hsl(var(--success))",
backgroundColor: "hsl(var(--success) / 0.10)",
borderColor: "hsl(var(--success) / 0.30)",
}}
data-testid="claims-badge-many"
data-claims-count={count}
>
{count} claims
</span>
);
}