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 ? ( + + ) : null} + +
+
+ ); +} + +/** + * 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 ( +
+
+
+
+ + Batch detail +
+

+ {summary.control_number ?? "Uncontrolled"} +

+
+ {firstIsPayment ? "835 ERA" : "837P"} ·{" "} + + {fmt.num(count)} + {" "} + {firstIsPayment ? "payments" : "claims"} +
+
+ +
+ +
+
+ Envelope +
+ +
+ +
+
+ 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 ? ( + + ) : ( + + )} +
    +
    + + ); +}