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(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 ( <> 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. */}
{/* ================================================================= HERO — dark editorial header ================================================================= */}
{/* Ghost editorial watermark — anchors the page's scale. */}
Acknowledgments,{" "} received. } 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. } />
{anyRejected ? "Mixed envelope" : "Envelope accepted"}
{/* 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. */}
{/* Keyboard hint — same rhythm as Reconciliation, sits under the header so the j/k affordance reads on first scan. */}
Navigate j / k · Press ? for shortcuts
{/* ================================================================= 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. ================================================================= */}
0 ? `${acceptRate.toFixed(1)}%` : "—" } accent={acceptRate >= 95 ? "success" : "warning"} hint="accepted / received" />
{/* ================================================================= 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 header */}
The register

All 999s, newest first.

Each row is a single 999. The accept / reject / partial code, the source batch it answers, and the original transaction set to download.

{isError ? ( refetch()} /> ) : isLoading ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : items.length === 0 ? (
) : (
ID Source Batch {/* 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. */} Claims Accepted Rejected Received ACK Code Parsed {items.map((a, idx) => { const isSelected = selectedIndex === idx; return ( 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]" )} > {a.id} {a.patientControlNumber ? ( <> {a.patientControlNumber} {" "}· {a.sourceBatchId} ) : ( a.sourceBatchId )} {a.acceptedCount} {a.rejectedCount} {a.receivedCount} {a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"} ); })}
)} {totalCount > PAGE_SIZE ? ( ) : null}
{/* ================================================================= 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. ================================================================= */} {/* ================================================================= 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. ================================================================= */}
Cyclone · 999 ACKs
{totalCount.toLocaleString()}{" "} {totalCount === 1 ? "row" : "rows"} on file · accepted {fmt.num(totals.accepted)} · rejected{" "} {fmt.num(totals.rejected)}
); } // --------------------------------------------------------------------------- // 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 ( {code} ); } // --------------------------------------------------------------------------- // 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 ( ); } 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, ); return (
Envelope acks

TA1 envelopes, newest first.

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.

{/* SP25: live-tail status for the TA1 stream. Quietly placed after the description so the eyebrow / display title remains the dominant scan target. */}
{isError ? ( refetch()} /> ) : isLoading ? (
{Array.from({ length: 3 }).map((_, i) => ( ))}
) : items.length === 0 ? (
) : (
Control # {/* 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. */} Batches Ack Note Interchange date Sender → Receiver {items.map((t) => ( {t.controlNumber || "—"} {t.noteCode ?? "—"} {t.interchangeDate ? fmt.dateShort(t.interchangeDate) : "—"} {t.interchangeTime ? ( · {t.interchangeTime} ) : null} {t.senderId ?? "?"} → {t.receiverId ?? "?"} ))}
)}
); } // --------------------------------------------------------------------------- // 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 ( {code} ); } // --------------------------------------------------------------------------- // 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 ( Orphan ); } if (count === 1) { return ( 1 claim ); } return ( {count} claims ); }