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 { Upload } from "@/pages/Upload";
|
||||||
import { ReconciliationPage } from "@/pages/Reconciliation";
|
import { ReconciliationPage } from "@/pages/Reconciliation";
|
||||||
import { Acks } from "@/pages/Acks";
|
import { Acks } from "@/pages/Acks";
|
||||||
|
import { Batches } from "@/pages/Batches";
|
||||||
|
|
||||||
function NotFound() {
|
function NotFound() {
|
||||||
return (
|
return (
|
||||||
@@ -32,6 +33,7 @@ export default function App() {
|
|||||||
<Route path="upload" element={<Upload />} />
|
<Route path="upload" element={<Upload />} />
|
||||||
<Route path="reconciliation" element={<ReconciliationPage />} />
|
<Route path="reconciliation" element={<ReconciliationPage />} />
|
||||||
<Route path="acks" element={<Acks />} />
|
<Route path="acks" element={<Acks />} />
|
||||||
|
<Route path="batches" element={<Batches />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</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,
|
Activity,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
|
Layers,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Receipt,
|
Receipt,
|
||||||
Stethoscope,
|
Stethoscope,
|
||||||
@@ -116,6 +117,22 @@ export function Sidebar() {
|
|||||||
<span>999 ACKs</span>
|
<span>999 ACKs</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</nav>
|
</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