927 lines
39 KiB
TypeScript
927 lines
39 KiB
TypeScript
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 72–140px, 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>
|
||
);
|
||
}
|