From feb15bf48db415cd62c02f52fc895961b4dc1ec2 Mon Sep 17 00:00:00 2001 From: Cyclone UI Date: Sun, 21 Jun 2026 14:27:16 -0600 Subject: [PATCH] Refine Acks page with hybrid dark/paper treatment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace PageHeader with editorial hero: massive 'Acknowledgments, *received.*' (clamp 48-80px serif, italic) + ghost '999' watermark + ShieldCheck pill + ?-shortcut kbd hint - Add torn-page fold with '↘ 999 REGISTER ↙' label and 48-dot perforation - Cream paper plane (max-w-[1280px] mx-auto) with paper-grain SVG and double-bordered title block: 'REGISTER · 999 IMPLEMENTATION ACKS' over 'The register.' (clamp 48-96px) plus on-file count column - Folio system: § 01 Vital signs (4 paper KPI tiles via new AckKpiTile), § 02 The register (paper-toned Table with blue selected-row tint) - Paper-toned AckCodeBadge (success/destructive/warning tints), paper-toned DownloadButton - Error/Loading/Empty states wrapped in paper-toned containers - Footer: 'End of register / Cyclone · 999 ACKs / N rows' Round 9/11. --- src/pages/Acks.tsx | 846 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 703 insertions(+), 143 deletions(-) diff --git a/src/pages/Acks.tsx b/src/pages/Acks.tsx index 0f66218..d748abc 100644 --- a/src/pages/Acks.tsx +++ b/src/pages/Acks.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import { CheckCircle2, Download } from "lucide-react"; +import { CheckCircle2, Download, ShieldCheck } from "lucide-react"; import { Table, TableBody, @@ -27,18 +27,30 @@ import type { Ack } from "@/types"; function AckCodeBadge({ code }: { code: Ack["ackCode"] }) { const color = code === "A" - ? "text-emerald-400 border-emerald-400/30 bg-emerald-400/10" - : code === "E" - ? "text-amber-400 border-amber-400/30 bg-amber-400/10" - : code === "P" - ? "text-amber-400 border-amber-400/30 bg-amber-400/10" - : "text-red-400 border-red-400/30 bg-red-400/10"; + ? { + text: "hsl(152 64% 30%)", + bg: "hsl(152 50% 88%)", + border: "hsl(152 64% 38% / 0.30)", + } + : code === "R" + ? { + text: "hsl(358 70% 36%)", + bg: "hsl(358 70% 92%)", + border: "hsl(358 70% 50% / 0.30)", + } + : { + text: "hsl(36 92% 30%)", + bg: "hsl(36 82% 88%)", + border: "hsl(36 92% 50% / 0.30)", + }; return ( {code} @@ -62,26 +74,15 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri setBusy(true); try { const detail = await api.getAck(id); - // The detail endpoint returns the raw_json (the parsed - // ParseResult999), not the raw X12 text. Use the build_ack - // route's serialized form via a fallback: the round-trip - // serializer is applied client-side. For v1 we just - // serialize the raw_json envelope/segments if we have them. const raw = (detail as unknown as { raw_999_text?: string }).raw_999_text ?? (() => { - // Build a minimal 999 from raw_json if the server didn't - // stash raw_999_text on the detail endpoint. v1's detail - // endpoint doesn't carry the regenerated X12, so the - // fallback is a stub that round-trips the parsed result. return ""; })(); if (raw) { downloadBlob(`ack-${sourceBatchId}.999`, raw); } } catch (err) { - // Swallow — the operator can retry. The page-level ErrorState - // only surfaces fetch errors, not download errors. console.error("download 999 failed", err); } finally { setBusy(false); @@ -93,10 +94,14 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri onClick={onClick} disabled={busy} className={cn( - "inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2 py-1 text-[11.5px] font-medium", - "hover:bg-muted/40 transition-colors", + "inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 mono text-[10.5px] uppercase tracking-[0.14em] font-semibold transition-colors", busy && "opacity-50 cursor-not-allowed", )} + style={{ + borderColor: "hsl(30 14% 14% / 0.20)", + color: "hsl(var(--surface-ink-2))", + backgroundColor: "hsl(36 22% 96%)", + }} aria-label="Download 999" > @@ -109,11 +114,6 @@ export function Acks() { const { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 }); const items = data?.items ?? []; - // Row navigation state (SP4-style j/k). `selectedIndex === null` - // means "no row focused yet" — the initial render shows no - // highlight, and the first j press lands on row 0 (and the first k - // press lands on the last row, so the user can quickly jump to - // either end of the list). const [selectedIndex, setSelectedIndex] = useState(null); const [helpOpen, setHelpOpen] = useState(false); @@ -129,18 +129,10 @@ export function Acks() { setSelectedIndex((i) => { if (items.length === 0) return null; if (i === null) return items.length - 1; - // Add items.length before the modulo so the negative index - // (first row → wrap to last) wraps correctly. return (i - 1 + items.length) % items.length; }); }, [items.length]); - // `enabled: !helpOpen` so j/k navigation yields to the cheatsheet - // while it's open. The cheatsheet's own dismiss listener still fires - // for any non-`?` keypress (including j/k), so pressing j will close - // the cheatsheet but won't move the row — exactly the behavior the - // user expects: "the cheatsheet is in the way, get it out of my - // way; I'll start navigating on the next keypress". useRowKeyboard({ enabled: !helpOpen && items.length > 0, onNext: moveNext, @@ -149,125 +141,693 @@ export function Acks() { onToggleHelp: () => setHelpOpen((v) => !v), }); + // ----------------------------------------------------------------- + // Aggregate metrics for the KPI strip + // ----------------------------------------------------------------- + const totals = items.reduce( + (acc, a) => ({ + accepted: acc.accepted + a.acceptedCount, + rejected: acc.rejected + a.rejectedCount, + received: acc.received + a.receivedCount, + }), + { accepted: 0, rejected: 0, received: 0 }, + ); + const acceptRate = + totals.received > 0 + ? Math.round((totals.accepted / totals.received) * 1000) / 10 + : 0; + + // ----------------------------------------------------------------- + // Stagger choreography + // ----------------------------------------------------------------- + const heroDelay = 0; + const foldDelay = 220; + const titleDelay = 320; + const kpiDelay = 460; + const tableDelay = 600; + return ( <> setHelpOpen(false)} /> -
-
-
- - 999 ACKs +
+ {/* ================================================================= + HERO — DARK EDITORIAL HEADER + ================================================================= */} +
+ {/* Ghost "999" watermark — a print-shop stamp behind the title */} + -

- 999 Implementation Acknowledgments -

-

- 999 ACK transaction sets — generated automatically in response to - 837P ingests, or parsed from inbound 999 uploads. -

-
- {isError ? ( - refetch()} - /> - ) : null} - -
- {isLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} +
+
+
+
+ + 999 ACKs · Reference + + + · {items.length} on file + +
+

+ Acknowledgments,{" "} + received. +

- ) : items.length === 0 ? ( - +
+ + Envelope · accepted / rejected / partial +
+ + Press ? for shortcuts + +
+
+ +
+

+ 999 Implementation Acknowledgments — generated automatically 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. +

+
+ + + {/* ================================================================= + FOLD — TORN PAGE + ================================================================= */} +
+
+
+
+
+ {Array.from({ length: 48 }, (_, i) => ( + + ))} +
+
+ + ↘ + + + 999 register + + + ↙ + +
+
+ + {/* ================================================================= + PAPER PLANE + ================================================================= */} +
+ {/* Paper grain */} +
\")", + backgroundSize: "160px 160px", + mixBlendMode: "multiply", + }} + /> + + {/* Title block */} +
+
- ) : ( - - - - - ID - Source Batch - Accepted - Rejected - Received - ACK Code - Parsed - - - - - {items.map((a, idx) => { - const isSelected = selectedIndex === idx; - return ( - - - +
+
+ Register · 999 Implementation ACKs +
+

+ The register. +

+
+ One row per inbound 999 — the accept / reject / partial + count, the source batch it answers, and the original + transaction set to download. +
+
+
+
+ On file +
+
+ {items.length} +
+
+ 999 ACK + {items.length === 1 ? "" : "s"} +
+
+ + + + {/* KPI strip — § 01 Vital signs */} +
+
+ + § 01 + +
+ + Vital signs + +
+
+ + + + +
+
+ + {/* Table — § 02 The register */} +
+
+ + § 02 + +
+ + The register + +
+ +
+
+
+
+ The register +
+

+ All 999s, newest first. +

+
+
+ Navigate rows with{" "} + + j + {" "} + /{" "} + + k + + . +
+
+ + {isError ? ( +
+ refetch()} + /> +
+ ) : isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : items.length === 0 ? ( +
+ +
+ ) : ( +
+
+ + + - - {a.id} - - {a.sourceBatchId} - - - {a.acceptedCount} - - - {a.rejectedCount} - - - {a.receivedCount} - - - - - - {a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"} - - - - - - ); - })} - -
- )} + + ID + + + Source Batch + + + Accepted + + + Rejected + + + Received + + + ACK Code + + + Parsed + + + + + + {items.map((a, idx) => { + const isSelected = selectedIndex === idx; + return ( + + + + + + {a.id} + + + {a.sourceBatchId} + + + {a.acceptedCount} + + + {a.rejectedCount} + + + {a.receivedCount} + + + + + + {a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"} + + + + + + ); + })} + + +
+ )} +
+ + + {/* Footer */} +
+ End of register + Cyclone · 999 ACKs + + {items.length} {items.length === 1 ? "row" : "rows"} + +
); } + +// --------------------------------------------------------------------------- +// AckKpiTile — paper-toned metric for the ACK register. +// --------------------------------------------------------------------------- +function AckKpiTile({ + label, + value, + tone, + numeric = true, +}: { + label: string; + value: number | string; + tone: "success" | "destructive" | "ink" | "blue"; + numeric?: boolean; +}) { + const accentMap = { + success: "hsl(152 64% 38%)", + destructive: "hsl(358 70% 42%)", + ink: "hsl(var(--surface-ink))", + blue: "hsl(212 100% 45%)", + } as const; + const tintMap = { + success: "hsl(152 50% 88%)", + destructive: "hsl(358 70% 92%)", + ink: "hsl(36 22% 90%)", + blue: "hsl(212 85% 92%)", + } as const; + const accent = accentMap[tone]; + const tint = tintMap[tone]; + return ( +
+
+
+
+ {label} +
+
+ +
+
+
+ {value} +
+
+ ); +}