merge: ui/batches-browser — list + detail drawer for /api/batches
This commit is contained in:
@@ -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