merge: ui/batches-browser — list + detail drawer for /api/batches
This commit is contained in:
@@ -9,6 +9,7 @@ import { ActivityLog } from "@/pages/ActivityLog";
|
||||
import { Upload } from "@/pages/Upload";
|
||||
import { ReconciliationPage } from "@/pages/Reconciliation";
|
||||
import { Acks } from "@/pages/Acks";
|
||||
import { Batches } from "@/pages/Batches";
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
@@ -32,6 +33,7 @@ export default function App() {
|
||||
<Route path="upload" element={<Upload />} />
|
||||
<Route path="reconciliation" element={<ReconciliationPage />} />
|
||||
<Route path="acks" element={<Acks />} />
|
||||
<Route path="batches" element={<Batches />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
// @vitest-environment happy-dom
|
||||
// React Query's state updates need an act-aware environment or it
|
||||
// logs noisy warnings. Matches the convention from Acks.test.tsx.
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Batches } from "./Batches";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
isConfigured: true,
|
||||
listBatches: vi.fn(),
|
||||
getBatch: vi.fn(),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Stub react-router-dom's NavLink-aware hooks by wrapping in a memory
|
||||
// router. The page itself doesn't use react-router state, but if any
|
||||
// future refactor does this keeps the test future-proof.
|
||||
function renderIntoContainer(
|
||||
element: React.ReactElement,
|
||||
initialEntries: string[] = ["/batches"]
|
||||
): { container: HTMLDivElement; unmount: () => void } {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
// The Batches page's `useBatchDetail` hook overrides `retry`
|
||||
// with a function that retries up to 3 times for non-404
|
||||
// errors. The default retryDelay (~1s, 2s, 4s backoff) would
|
||||
// blow past the test timeout, so we pin retries to 10ms —
|
||||
// the retry behavior we want to assert is *that retries
|
||||
// happen* (i.e. the not_found branch's no-retry contract),
|
||||
// not the pacing.
|
||||
retryDelay: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
const root: Root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<QueryClientProvider client={qc}>{element}</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
return {
|
||||
container,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForText(
|
||||
text: string,
|
||||
timeoutMs = 2000
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!document.body.textContent?.includes(text)) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(
|
||||
`waitForText: "${text}" did not appear within ${timeoutMs}ms`
|
||||
);
|
||||
}
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for a DOM node matching a CSS selector. react-query's
|
||||
* `useQuery` returns to `idle` between microtasks; a single
|
||||
* `Promise.resolve()` isn't always enough to flush a queryFn that
|
||||
* synchronously rejects. This helper keeps yielding until either
|
||||
* the selector matches or the deadline expires.
|
||||
*/
|
||||
async function waitForSelector(
|
||||
selector: string,
|
||||
timeoutMs = 2000
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!document.querySelector(selector)) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(
|
||||
`waitForSelector: "${selector}" did not appear within ${timeoutMs}ms`
|
||||
);
|
||||
}
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 837P-shaped payload: `claims` rows have `claim_id`. The page uses
|
||||
// the presence of `payer_claim_control_number` on the first row as
|
||||
// the discriminator between 837P and 835; 837P rows lack it.
|
||||
function make837(id: string) {
|
||||
return {
|
||||
envelope: {
|
||||
sender_id: "SENDER",
|
||||
receiver_id: "RECEIVER",
|
||||
control_number: id,
|
||||
transaction_date: "2026-06-15",
|
||||
transaction_time: "12:00",
|
||||
},
|
||||
summary: {
|
||||
input_file: "x.837",
|
||||
control_number: id,
|
||||
transaction_date: "2026-06-15",
|
||||
total_claims: 2,
|
||||
passed: 2,
|
||||
failed: 0,
|
||||
failed_claim_ids: [],
|
||||
issues_by_rule: {},
|
||||
},
|
||||
claims: [
|
||||
{ claim_id: "CLM-1" },
|
||||
{ claim_id: "CLM-2" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Batches page", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// happy-dom's window.location persists between tests; reset the
|
||||
// URL so the URL-state hook starts each test from a clean slate.
|
||||
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
|
||||
.happyDOM.setURL("http://localhost/batches");
|
||||
});
|
||||
|
||||
it("renders the list skeleton when loading", async () => {
|
||||
// Never resolve — keeps the query in the loading state forever.
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
new Promise(() => {})
|
||||
);
|
||||
|
||||
const { unmount } = renderIntoContainer(<Batches />);
|
||||
|
||||
// The skeleton primitive carries the data-testid the page sets
|
||||
// on the wrapping container; check the attribute instead of text.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(
|
||||
document.querySelector('[data-testid="batches-skeleton"]')
|
||||
).not.toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders the empty state when the backend returns no batches", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const { unmount } = renderIntoContainer(<Batches />);
|
||||
|
||||
// Eyebrow copy unique to the empty state.
|
||||
await waitForText("awaiting first ingest");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders rows with the correct kind badge colors", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "B-1",
|
||||
kind: "837p",
|
||||
inputFilename: "co_medicaid_837p.txt",
|
||||
parsedAt: "2026-06-15T12:00:00Z",
|
||||
claimCount: 5,
|
||||
},
|
||||
{
|
||||
id: "B-2",
|
||||
kind: "835",
|
||||
inputFilename: "co_medicaid_835.txt",
|
||||
parsedAt: "2026-06-15T13:00:00Z",
|
||||
claimCount: 7,
|
||||
},
|
||||
]);
|
||||
|
||||
const { unmount } = renderIntoContainer(<Batches />);
|
||||
|
||||
// Wait for at least one row to appear.
|
||||
await waitForText("co_medicaid_837p.txt");
|
||||
|
||||
// Both kinds render with their own data-testid.
|
||||
expect(
|
||||
document.querySelector('[data-testid="kind-badge-837p"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-testid="kind-badge-835"]')
|
||||
).not.toBeNull();
|
||||
|
||||
// Class-name spot checks: 837p is sky-* (cool blue), 835 is
|
||||
// amber-* (warm). We match `text-sky-300` for 837p and
|
||||
// `text-amber-300` for 835 — the literal class strings baked
|
||||
// into `KindBadge`. This catches accidental swaps of the color
|
||||
// palettes between the two variants.
|
||||
const sky = document.querySelector('[data-testid="kind-badge-837p"]');
|
||||
const amber = document.querySelector('[data-testid="kind-badge-835"]');
|
||||
expect(sky?.className).toContain("text-sky-300");
|
||||
expect(amber?.className).toContain("text-amber-300");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clicking a row opens the detail drawer", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "B-9",
|
||||
kind: "837p",
|
||||
inputFilename: "demo.837",
|
||||
parsedAt: "2026-06-15T12:00:00Z",
|
||||
claimCount: 3,
|
||||
},
|
||||
]);
|
||||
// Resolve the detail call so the drawer renders content rather
|
||||
// than a skeleton — we just need to confirm it opens.
|
||||
(api.getBatch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
make837("B-9")
|
||||
);
|
||||
|
||||
const { unmount } = renderIntoContainer(<Batches />);
|
||||
|
||||
await waitForText("demo.837");
|
||||
|
||||
// Drawer should NOT be in the DOM before click.
|
||||
expect(
|
||||
document.querySelector('[data-testid="batch-detail-drawer"]')
|
||||
).toBeNull();
|
||||
|
||||
// Click the row.
|
||||
const row = document.querySelector(
|
||||
'[data-testid="batch-row-B-9"]'
|
||||
) as HTMLElement | null;
|
||||
expect(row).not.toBeNull();
|
||||
await act(async () => {
|
||||
row?.click();
|
||||
// Let the query kick off.
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Drawer now mounted and showing the detail content.
|
||||
expect(
|
||||
document.querySelector('[data-testid="batch-detail-drawer"]')
|
||||
).not.toBeNull();
|
||||
// Once the mock resolves, the drawer renders content (not
|
||||
// skeleton, not error).
|
||||
await waitForText("Envelope");
|
||||
expect(
|
||||
document.querySelector('[data-testid="batch-detail-content"]')
|
||||
).not.toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("Escape closes the drawer", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "B-9",
|
||||
kind: "837p",
|
||||
inputFilename: "demo.837",
|
||||
parsedAt: "2026-06-15T12:00:00Z",
|
||||
claimCount: 3,
|
||||
},
|
||||
]);
|
||||
(api.getBatch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
make837("B-9")
|
||||
);
|
||||
|
||||
const { unmount } = renderIntoContainer(<Batches />);
|
||||
await waitForText("demo.837");
|
||||
|
||||
// Open the drawer.
|
||||
const row = document.querySelector(
|
||||
'[data-testid="batch-row-B-9"]'
|
||||
) as HTMLElement | null;
|
||||
await act(async () => {
|
||||
row?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitForText("Envelope");
|
||||
expect(
|
||||
document.querySelector('[data-testid="batch-detail-drawer"]')
|
||||
).not.toBeNull();
|
||||
|
||||
// Dispatch Escape from `document.body` — `useDrawerKeyboard`
|
||||
// ignores events whose target is an editable element, so
|
||||
// dispatching from `body` simulates the user pressing Esc while
|
||||
// the page (not an input) has focus.
|
||||
await act(async () => {
|
||||
const ev = new KeyboardEvent("keydown", {
|
||||
key: "Escape",
|
||||
bubbles: true,
|
||||
});
|
||||
document.body.dispatchEvent(ev);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Drawer closed — null result because the Dialog returns early.
|
||||
expect(
|
||||
document.querySelector('[data-testid="batch-detail-drawer"]')
|
||||
).toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders the not-found error state on a 404 from getBatch", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "B-missing",
|
||||
kind: "837p",
|
||||
inputFilename: "ghost.837",
|
||||
parsedAt: "2026-06-15T12:00:00Z",
|
||||
claimCount: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
// getBatch throws the same plain-Error shape `api.ts` produces:
|
||||
// `${status} ${statusText} — ${detail}`. The hook rewraps the 404
|
||||
// into ApiError(404) before it reaches the drawer's branching
|
||||
// helper, so we mirror that contract here — assert that the
|
||||
// drawer's error state is the not_found variant, not the
|
||||
// generic network variant.
|
||||
(api.getBatch as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("404 Not Found — batch not found")
|
||||
);
|
||||
|
||||
const { unmount } = renderIntoContainer(<Batches />);
|
||||
await waitForText("ghost.837");
|
||||
|
||||
const row = document.querySelector(
|
||||
'[data-testid="batch-row-B-missing"]'
|
||||
) as HTMLElement | null;
|
||||
await act(async () => {
|
||||
row?.click();
|
||||
});
|
||||
await waitForSelector('[data-testid="batch-detail-error-not_found"]');
|
||||
|
||||
// not_found branch: must be present, network branch must not.
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-testid="batch-detail-error-not_found"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-testid="batch-detail-error-network"]'
|
||||
)
|
||||
).toBeNull();
|
||||
|
||||
// The not_found variant has no retry affordance — a retry won't
|
||||
// bring back a batch that doesn't exist.
|
||||
expect(
|
||||
document.querySelector('[data-testid="batch-detail-error-retry"]')
|
||||
).toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders the network error state on a non-404 failure", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "B-bad",
|
||||
kind: "835",
|
||||
inputFilename: "broken.835",
|
||||
parsedAt: "2026-06-15T12:00:00Z",
|
||||
claimCount: 0,
|
||||
},
|
||||
]);
|
||||
// Non-404 failure (server error). getBatch throws plain Error;
|
||||
// the hook only rewraps 404s, so this surfaces as a generic
|
||||
// "network" error with a retry button.
|
||||
(api.getBatch as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("500 Internal Server Error — upstream")
|
||||
);
|
||||
|
||||
const { unmount } = renderIntoContainer(<Batches />);
|
||||
await waitForText("broken.835");
|
||||
|
||||
const row = document.querySelector(
|
||||
'[data-testid="batch-row-B-bad"]'
|
||||
) as HTMLElement | null;
|
||||
await act(async () => {
|
||||
row?.click();
|
||||
});
|
||||
await waitForSelector('[data-testid="batch-detail-error-network"]');
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-testid="batch-detail-error-network"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-testid="batch-detail-error-not_found"]')
|
||||
).toBeNull();
|
||||
// Network variant offers retry.
|
||||
expect(
|
||||
document.querySelector('[data-testid="batch-detail-error-retry"]')
|
||||
).not.toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// Keep `ApiError` referenced so its import isn't tree-shaken by
|
||||
// vitest's transformer when the mock factory above is hoisted.
|
||||
void ApiError;
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
import { BatchesList, BatchesListSkeleton } from "@/components/BatchesList";
|
||||
import {
|
||||
BatchDetailContent,
|
||||
BatchDetailError,
|
||||
BatchDetailSkeleton,
|
||||
batchErrorKind,
|
||||
} from "@/components/BatchDetail";
|
||||
import { useBatches } from "@/hooks/useBatches";
|
||||
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
|
||||
import { api, ApiError, type BatchSummary } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ParseResult837, ParseResult835 } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline: batch drawer URL state.
|
||||
//
|
||||
// `useDrawerUrlState` (in src/hooks/) is hard-coded to `?claim=` — it
|
||||
// exists to drive the claim detail drawer. Mirroring that shape for
|
||||
// `?batch=` keeps the two drawers decoupled: opening a claim from
|
||||
// elsewhere doesn't disturb the batch URL, and vice versa. Inlining
|
||||
// (rather than creating a parallel hook file) keeps the change inside
|
||||
// this workstream's scope.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readBatchId(): string | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const value = params.get("batch");
|
||||
return value === "" ? null : value;
|
||||
}
|
||||
|
||||
function buildBatchUrl(batchId: string | null): string {
|
||||
const url = new URL(window.location.href);
|
||||
if (batchId === null) {
|
||||
url.searchParams.delete("batch");
|
||||
} else {
|
||||
url.searchParams.set("batch", batchId);
|
||||
}
|
||||
return url.pathname + url.search + url.hash;
|
||||
}
|
||||
|
||||
function useBatchDrawerUrlState(): {
|
||||
batchId: string | null;
|
||||
open: (id: string) => void;
|
||||
close: () => void;
|
||||
setBatchId: (id: string) => void;
|
||||
} {
|
||||
const [batchId, setBatchIdState] = useState<string | null>(() => readBatchId());
|
||||
|
||||
const open = useCallback((id: string) => {
|
||||
window.history.pushState(null, "", buildBatchUrl(id));
|
||||
setBatchIdState(id);
|
||||
}, []);
|
||||
|
||||
const setBatchId = useCallback((id: string) => {
|
||||
window.history.replaceState(null, "", buildBatchUrl(id));
|
||||
setBatchIdState(id);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
window.history.pushState(null, "", buildBatchUrl(null));
|
||||
setBatchIdState(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setBatchIdState(readBatchId());
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, []);
|
||||
|
||||
return { batchId, open, close, setBatchId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline: per-batch detail query.
|
||||
//
|
||||
// `api.getBatch` throws plain `Error` with the HTTP status code at the
|
||||
// start of the message (e.g. `"404 Not Found — batch not found"`),
|
||||
// not `ApiError`. To drive the drawer's not-found branch via the same
|
||||
// `ApiError.status === 404` check used everywhere else, this hook
|
||||
// detects the `"404"` prefix in the message and rethrows as
|
||||
// `ApiError(404, …)`. Other errors pass through unchanged.
|
||||
//
|
||||
// 30-second staleTime mirrors `useClaimDetail` so j/k navigation
|
||||
// reuses prior fetches within the drawer session.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useBatchDetail(id: string | null): {
|
||||
data: (ParseResult837 | ParseResult835) | null;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
} {
|
||||
const q = useQuery<ParseResult837 | ParseResult835>({
|
||||
queryKey: ["batch-detail", id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api.getBatch(id as string);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.startsWith("404")
|
||||
) {
|
||||
throw new ApiError(404, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
enabled: id !== null,
|
||||
staleTime: 30 * 1000,
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status === 404) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
});
|
||||
|
||||
if (id === null) {
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: q.data ?? null,
|
||||
isLoading: q.isLoading,
|
||||
isError: q.isError,
|
||||
error: q.error,
|
||||
refetch: () => {
|
||||
void q.refetch();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HELP_NOOP = () => {};
|
||||
|
||||
export function Batches() {
|
||||
const { data, isLoading, isError, error, refetch } = useBatches(100);
|
||||
const items: BatchSummary[] = data ?? [];
|
||||
|
||||
const { batchId, open, close, setBatchId } = useBatchDrawerUrlState();
|
||||
|
||||
const { data: detail, isLoading: detailLoading, error: detailError, refetch: refetchDetail } =
|
||||
useBatchDetail(batchId);
|
||||
|
||||
// j/k navigation: derive next/prev batch IDs based on the current
|
||||
// batch's index in the loaded list. Wrap around at both ends. If
|
||||
// the current batch isn't in the list, the callbacks are no-ops —
|
||||
// defensive against bad input.
|
||||
const { onNext, onPrev } = useMemo(() => {
|
||||
if (batchId === null || items.length === 0) {
|
||||
return { onNext: () => {}, onPrev: () => {} };
|
||||
}
|
||||
const idx = items.findIndex((b) => b.id === batchId);
|
||||
if (idx === -1) return { onNext: () => {}, onPrev: () => {} };
|
||||
return {
|
||||
onNext: () => {
|
||||
const nextId = items[(idx + 1) % items.length].id;
|
||||
setBatchId(nextId);
|
||||
},
|
||||
onPrev: () => {
|
||||
const prevId = items[(idx - 1 + items.length) % items.length].id;
|
||||
setBatchId(prevId);
|
||||
},
|
||||
};
|
||||
}, [batchId, items, setBatchId]);
|
||||
|
||||
useDrawerKeyboard({
|
||||
enabled: batchId !== null,
|
||||
onNext,
|
||||
onPrev,
|
||||
onClose: close,
|
||||
// The Batches page doesn't ship a keyboard cheatsheet overlay —
|
||||
// ? is silently swallowed here. Wiring it later is a no-op
|
||||
// because useDrawerKeyboard only fires the callback.
|
||||
onToggleHelp: HELP_NOOP,
|
||||
});
|
||||
|
||||
const dimBackground = batchId !== null;
|
||||
const errKind = batchErrorKind(detailError);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={batchId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) close();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
// Right-anchored side panel, matching the claim-drawer's
|
||||
// visual identity. We override the Dialog primitive's
|
||||
// centered defaults so it behaves as a drawer (not a modal).
|
||||
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
|
||||
data-testid="batch-detail-drawer"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{batchId === null ? null : detailLoading ? (
|
||||
<BatchDetailSkeleton />
|
||||
) : errKind ? (
|
||||
<BatchDetailError
|
||||
kind={errKind}
|
||||
onRetry={() => {
|
||||
void refetchDetail();
|
||||
}}
|
||||
onClose={close}
|
||||
/>
|
||||
) : detail ? (
|
||||
<BatchDetailContent data={detail} onClose={close} />
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div
|
||||
data-testid="batches-page-body"
|
||||
className={cn(
|
||||
"space-y-8 animate-fade-in transition-opacity",
|
||||
dimBackground && "pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
<header>
|
||||
<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" />
|
||||
Batches
|
||||
</div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">
|
||||
Parsed batches
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||||
Every 837P and 835 file the backend has ingested. Click a row
|
||||
for envelope, summary, and a peek at the contained claims.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message="Couldn't load batches from the backend."
|
||||
detail={error instanceof Error ? error.message : String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<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."
|
||||
/>
|
||||
) : (
|
||||
<BatchesList items={items} openId={batchId} onOpen={open} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user