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 (
<>