Refine Batches page with hybrid dark/paper treatment

- 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.
This commit is contained in:
Cyclone UI
2026-06-21 14:34:26 -06:00
parent 7db5e448cd
commit 736bf4d333
3 changed files with 730 additions and 101 deletions
+63 -10
View File
@@ -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 (
<Table data-testid="batches-table">
<Table data-testid="batches-table" tone={tone}>
<TableHeader>
<TableRow>
<TableHead>Kind</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Input file</TableHead>
<TableHead className="text-right">Claims</TableHead>
<TableHead
className="text-right"
style={isPaper ? { color: "hsl(var(--surface-ink-2))" } : undefined}
>
Claims
</TableHead>
<TableHead>Parsed</TableHead>
<TableHead className="w-6" aria-label="Open" />
</TableRow>
@@ -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",
)}
>
<TableCell>
<KindBadge kind={b.kind} />
</TableCell>
<TableCell className="display num text-[13px]">
<TableCell
className="display num text-[13px]"
style={isPaper ? { color: "hsl(var(--surface-ink))" } : undefined}
>
{b.id}
</TableCell>
<TableCell className="font-mono text-[12px] text-muted-foreground truncate max-w-[280px]">
<TableCell
className={cn(
"font-mono text-[12px] truncate max-w-[280px]",
isPaper
? "text-[hsl(var(--surface-ink-2))]"
: "text-muted-foreground",
)}
>
{b.inputFilename}
</TableCell>
<TableCell className="text-right display num text-[13px]">
<TableCell
className="text-right display num text-[13px]"
style={isPaper ? { color: "hsl(var(--surface-ink))" } : undefined}
>
<AnimatedNumber
value={b.claimCount}
format={(n) => fmt.num(Math.round(n))}
/>
</TableCell>
<TableCell className="text-muted-foreground num text-[12.5px]">
<TableCell
className={cn(
"num text-[12.5px]",
isPaper
? "text-[hsl(var(--surface-ink-3))]"
: "text-muted-foreground",
)}
>
{b.parsedAt ? fmt.dateShort(b.parsedAt) : "—"}
</TableCell>
<TableCell className="text-muted-foreground text-right">
<TableCell
className={cn(
"text-right",
isPaper
? "text-[hsl(var(--surface-ink-3))]"
: "text-muted-foreground",
)}
>
<span aria-hidden></span>
</TableCell>
</TableRow>
+109 -68
View File
@@ -1,87 +1,128 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
// ---------------------------------------------------------------------------
// 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 `<Table>` 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<HTMLTableElement> & {
tone?: Tone;
};
const Table = React.forwardRef<HTMLTableElement, TableProps>(
({ className, tone = "dark", ...props }, ref) => (
<div
className={cn("relative w-full overflow-auto", className)}
data-tone={tone}
>
<table ref={ref} className="w-full caption-bottom text-sm" {...props} />
</div>
)
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn(
"[&_tr]:border-b [&_tr]:border-border/60 [&_tr]:bg-muted/20",
className
)}
{...props}
/>
));
type TableSectionProps = React.HTMLAttributes<HTMLTableSectionElement>;
const TableHeader = React.forwardRef<HTMLTableSectionElement, TableSectionProps>(
({ className, ...props }, ref) => {
// Paper-tone: cream-papered header band, soft border, no dark muted fill.
return (
<thead
ref={ref}
className={cn(
"[&_tr]:border-b [&_tr]:border-border/60 [&_tr]:bg-muted/20",
// When the parent <Table> 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<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
const TableBody = React.forwardRef<HTMLTableSectionElement, TableSectionProps>(
({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
);
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b border-border/40 transition-colors hover:bg-muted/30 focus-within:bg-muted/40 data-[state=selected]:bg-muted/50",
className
)}
{...props}
/>
));
type TableRowProps = React.HTMLAttributes<HTMLTableRowElement>;
const TableRow = React.forwardRef<HTMLTableRowElement, TableRowProps>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b border-border/40 transition-colors hover:bg-muted/30 focus-within:bg-muted/40 data-[state=selected]:bg-muted/50",
// Paper-tone: warm cream hover, soft hairline between rows.
"[[data-tone=paper]_&]:border-[hsl(30_14%_14%_/_0.08)]",
"[[data-tone=paper]_&]:hover:bg-[hsl(36_22%_94%)]",
"[[data-tone=paper]_&]:focus-within:bg-[hsl(36_22%_94%)]",
"[[data-tone=paper]_&]:data-[state=selected]:bg-[hsl(212_85%_95%)]",
className
)}
{...props}
/>
)
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, scope = "col", ...props }, ref) => (
<th
ref={ref}
scope={scope}
className={cn(
"h-9 px-4 text-left align-middle text-[10.5px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80 [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
type TableHeadProps = React.ThHTMLAttributes<HTMLTableCellElement>;
const TableHead = React.forwardRef<HTMLTableCellElement, TableHeadProps>(
({ className, scope = "col", ...props }, ref) => (
<th
ref={ref}
scope={scope}
className={cn(
"h-9 px-4 text-left align-middle text-[10.5px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80 [&:has([role=checkbox])]:pr-0",
// Paper-tone: surface-ink-2 (warm dark) instead of cool muted-foreground.
"[[data-tone=paper]_&]:text-[hsl(var(--surface-ink-2))]",
className
)}
{...props}
/>
)
);
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
const TableCell = React.forwardRef<HTMLTableCellElement, TableHeadProps>(
({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0",
// Paper-tone: warm foreground (surface-ink) for the primary text.
"[[data-tone=paper]_&]:text-[hsl(var(--surface-ink))]",
className
)}
{...props}
/>
)
);
TableCell.displayName = "TableCell";
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
export type { Tone as TableTone };
+558 -23
View File
@@ -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 (
<>
<Dialog
@@ -188,37 +219,541 @@ export function Batches() {
<div
data-testid="batches-page-body"
className={cn(
"space-y-6 lg:space-y-8 animate-fade-in transition-opacity",
"space-y-0 animate-fade-in transition-opacity",
dimBackground && "pointer-events-none opacity-60",
)}
>
<PageHeader
eyebrow="Batches"
title={<>Parsed <span className="display italic text-muted-foreground">batches</span></>}
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
================================================================= */}
<section
className="relative pt-6 pb-8 lg:pt-9 lg:pb-10"
style={{ animationDelay: `${heroDelay}ms` }}
>
{/* Ghost "PARSED" watermark — a print-shop stamp behind the title. */}
<div
aria-hidden="true"
className="pointer-events-none select-none absolute inset-x-0 top-[58%] -translate-y-1/2 whitespace-nowrap display text-center"
style={{
fontSize: "clamp(160px, 20vw, 300px)",
letterSpacing: "-0.05em",
opacity: 0.05,
lineHeight: 1,
color: "hsl(var(--surface-ink))",
}}
>
PARSED
</div>
{isError ? (
<ErrorState
message="Couldn't load batches from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-8 items-end mb-7">
<div className="min-w-0 max-w-3xl">
<div className="flex items-center gap-3 mb-5">
<div className="h-px w-14 bg-foreground/25" />
<span className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground">
Batches · Register
</span>
<span
className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground/60 hidden sm:inline"
aria-hidden
>
· {totals.count} on file
</span>
</div>
<h1 className="display text-[48px] sm:text-[64px] lg:text-[80px] leading-[0.92] text-foreground tracking-[-0.04em]">
Parsed{" "}
<span className="italic text-muted-foreground/85">batches.</span>
</h1>
</div>
<div className="flex flex-col items-start lg:items-end gap-3">
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3.5 py-2 text-[11.5px] mono uppercase tracking-[0.14em] text-muted-foreground backdrop-blur">
<Files
className="h-3.5 w-3.5"
strokeWidth={1.75}
style={{ color: "hsl(var(--accent))" }}
/>
837P · 835 · envelope & summary
</div>
<kbd
className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70"
>
Tap a row to open the drawer
</kbd>
</div>
</div>
<div className="relative z-10 max-w-2xl">
<p className="text-[14px] text-muted-foreground leading-relaxed">
Every 837P and 835 file the backend has ingested. Click a row
for envelope, summary, and a peek at the contained claims.
</p>
</div>
</section>
{/* =================================================================
FOLD — TORN PAGE
================================================================= */}
<div
aria-hidden
className="relative h-14"
style={{ animationDelay: `${foldDelay}ms` }}
>
<div
className="absolute inset-x-0 top-0 h-1/2"
style={{
background:
"linear-gradient(to bottom, hsl(0 0% 0% / 0.55), transparent)",
}}
/>
) : null}
<div
className="absolute inset-x-0 bottom-0 h-1/2"
style={{
background:
"linear-gradient(to top, hsl(30 14% 14% / 0.18), transparent)",
}}
/>
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
style={{
background:
"linear-gradient(to right, transparent, hsl(30 14% 14% / 0.5) 12%, hsl(30 14% 14% / 0.7) 50%, hsl(30 14% 14% / 0.5) 88%, transparent)",
}}
/>
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-2"
style={{ pointerEvents: "none" }}
>
{Array.from({ length: 48 }, (_, i) => (
<span
key={i}
className="block h-[3px] w-[3px] rounded-full"
style={{ backgroundColor: "hsl(30 14% 14% / 0.22)" }}
/>
))}
</div>
<div className="relative z-10 mx-auto h-full flex items-center justify-center gap-3 bg-background px-4 w-fit">
<span
className="display italic"
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
>
</span>
<span
className="mono uppercase tracking-[0.24em]"
style={{
color: "hsl(var(--muted-foreground))",
fontSize: 11,
fontWeight: 500,
}}
>
The register
</span>
<span
className="display italic"
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
>
</span>
</div>
</div>
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<BatchesListSkeleton />
) : items.length === 0 ? (
<EmptyState
eyebrow="Batches · awaiting first ingest"
message="Upload an 837P or 835 file on the Upload page to populate this list."
{/* =================================================================
PAPER PLANE
================================================================= */}
<div
className="relative max-w-[1280px] mx-auto"
style={{
animationDelay: `${titleDelay}ms`,
backgroundColor: "hsl(var(--surface))",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 30px 80px -24px hsl(0 0% 0% / 0.45)",
}}
>
{/* Paper grain */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.08 0 0 0 0 0.05 0 0 0 0.9 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\")",
backgroundSize: "160px 160px",
mixBlendMode: "multiply",
}}
/>
{/* Title block */}
<div
className="relative px-8 lg:px-14 pt-12 pb-9 border-b animate-fade-in-up"
style={{
animationDelay: `${titleDelay}ms`,
borderColor: "hsl(30 14% 14% / 0.12)",
}}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-0 bottom-0 w-px"
style={{ backgroundColor: "hsl(30 14% 14% / 0.14)" }}
/>
) : (
<BatchesList items={items} openId={batchId} onOpen={open} />
)}
<div className="flex items-end justify-between gap-8 flex-wrap">
<div>
<div
className="mono text-[12px] uppercase tracking-[0.24em] mb-4 font-medium"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Register · Parsed batches
</div>
<h2
className="display leading-[0.92] tracking-[-0.04em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(48px, 7vw, 96px)",
fontWeight: 400,
}}
>
The register.
</h2>
<div
className="mt-4 display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: "clamp(15px, 1.3vw, 18px)",
lineHeight: 1.4,
maxWidth: "32ch",
}}
>
One row per ingested file the kind, the input filename,
how many claims it carried, and the day it was parsed.
</div>
</div>
<div className="text-right shrink-0">
<div
className="mono text-[11px] uppercase tracking-[0.24em] mb-1.5 font-medium"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
On file
</div>
<div
className="display tabular-nums"
style={{
color: "hsl(var(--surface-ink))",
fontSize: 26,
lineHeight: 1.1,
}}
>
{totals.count}
</div>
<div
className="mono text-[11px] mt-1"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
batch{totals.count === 1 ? "" : "es"} · {totals.claims}{" "}
claim{totals.claims === 1 ? "" : "s"}
</div>
</div>
</div>
</div>
{/* KPI strip — § 01 Vital signs */}
<section
aria-label="Batch register metrics"
className="relative px-8 lg:px-14 py-7 animate-fade-in-up"
style={{ animationDelay: `${kpiDelay}ms` }}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-7 flex flex-col items-center gap-1"
>
<span
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
§ 01
</span>
<div
className="w-px h-10"
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
/>
<span
className="display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: 11,
writingMode: "vertical-rl",
transform: "rotate(180deg)",
letterSpacing: "0.16em",
}}
>
Vital signs
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<BatchesKpiTile
label="On file"
value={totals.count}
icon={<Layers className="h-3 w-3" strokeWidth={1.75} />}
tone="ink"
/>
<BatchesKpiTile
label="837P"
value={totals.p837}
icon={<GitBranch className="h-3 w-3" strokeWidth={1.75} />}
tone="blue"
/>
<BatchesKpiTile
label="835"
value={totals.p835}
icon={<Receipt className="h-3 w-3" strokeWidth={1.75} />}
tone="amber"
/>
<BatchesKpiTile
label="Claims"
value={totals.claims}
tone="success"
/>
</div>
</section>
{/* Table — § 02 The register */}
<section
aria-label="Batch register"
className="relative px-8 lg:px-14 pb-8 lg:pb-10 animate-fade-in-up"
style={{ animationDelay: `${tableDelay}ms` }}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-9 flex flex-col items-center gap-1"
>
<span
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
§ 02
</span>
<div
className="w-px h-12"
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
/>
<span
className="display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: 11,
writingMode: "vertical-rl",
transform: "rotate(180deg)",
letterSpacing: "0.16em",
}}
>
The register
</span>
</div>
<div
className="pt-6 border-t"
style={{
borderTopStyle: "double",
borderTopWidth: 3,
borderColor: "hsl(30 14% 14% / 0.12)",
}}
>
<div className="flex items-end justify-between gap-6 flex-wrap mb-5">
<div>
<div
className="mono text-[11.5px] uppercase tracking-[0.24em] font-semibold mb-2"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
The register
</div>
<h3
className="display leading-[0.98] tracking-[-0.03em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(24px, 2.6vw, 32px)",
fontWeight: 400,
}}
>
All <span className="italic">ingests</span>, newest first.
</h3>
</div>
<div
className="text-[12.5px] display italic"
style={{ color: "hsl(var(--surface-ink-2))", maxWidth: 380 }}
>
Tap a row to open the envelope drawer. Use{" "}
<kbd
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
style={{
borderColor: "hsl(30 14% 14% / 0.20)",
color: "hsl(var(--surface-ink))",
}}
>
</kbd>{" "}
/{" "}
<kbd
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
style={{
borderColor: "hsl(30 14% 14% / 0.20)",
color: "hsl(var(--surface-ink))",
}}
>
</kbd>{" "}
inside the drawer to step.
</div>
</div>
{isError ? (
<div
className="rounded-md border p-5"
style={{
borderColor: "hsl(30 14% 14% / 0.16)",
backgroundColor: "hsl(36 22% 96%)",
}}
>
<ErrorState
message="Couldn't load batches from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
</div>
) : isLoading ? (
<div
className="rounded-md border p-4 space-y-2"
style={{
borderColor: "hsl(30 14% 14% / 0.10)",
backgroundColor: "hsl(36 22% 96%)",
}}
>
<BatchesListSkeleton />
</div>
) : items.length === 0 ? (
<div
className="rounded-md border p-10 text-center"
style={{
borderColor: "hsl(30 14% 14% / 0.16)",
borderStyle: "dashed",
backgroundColor: "hsl(36 22% 96%)",
}}
>
<EmptyState
eyebrow="Batches · awaiting first ingest"
message="Upload an 837P or 835 file on the Upload page to populate this list."
/>
</div>
) : (
<div
className="rounded-md border overflow-hidden"
style={{
borderColor: "hsl(30 14% 14% / 0.10)",
backgroundColor: "hsl(36 22% 98%)",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
}}
>
<BatchesList
items={items}
openId={batchId}
onOpen={open}
tone="paper"
/>
</div>
)}
</div>
</section>
{/* Footer */}
<div
className="relative px-8 lg:px-14 py-5 border-t flex items-center justify-between mono text-[10.5px] uppercase tracking-[0.18em]"
style={{
borderColor: "hsl(30 14% 14% / 0.12)",
color: "hsl(var(--surface-ink-3))",
}}
>
<span>End of register</span>
<span>Cyclone · Batches</span>
<span>
{totals.count} {totals.count === 1 ? "row" : "rows"}
</span>
</div>
</div>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div
className="relative rounded-xl p-5 overflow-hidden border"
style={{
backgroundColor: "hsl(var(--surface))",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06), inset 3px 0 0 0 hsl(0 0% 100% / 0.4)",
borderColor: "hsl(30 14% 14% / 0.10)",
}}
>
<div
aria-hidden
className="absolute left-0 top-5 bottom-5 w-[3px] rounded-r-sm"
style={{ backgroundColor: accent, opacity: 0.85 }}
/>
<div className="flex items-center justify-between mb-2.5">
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{label}
</div>
<div
className="h-5 w-5 rounded-md flex items-center justify-center"
style={{ backgroundColor: tint, color: accent }}
>
{icon ?? (
<span
className="block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: accent }}
/>
)}
</div>
</div>
<div
className="display tabular-nums tracking-[-0.04em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(28px, 3vw, 40px)",
lineHeight: 1,
fontWeight: 400,
}}
>
{value}
</div>
</div>
);
}