merge: ui/batches-browser — list + detail drawer for /api/batches

This commit is contained in:
Tyler
2026-06-20 16:59:44 -06:00
6 changed files with 1160 additions and 0 deletions
+321
View File
@@ -0,0 +1,321 @@
import { AlertCircle, WifiOff, X } from "lucide-react";
import { ApiError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type {
Envelope,
ParseResult837,
ParseResult835,
} from "@/types";
/**
* Skeleton placeholder for the batch detail drawer body. The numbers
* are tuned to mirror the actual content sections (header, envelope,
* claims/payments, summary stats) so the drawer doesn't reflow on
* load.
*/
export function BatchDetailSkeleton() {
return (
<div
className="flex flex-col gap-6 p-6"
data-testid="batch-detail-skeleton"
aria-busy="true"
>
<Skeleton variant="default" height={48} />
<Skeleton variant="row" />
<div className="flex flex-col gap-2">
<Skeleton variant="row" />
<Skeleton variant="row" />
<Skeleton variant="row" />
</div>
<Skeleton variant="default" height={96} />
</div>
);
}
const COPY = {
not_found: {
eyebrow: "NOT FOUND",
message: "This batch doesn't exist or has been removed.",
},
network: {
eyebrow: "CONNECTION",
message: "Couldn't reach the server. Check your connection and try again.",
},
} as const;
type BatchDetailErrorProps = {
kind: "not_found" | "network";
onRetry?: () => void;
onClose: () => void;
};
/**
* Two-shape error state — `not_found` (the batch id doesn't resolve,
* e.g. a stale deep link) vs `network` (transient fetch failure).
* Not_found has no retry affordance (retrying won't help); network
* does when `onRetry` is supplied. Mirrors `ClaimDrawerError`.
*/
export function BatchDetailError({
kind,
onRetry,
onClose,
}: BatchDetailErrorProps) {
const { eyebrow, message } = COPY[kind];
const Icon = kind === "network" ? WifiOff : AlertCircle;
return (
<div
className="flex flex-col items-start gap-4 p-6"
role="alert"
data-testid={`batch-detail-error-${kind}`}
>
<div className="flex items-center gap-2">
<Icon
className="h-5 w-5 text-destructive"
strokeWidth={1.75}
aria-hidden
/>
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive/80">
{eyebrow}
</span>
</div>
<p className="text-sm text-muted-foreground">{message}</p>
<div className="flex items-center gap-2">
{kind === "network" && onRetry ? (
<Button
variant="outline"
size="sm"
onClick={onRetry}
data-testid="batch-detail-error-retry"
>
Retry
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onClick={onClose}
data-testid="batch-detail-error-close"
>
Close
</Button>
</div>
</div>
);
}
/**
* Branch the raw error into the two shapes the spec calls out:
* - ApiError(404) → "not_found" (no retry, the batch is gone)
* - anything else → "network" (retry available)
*
* Note: `api.getBatch` currently throws plain `Error` with the HTTP
* status at the start of the message (e.g. `"404 Not Found — …"`),
* not `ApiError`. The caller is expected to rewrap 404s into
* `ApiError(404, …)` before they reach this helper — that's done in
* the per-batch detail hook inside `Batches.tsx`.
*/
export function batchErrorKind(error: Error | null): "not_found" | "network" | null {
if (!error) return null;
if (error instanceof ApiError && error.status === 404) return "not_found";
return "network";
}
// ---------------------------------------------------------------------------
// Content (rendered once data is available)
// ---------------------------------------------------------------------------
function EnvBlock({ envelope }: { envelope: Envelope | null }) {
if (!envelope) return null;
return (
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
<Field label="Sender" value={envelope.sender_id} mono />
<Field label="Receiver" value={envelope.receiver_id} mono />
<Field label="Control #" value={envelope.control_number} mono />
<Field
label="Date"
value={`${envelope.transaction_date}${
envelope.transaction_time ? ` ${envelope.transaction_time}` : ""
}`}
mono
/>
</div>
);
}
function Field({
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="min-w-0">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1">
{label}
</div>
<div
className={
mono
? "font-mono text-[12.5px] truncate"
: "text-[13px] truncate"
}
>
{value}
</div>
</div>
);
}
/**
* Detail content. Accepts the parser's union return type — the actual
* shape varies between 837P and 835 — and renders the shared envelope
* plus a kind-specific summary. We keep this minimal (envelope +
* counts + a peek at the claim list) because the page-level scope is
* "batches browser" rather than "full per-batch inspector".
*
* Discriminating 837P vs 835: both variants carry `claims`, but the
* row shapes differ (837P = `ClaimOutput` with `claim_id`, 835 =
* `ClaimPayment` with `payer_claim_control_number`). The presence of
* `payer_claim_control_number` on the first row is the discriminator.
*/
export function BatchDetailContent({
data,
onClose,
}: {
data: ParseResult837 | ParseResult835;
onClose: () => void;
}) {
const list = data.claims;
const firstIsPayment =
list.length > 0 &&
"payer_claim_control_number" in (list[0] as object);
const count = list.length;
const summary = data.summary;
return (
<div
className="flex h-full flex-col overflow-y-auto"
data-testid="batch-detail-content"
>
<header className="flex items-start justify-between gap-4 p-6 border-b border-border/60">
<div className="min-w-0">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Batch detail
</div>
<h2 className="text-[18px] font-semibold tracking-tight truncate">
{summary.control_number ?? "Uncontrolled"}
</h2>
<div className="mt-1.5 text-[13px] text-muted-foreground">
{firstIsPayment ? "835 ERA" : "837P"} ·{" "}
<span className="display num text-foreground">
{fmt.num(count)}
</span>{" "}
{firstIsPayment ? "payments" : "claims"}
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="rounded-md p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors"
data-testid="batch-detail-close"
>
<X className="h-4 w-4" />
</button>
</header>
<section className="p-6 border-b border-border/60">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-3">
Envelope
</div>
<EnvBlock envelope={data.envelope} />
</section>
<section className="p-6 border-b border-border/60">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-3">
Summary
</div>
<div className="grid grid-cols-3 gap-4">
<Stat label="Total" value={fmt.num(summary.total_claims)} />
<Stat
label="Passed"
value={fmt.num(summary.passed)}
tone="success"
/>
<Stat
label="Failed"
value={fmt.num(summary.failed)}
tone={summary.failed > 0 ? "destructive" : "muted"}
/>
</div>
</section>
{list.length > 0 ? (
<section className="p-6">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-3">
{firstIsPayment ? "Payments" : "Claims"} · first{" "}
{Math.min(10, list.length)}
</div>
<ul className="font-mono text-[12px] space-y-1.5">
{list.slice(0, 10).map((c, i) => {
const id = firstIsPayment
? (c as { payer_claim_control_number: string })
.payer_claim_control_number
: (c as { claim_id: string }).claim_id;
return (
<li
key={id ?? i}
className="flex items-center gap-2 text-muted-foreground"
>
<span className="text-[10.5px] uppercase tracking-[0.14em] w-6 text-right">
{i + 1}
</span>
<span className="truncate">{id}</span>
</li>
);
})}
</ul>
{list.length > 10 ? (
<div className="mt-3 text-[11px] text-muted-foreground">
+ {fmt.num(list.length - 10)} more
</div>
) : null}
</section>
) : null}
</div>
);
}
function Stat({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "success" | "destructive" | "muted";
}) {
const color =
tone === "success"
? "text-emerald-400"
: tone === "destructive"
? "text-red-400"
: "text-foreground";
return (
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1">
{label}
</div>
<div className={cn("display num text-[20px]", color)}>{value}</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { AnimatedNumber } from "@/components/AnimatedNumber";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { fmt } from "@/lib/format";
import type { BatchSummary } from "@/lib/api";
/**
* Colored kind badge — 837p in cool blue, 835 in warm amber. The colors
* are deliberately not the project-wide `--accent` electric blue (that's
* reserved for active nav + primary CTAs) so the rows scan as a
* two-flavor instrument instead of a uniform accent.
*
* Voice mirrors `AckCodeBadge` in `src/pages/Acks.tsx` (uppercase,
* wide tracking, hairline border, low-opacity fill).
*/
function KindBadge({ kind }: { kind: BatchSummary["kind"] }) {
const color =
kind === "837p"
? "text-sky-300 border-sky-400/30 bg-sky-400/10"
: "text-amber-300 border-amber-400/30 bg-amber-400/10";
return (
<span
data-testid={`kind-badge-${kind}`}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10.5px] font-semibold uppercase tracking-[0.14em] font-mono",
color,
)}
>
{kind}
</span>
);
}
/**
* 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.
*/
export function BatchesListSkeleton() {
return (
<div className="p-4 space-y-2" data-testid="batches-skeleton">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
);
}
type BatchesListProps = {
items: BatchSummary[];
/**
* Currently-open batch id (or null when drawer is closed). The open
* row gets a subtle background tint so the operator knows which
* batch the drawer is showing.
*/
openId: string | null;
onOpen: (id: string) => void;
};
/**
* Tabular list of every persisted batch. Rows are clickable — clicking
* opens the detail drawer. `AnimatedNumber` is used for `claimCount`
* 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) {
return (
<Table data-testid="batches-table">
<TableHeader>
<TableRow>
<TableHead>Kind</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Input file</TableHead>
<TableHead className="text-right">Claims</TableHead>
<TableHead>Parsed</TableHead>
<TableHead className="w-6" aria-label="Open" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((b) => (
<TableRow
key={b.id}
onClick={() => onOpen(b.id)}
data-testid={`batch-row-${b.id}`}
data-open={openId === b.id ? "true" : undefined}
className={cn(
"animate-row-flash cursor-pointer",
openId === b.id && "bg-muted/40",
)}
>
<TableCell>
<KindBadge kind={b.kind} />
</TableCell>
<TableCell className="display num text-[13px]">
{b.id}
</TableCell>
<TableCell className="font-mono text-[12px] text-muted-foreground truncate max-w-[280px]">
{b.inputFilename}
</TableCell>
<TableCell className="text-right display num text-[13px]">
<AnimatedNumber
value={b.claimCount}
format={(n) => fmt.num(Math.round(n))}
/>
</TableCell>
<TableCell className="text-muted-foreground num text-[12.5px]">
{b.parsedAt ? fmt.dateShort(b.parsedAt) : "—"}
</TableCell>
<TableCell className="text-muted-foreground text-right">
<span aria-hidden></span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
+17
View File
@@ -3,6 +3,7 @@ import {
Activity,
CheckCircle2,
GitMerge,
Layers,
LayoutDashboard,
Receipt,
Stethoscope,
@@ -116,6 +117,22 @@ export function Sidebar() {
<span>999 ACKs</span>
</NavLink>
</li>
<li>
<NavLink
to="/batches"
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "nav-active"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
)
}
>
<Layers className="h-4 w-4" strokeWidth={1.5} />
<span>Batches</span>
</NavLink>
</li>
</ul>
</nav>