diff --git a/src/App.tsx b/src/App.tsx
index 29dab6e..b404cf3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
diff --git a/src/components/BatchDetail.tsx b/src/components/BatchDetail.tsx
new file mode 100644
index 0000000..7b19fd2
--- /dev/null
+++ b/src/components/BatchDetail.tsx
@@ -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 (
+
+ );
+}
+
+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 (
+
+
+
+
+ {eyebrow}
+
+
+
{message}
+
+ {kind === "network" && onRetry ? (
+
+ Retry
+
+ ) : null}
+
+ Close
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+
+
+
+ );
+}
+
+function Field({
+ label,
+ value,
+ mono,
+}: {
+ label: string;
+ value: string;
+ mono?: boolean;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+
+
+
+
+ Summary
+
+
+
+
+ 0 ? "destructive" : "muted"}
+ />
+
+
+
+ {list.length > 0 ? (
+
+
+ {firstIsPayment ? "Payments" : "Claims"} · first{" "}
+ {Math.min(10, list.length)}
+
+
+ {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 (
+
+
+ {i + 1}
+
+ {id}
+
+ );
+ })}
+
+ {list.length > 10 ? (
+
+ + {fmt.num(list.length - 10)} more
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+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 (
+
+
+ {label}
+
+
{value}
+
+ );
+}
diff --git a/src/components/BatchesList.tsx b/src/components/BatchesList.tsx
new file mode 100644
index 0000000..31d9904
--- /dev/null
+++ b/src/components/BatchesList.tsx
@@ -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 (
+
+ {kind}
+
+ );
+}
+
+/**
+ * Skeleton rows for the batches table. Mirrors the row count used in
+ * `Acks.tsx` (5 placeholders) so the loading density matches the rest
+ * of the app.
+ */
+export function BatchesListSkeleton() {
+ return (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+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 (
+
+
+
+ Kind
+ Batch
+ Input file
+ Claims
+ Parsed
+
+
+
+
+ {items.map((b) => (
+ 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",
+ )}
+ >
+
+
+
+
+ {b.id}
+
+
+ {b.inputFilename}
+
+
+ fmt.num(Math.round(n))}
+ />
+
+
+ {b.parsedAt ? fmt.dateShort(b.parsedAt) : "—"}
+
+
+ ›
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index d970cad..4ac5a75 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -3,6 +3,7 @@ import {
Activity,
CheckCircle2,
GitMerge,
+ Layers,
LayoutDashboard,
Receipt,
Stethoscope,
@@ -116,6 +117,22 @@ export function Sidebar() {
999 ACKs
+
+
+ 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"
+ )
+ }
+ >
+
+ Batches
+
+
diff --git a/src/pages/Batches.test.tsx b/src/pages/Batches.test.tsx
new file mode 100644
index 0000000..ba08a42
--- /dev/null
+++ b/src/pages/Batches.test.tsx
@@ -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(
+
+ {element}
+
+ );
+ });
+ return {
+ container,
+ unmount: () => {
+ act(() => root.unmount());
+ container.remove();
+ },
+ };
+}
+
+async function waitForText(
+ text: string,
+ timeoutMs = 2000
+): Promise {
+ 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 {
+ 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).mockReturnValue(
+ new Promise(() => {})
+ );
+
+ const { unmount } = renderIntoContainer( );
+
+ // 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).mockResolvedValue([]);
+
+ const { unmount } = renderIntoContainer( );
+
+ // 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).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( );
+
+ // 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).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).mockResolvedValue(
+ make837("B-9")
+ );
+
+ const { unmount } = renderIntoContainer( );
+
+ 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).mockResolvedValue([
+ {
+ id: "B-9",
+ kind: "837p",
+ inputFilename: "demo.837",
+ parsedAt: "2026-06-15T12:00:00Z",
+ claimCount: 3,
+ },
+ ]);
+ (api.getBatch as unknown as ReturnType).mockResolvedValue(
+ make837("B-9")
+ );
+
+ const { unmount } = renderIntoContainer( );
+ 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).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).mockRejectedValue(
+ new Error("404 Not Found — batch not found")
+ );
+
+ const { unmount } = renderIntoContainer( );
+ 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).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).mockRejectedValue(
+ new Error("500 Internal Server Error — upstream")
+ );
+
+ const { unmount } = renderIntoContainer( );
+ 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;
diff --git a/src/pages/Batches.tsx b/src/pages/Batches.tsx
new file mode 100644
index 0000000..34246c6
--- /dev/null
+++ b/src/pages/Batches.tsx
@@ -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(() => 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({
+ 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 (
+ <>
+ {
+ if (!open) close();
+ }}
+ >
+
+ {batchId === null ? null : detailLoading ? (
+
+ ) : errKind ? (
+ {
+ void refetchDetail();
+ }}
+ onClose={close}
+ />
+ ) : detail ? (
+
+ ) : null}
+
+
+
+
+
+
+
+ Batches
+
+
+ Parsed batches
+
+
+ Every 837P and 835 file the backend has ingested. Click a row
+ for envelope, summary, and a peek at the contained claims.
+
+
+
+ {isError ? (
+
refetch()}
+ />
+ ) : null}
+
+
+ {isLoading ? (
+
+ ) : items.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ );
+}