From 736bf4d33355d0b06e5002ede96caa918443d7d7 Mon Sep 17 00:00:00 2001 From: Cyclone UI Date: Sun, 21 Jun 2026 14:34:26 -0600 Subject: [PATCH] Refine Batches page with hybrid dark/paper treatment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tone='paper' prop to shared Table/TableHeader/TableRow/TableHead/ TableCell components; paper-toned chrome swaps dark muted/20 header for cream paper, dark hover for warm cream hover, dark selected for paper blue tint, muted-foreground text for surface-ink. Dark tone is the default and unchanged. - Extend BatchesList with tone='paper' variant: cream hover, warm surface-ink text, paper-tinted open-row (blue tint + ring). Kind badge (text-sky-300 / text-amber-300 + data-testid) and row data-testid='batch-row-{id}' preserved for the test contract. - Replace PageHeader with editorial hero: massive 'Parsed *batches.*' (clamp 48-80px serif, italic for 'batches.') + ghost 'PARSED' watermark + Files pill (837P/835/envelope) + tap-to-open hint - Add torn-page fold with '↘ THE REGISTER ↙' label and 48-dot perforation - Cream paper plane (max-w-[1280px] mx-auto) with paper-grain SVG and double-bordered title block: 'REGISTER · PARSED BATCHES' over 'The register.' (clamp 48-96px) plus on-file count column - Folio system: § 01 Vital signs (4 paper KPI tiles via new BatchesKpiTile: On file / 837P / 835 / Claims with ink/blue/amber/ success accent rails and Icons from lucide), § 02 The register (paper-toned BatchesList) - Error/Loading/Empty states wrapped in paper-toned containers - Footer: 'End of register / Cyclone · Batches / N rows' Round 10/11. --- src/components/BatchesList.tsx | 73 ++++- src/components/ui/table.tsx | 177 ++++++---- src/pages/Batches.tsx | 581 +++++++++++++++++++++++++++++++-- 3 files changed, 730 insertions(+), 101 deletions(-) diff --git a/src/components/BatchesList.tsx b/src/components/BatchesList.tsx index 31d9904..4d22afc 100644 --- a/src/components/BatchesList.tsx +++ b/src/components/BatchesList.tsx @@ -20,6 +20,11 @@ import type { BatchSummary } from "@/lib/api"; * * Voice mirrors `AckCodeBadge` in `src/pages/Acks.tsx` (uppercase, * wide tracking, hairline border, low-opacity fill). + * + * The literal class names `text-sky-300` and `text-amber-300` are + * pinned by `Batches.test.tsx` as a contract — the test asserts the + * badge's text color is one of these two strings. Don't replace them + * with arbitrary HSL values or the test will fail. */ function KindBadge({ kind }: { kind: BatchSummary["kind"] }) { const color = @@ -42,7 +47,8 @@ function KindBadge({ kind }: { kind: BatchSummary["kind"] }) { /** * Skeleton rows for the batches table. Mirrors the row count used in * `Acks.tsx` (5 placeholders) so the loading density matches the rest - * of the app. + * of the app. `data-testid="batches-skeleton"` is pinned by + * `Batches.test.tsx`. */ export function BatchesListSkeleton() { return ( @@ -63,6 +69,13 @@ type BatchesListProps = { */ openId: string | null; onOpen: (id: string) => void; + /** + * When `paper`, the table sits inside a cream "paper plane" section + * and uses the paper-toned color scheme: warm hover, hairline border + * in surface-line, surface-ink text. When `dark` (default), the + * original dark-mode chrome is used. + */ + tone?: "dark" | "paper"; }; /** @@ -71,15 +84,26 @@ type BatchesListProps = { * so the numbers tick up from 0 on first render (gives the page a * little life on load; consistent with the Dashboard KPI cards). */ -export function BatchesList({ items, openId, onOpen }: BatchesListProps) { +export function BatchesList({ + items, + openId, + onOpen, + tone = "dark", +}: BatchesListProps) { + const isPaper = tone === "paper"; return ( - +
Kind Batch Input file - Claims + + Claims + Parsed @@ -93,28 +117,57 @@ export function BatchesList({ items, openId, onOpen }: BatchesListProps) { data-open={openId === b.id ? "true" : undefined} className={cn( "animate-row-flash cursor-pointer", - openId === b.id && "bg-muted/40", + isPaper && openId === b.id && + "!bg-[hsl(212_85%_95%)] ring-1 ring-inset ring-[hsl(212_100%_45%_/_0.30)]", + !isPaper && openId === b.id && "bg-muted/40", )} > - + {b.id} - + {b.inputFilename} - + fmt.num(Math.round(n))} /> - + {b.parsedAt ? fmt.dateShort(b.parsedAt) : "—"} - + diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index ca67893..40d5c27 100644 --- a/src/components/ui/table.tsx +++ b/src/components/ui/table.tsx @@ -1,87 +1,128 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -const Table = React.forwardRef>( - ({ className, ...props }, ref) => ( -
-
+// --------------------------------------------------------------------------- +// Table +// +// Shared table primitive used by Claims, Remittances, Batches, Acks, etc. +// +// `tone="paper"` swaps the dark-mode row chrome (muted/20 header, muted/30 +// hover) for paper-toned chrome (cream surface, soft hairline border, +// tinted hover). Paper-toned tables sit inside the cream "paper plane" +// sections of the hybrid Magazine Spread layout. The default `tone="dark"` +// is unchanged from the original look so existing callers keep their +// behavior. Pages pass the tone prop once on `
` and the children +// inherit the matching colors via the data-tone attribute — no need to +// rewrite every TableHead/TableRow/TableCell. +// --------------------------------------------------------------------------- + +type Tone = "dark" | "paper"; + +type TableProps = React.HTMLAttributes & { + tone?: Tone; +}; + +const Table = React.forwardRef( + ({ className, tone = "dark", ...props }, ref) => ( +
+
) ); Table.displayName = "Table"; -const TableHeader = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( - -)); +type TableSectionProps = React.HTMLAttributes; + +const TableHeader = React.forwardRef( + ({ className, ...props }, ref) => { + // Paper-tone: cream-papered header band, soft border, no dark muted fill. + return ( + is paper-toned, swap to cream chrome. + "[[data-tone=paper]_&]:bg-[hsl(36_22%_92%)]", + "[[data-tone=paper]_&]:[&_tr]:bg-[hsl(36_22%_92%)]", + "[[data-tone=paper]_&]:[&_tr]:border-[hsl(30_14%_14%_/_0.10)]", + className + )} + {...props} + /> + ); + } +); TableHeader.displayName = "TableHeader"; -const TableBody = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( - -)); +const TableBody = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); TableBody.displayName = "TableBody"; -const TableRow = React.forwardRef< - HTMLTableRowElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( - -)); +type TableRowProps = React.HTMLAttributes; + +const TableRow = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); TableRow.displayName = "TableRow"; -const TableHead = React.forwardRef< - HTMLTableCellElement, - React.ThHTMLAttributes ->(({ className, scope = "col", ...props }, ref) => ( -
-)); +type TableHeadProps = React.ThHTMLAttributes; + +const TableHead = React.forwardRef( + ({ className, scope = "col", ...props }, ref) => ( + + ) +); TableHead.displayName = "TableHead"; -const TableCell = React.forwardRef< - HTMLTableCellElement, - React.TdHTMLAttributes ->(({ className, ...props }, ref) => ( - -)); +const TableCell = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); TableCell.displayName = "TableCell"; export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }; +export type { Tone as TableTone }; diff --git a/src/pages/Batches.tsx b/src/pages/Batches.tsx index a4679d3..97a2564 100644 --- a/src/pages/Batches.tsx +++ b/src/pages/Batches.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import { Files, GitBranch, Layers, Receipt } from "lucide-react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { EmptyState } from "@/components/ui/empty-state"; import { ErrorState } from "@/components/ui/error-state"; -import { PageHeader } from "@/components/PageHeader"; +import { Skeleton } from "@/components/ui/skeleton"; import { BatchesList, BatchesListSkeleton } from "@/components/BatchesList"; import { BatchDetailContent, @@ -118,6 +119,14 @@ function useBatchDetail(id: string | null): { const HELP_NOOP = () => {}; +/** + * Batches page — hybrid Magazine Spread treatment. + * + * Dark editorial hero → torn-page fold → cream paper plane with the + * batch register (KPI strip + tabular list). Click a row to open the + * detail drawer (rendered above the page, dimmed via the body + * opacity). + */ export function Batches() { const { data, isLoading, isError, error, refetch } = useBatches(100); const items: BatchSummary[] = data ?? []; @@ -156,6 +165,28 @@ export function Batches() { const dimBackground = batchId !== null; const errKind = batchErrorKind(detailError); + // ----------------------------------------------------------------- + // Aggregate metrics for the § 01 KPI strip + // ----------------------------------------------------------------- + const totals = items.reduce( + (acc, b) => ({ + count: acc.count + 1, + p837: acc.p837 + (b.kind === "837p" ? 1 : 0), + p835: acc.p835 + (b.kind === "835" ? 1 : 0), + claims: acc.claims + b.claimCount, + }), + { count: 0, p837: 0, p835: 0, claims: 0 }, + ); + + // ----------------------------------------------------------------- + // Stagger choreography (matches Acks / Reconciliation / Upload) + // ----------------------------------------------------------------- + const heroDelay = 0; + const foldDelay = 220; + const titleDelay = 320; + const kpiDelay = 460; + const tableDelay = 600; + return ( <> - Parsed batches} - subtitle="Every 837P and 835 file the backend has ingested. Click a row for envelope, summary, and a peek at the contained claims." - /> + {/* ================================================================= + HERO — DARK EDITORIAL HEADER + ================================================================= */} +
+ {/* Ghost "PARSED" watermark — a print-shop stamp behind the title. */} + - {isError ? ( - refetch()} +
+
+
+
+ + Batches · Register + + + · {totals.count} on file + +
+

+ Parsed{" "} + batches. +

+
+
+
+ + 837P · 835 · envelope & summary +
+ + Tap a row to open the drawer + +
+
+ +
+

+ Every 837P and 835 file the backend has ingested. Click a row + for envelope, summary, and a peek at the contained claims. +

+
+
+ + {/* ================================================================= + FOLD — TORN PAGE + ================================================================= */} +
+
- ) : null} +
+
+
+ {Array.from({ length: 48 }, (_, i) => ( + + ))} +
+
+ + ↘ + + + The register + + + ↙ + +
+
-
- {isLoading ? ( - - ) : items.length === 0 ? ( - + {/* Paper grain */} +
\")", + backgroundSize: "160px 160px", + mixBlendMode: "multiply", + }} + /> + + {/* Title block */} +
+
- ) : ( - - )} +
+
+
+ Register · Parsed batches +
+

+ The register. +

+
+ One row per ingested file — the kind, the input filename, + how many claims it carried, and the day it was parsed. +
+
+
+
+ On file +
+
+ {totals.count} +
+
+ batch{totals.count === 1 ? "" : "es"} · {totals.claims}{" "} + claim{totals.claims === 1 ? "" : "s"} +
+
+
+
+ + {/* KPI strip — § 01 Vital signs */} +
+
+ + § 01 + +
+ + Vital signs + +
+
+ } + tone="ink" + /> + } + tone="blue" + /> + } + tone="amber" + /> + +
+
+ + {/* Table — § 02 The register */} +
+
+ + § 02 + +
+ + The register + +
+ +
+
+
+
+ The register +
+

+ All ingests, newest first. +

+
+
+ Tap a row to open the envelope drawer. Use{" "} + + ← + {" "} + /{" "} + + → + {" "} + inside the drawer to step. +
+
+ + {isError ? ( +
+ refetch()} + /> +
+ ) : isLoading ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ + {/* Footer */} +
+ End of register + Cyclone · Batches + + {totals.count} {totals.count === 1 ? "row" : "rows"} + +
); } + +// --------------------------------------------------------------------------- +// BatchesKpiTile — paper-toned metric for the batch register. +// Mirrors AckKpiTile / KpiTile in the other hybrid pages. +// --------------------------------------------------------------------------- +function BatchesKpiTile({ + label, + value, + tone, + icon, +}: { + label: string; + value: number; + tone: "blue" | "amber" | "ink" | "success"; + icon?: React.ReactNode; +}) { + const accentMap = { + blue: "hsl(212 100% 45%)", + amber: "hsl(36 92% 50%)", + ink: "hsl(var(--surface-ink))", + success: "hsl(152 64% 38%)", + } as const; + const tintMap = { + blue: "hsl(212 85% 92%)", + amber: "hsl(36 82% 92%)", + ink: "hsl(36 22% 90%)", + success: "hsl(152 50% 88%)", + } as const; + const accent = accentMap[tone]; + const tint = tintMap[tone]; + return ( +
+
+
+
+ {label} +
+
+ {icon ?? ( + + )} +
+
+
+ {value} +
+
+ ); +}