feat(frontend): Batches browser page with list + detail drawer
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user