From f4bafc1c94e99fddbc487249ebcb1de295d08437 Mon Sep 17 00:00:00 2001 From: Nora Date: Wed, 24 Jun 2026 13:57:12 -0600 Subject: [PATCH] feat: History tab on Upload page with one-click Re-export ZIP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a second tab to the Upload page that surfaces the persisted batch archive and lets the user re-download any 837P batch as a ZIP without re-parsing the original file. - Backend: /api/batches now carries per-row claimIds (837P only). 835 batches return an empty list, which the UI uses as the signal to hide the Re-export button on those rows. Avoids an extra round-trip to /api/batches/{id} per row. - Frontend: BatchSummary.claimIds added to the list-endpoint type. - Upload page: page body wrapped in Tabs.Root with a History trigger that mirrors ?tab= in the URL for deep-link round-trip. The History tab renders UploadHistory → HistoryTable → HistoryRow with a one-click Re-export ZIP button per 837P row. The button calls POST /api/batches/{id}/export-837 with the row's claim ids and downloads the ZIP via downloadBlob. Falls back to the in-memory parsedBatches store when the backend returns no rows so the tab stays useful in sample-data mode. - Backend tests: claimIds present on 837P rows, empty on 835 rows. - Frontend tests: 13 tests covering tab switching, URL deep-link, loading/error/empty states, the 837P-vs-835 button visibility split, the Re-export happy path, and the failure toast. --- backend/src/cyclone/api.py | 30 +- backend/tests/test_api_gets.py | 36 ++ src/lib/api.ts | 8 + src/pages/Upload.history.test.tsx | 531 ++++++++++++++++++++++++++++++ src/pages/Upload.tsx | 332 ++++++++++++++++++- 5 files changed, 934 insertions(+), 3 deletions(-) create mode 100644 src/pages/Upload.history.test.tsx diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index cfb6a3c..3a79445 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -1628,12 +1628,39 @@ def _batch_summary_claim_count(rec: BatchRecord) -> int: return 0 +def _batch_summary_claim_ids(rec: BatchRecord) -> list[str]: + """Return per-claim ids for an 837P batch, or ``[]`` otherwise. + + The Upload page's History tab renders a one-click Re-export ZIP + button per row; that button calls + ``POST /api/batches/{id}/export-837`` with the row's claim ids. + Carrying them in the list response avoids an extra round-trip + to ``/api/batches/{id}`` for every row. 835 has no re-export + endpoint, so the list is empty for those — the UI uses the + empty list as the signal to hide the button. + """ + if rec.kind != "837p": + return [] + return [ + c.claim_id + for c in rec.result.claims # type: ignore[attr-defined] + if getattr(c, "claim_id", None) + ] + + @app.get("/api/batches", dependencies=[Depends(matrix_gate)]) def list_batches( request: Request, limit: int = Query(100, ge=1, le=1000), ) -> Any: - """Summary of all parsed batches, newest first.""" + """Summary of all parsed batches, newest first. + + Each item includes ``claimIds`` (837P only) so the History tab + on the Upload page can render a one-click re-export button per + row without an extra round-trip to ``/api/batches/{id}``. The + list is still capped at ``limit`` claims; see the full result + via the by-id endpoint when more is needed. + """ records = store.list(limit=limit) items = [ { @@ -1642,6 +1669,7 @@ def list_batches( "inputFilename": r.input_filename, "parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"), "claimCount": _batch_summary_claim_count(r), + "claimIds": _batch_summary_claim_ids(r), } for r in records ] diff --git a/backend/tests/test_api_gets.py b/backend/tests/test_api_gets.py index f7f7cd2..e39dfaf 100644 --- a/backend/tests/test_api_gets.py +++ b/backend/tests/test_api_gets.py @@ -65,6 +65,42 @@ def test_batches_returns_summary_after_parse(seeded_store): assert body["items"][0]["inputFilename"] == "x.txt" +def test_batches_includes_claim_ids_for_837p(seeded_store): + """The Upload page's History tab renders a Re-export ZIP button per + row that fires ``POST /api/batches/{id}/export-837`` with the row's + claim ids. Carry those ids in the list response so the UI doesn't + need an extra round-trip per row to fetch them.""" + batches = seeded_store.get("/api/batches", headers=JSON).json()["items"] + assert len(batches) == 1 + item = batches[0] + assert item["kind"] == "837p" + # claimIds is a non-empty list of the same claim ids that live + # inside the parsed result — see `seeded_store` in conftest.py. + assert isinstance(item["claimIds"], list) + assert len(item["claimIds"]) == 2 + full = seeded_store.get(f"/api/batches/{item['id']}").json() + assert sorted(item["claimIds"]) == sorted(c["claim_id"] for c in full["claims"]) + + +def test_batches_claim_ids_empty_for_835(client: TestClient, tmp_path): + """835 has no re-export endpoint — the field is always ``[]`` so the + History tab can use it as the signal to hide the Re-export button.""" + src = Path(__file__).parent / "fixtures" / "minimal_835.txt" + files = {"file": ("minimal_835.txt", src.read_bytes(), "text/plain")} + r = client.post( + "/api/parse-835", + params={"payer": "co_medicaid_835"}, + files=files, + headers=JSON, + ) + assert r.status_code == 200, r.text + body = client.get("/api/batches", headers=JSON).json() + assert body["total"] == 1 + item = body["items"][0] + assert item["kind"] == "835" + assert item["claimIds"] == [] + + # --------------------------------------------------------------------------- # # /api/batches/{id} # --------------------------------------------------------------------------- # diff --git a/src/lib/api.ts b/src/lib/api.ts index 1c6908d..0e5d302 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -171,6 +171,14 @@ export interface BatchSummary { inputFilename: string; parsedAt: string; claimCount: number; + /** + * Per-claim ids for 837P batches. Empty for 835 (no re-export + * endpoint) — the UI hides the one-click Re-export button when + * this is empty. Lets the History tab call + * `POST /api/batches/{id}/export-837` directly with the row's ids + * instead of an extra round-trip to `/api/batches/{id}`. + */ + claimIds: string[]; } // --------------------------------------------------------------------------- diff --git a/src/pages/Upload.history.test.tsx b/src/pages/Upload.history.test.tsx new file mode 100644 index 0000000..af287a1 --- /dev/null +++ b/src/pages/Upload.history.test.tsx @@ -0,0 +1,531 @@ +// @vitest-environment happy-dom +// Tell React this is an `act`-aware test environment so react-query's +// internal state updates flush through without noisy console warnings. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +import React, { act } from "react"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Upload } from "./Upload"; +import { useAppStore } from "@/store"; +import type { ParsedBatch } from "@/types"; +import type { BatchSummary } from "@/lib/api"; + +// Mock the api surface so the History tab never touches the network. +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + api: { + ...actual.api, + isConfigured: true, + listBatches: vi.fn(), + exportBatch837: vi.fn(), + }, + }; +}); + +import { api } from "@/lib/api"; + +// The Re-export ZIP button ultimately calls `downloadBlob(filename, blob)` +// from `@/lib/download`; that helper mutates the DOM (creates a link and +// clicks it). Stub the whole module so we never touch the real DOM and +// the test can assert the filename we asked to be downloaded. +vi.mock("@/lib/download", () => ({ + downloadBlob: vi.fn(), + downloadTextFile: vi.fn(), +})); + +import { downloadBlob } from "@/lib/download"; + +// The Re-export path toasts via sonner. Mock it to keep the test output +// quiet and let assertions about success/error fire without spam. +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + message: vi.fn(), + }, +})); + +import { toast } from "sonner"; + +// The Upload page also relies on useAuth (via RoleGate). Mock it to an +// authenticated admin so the parse affordances render — we don't test +// RoleGate behavior here, only the History tab. +vi.mock("@/auth/useAuth", () => ({ + useAuth: () => ({ + user: { id: 1, username: "test", role: "admin" }, + status: "authenticated", + }), +})); + +// --------------------------------------------------------------------------- +// Test harness — Upload page lives behind a router (for useSearchParams) +// AND a QueryClient (for useBatches). Wrap both. +// --------------------------------------------------------------------------- + +interface MountOpts { + initialEntries?: string[]; + /** Pre-populated query data for `api.listBatches`. */ + batches?: BatchSummary[]; + /** Whether listBatches should resolve or reject. */ + batchesStatus?: "success" | "error" | "pending"; +} + +interface Harness { + container: HTMLDivElement; + unmount: () => void; + tracker: { pathname: string; search: string }; +} + +function mountUpload(opts: MountOpts = {}): Harness { + const { + initialEntries = ["/upload"], + batches = [], + batchesStatus = "success", + } = opts; + + // Wire the listBatches mock based on the requested status. + const listBatchesMock = api.listBatches as unknown as ReturnType< + typeof vi.fn + >; + listBatchesMock.mockReset(); + if (batchesStatus === "success") { + listBatchesMock.mockResolvedValue(batches); + } else if (batchesStatus === "error") { + listBatchesMock.mockRejectedValue(new Error("network down")); + } else { + // pending — never resolve + listBatchesMock.mockReturnValue(new Promise(() => {})); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const tracker = { pathname: "/upload", search: "" }; + const Tracker = () => { + const loc = useLocation(); + React.useEffect(() => { + tracker.pathname = loc.pathname; + tracker.search = loc.search; + }, [loc.pathname, loc.search]); + return null; + }; + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + const root: Root = createRoot(container); + act(() => { + root.render( + React.createElement( + MemoryRouter, + { initialEntries }, + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement(Tracker), + React.createElement(Upload), + ), + ), + ); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + tracker, + }; +} + +async function flush(): Promise { + // React Query schedules its settled state through a microtask + + // setTimeout chain. Two ticks is the minimum to settle a mocked + // promise end-to-end; three to be safe across happy-dom + // implementations. + for (let i = 0; i < 3; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +/** + * Poll a predicate until it holds or we time out. Used for assertions + * that have to wait for the query to settle (which takes more than a + * single `flush()` in some happy-dom runs). + */ +async function settle( + predicate: () => boolean, + timeoutMs = 2000, +): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error("settle: predicate did not hold within timeout"); + } + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +/** + * Click a Radix Tabs.Trigger in happy-dom. Radix listens for + * `pointerdown` (not just `click`) to fire its onValueChange handler, + * so the synthetic event sequence matters. + */ +async function clickTab(trigger: HTMLElement): Promise { + await act(async () => { + trigger.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + cancelable: true, + pointerType: "mouse", + }), + ); + trigger.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, cancelable: true }), + ); + trigger.dispatchEvent( + new MouseEvent("mouseup", { bubbles: true, cancelable: true }), + ); + trigger.click(); + await flush(); + }); +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const BATCH_837_A: BatchSummary = { + id: "BATCH-A", + kind: "837p", + inputFilename: "tp-001-837P.txt", + parsedAt: "2026-06-23T12:00:00Z", + claimCount: 12, + claimIds: ["CLM-001", "CLM-002", "CLM-003"], +}; + +const BATCH_837_B: BatchSummary = { + id: "BATCH-B", + kind: "837p", + inputFilename: "tp-002-837P.txt", + parsedAt: "2026-06-22T12:00:00Z", + claimCount: 7, + claimIds: ["CLM-101", "CLM-102"], +}; + +const BATCH_835: BatchSummary = { + id: "BATCH-C", + kind: "835", + inputFilename: "tp-003-835.txt", + parsedAt: "2026-06-21T12:00:00Z", + claimCount: 4, + claimIds: [], // 835 has no re-export endpoint +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("Upload page — History tab", () => { + beforeEach(() => { + useAppStore.setState({ parsedBatches: [] }); + vi.mocked(downloadBlob).mockReset(); + vi.mocked(toast.success).mockReset(); + vi.mocked(toast.error).mockReset(); + }); + + afterEach(() => { + useAppStore.setState({ parsedBatches: [] }); + }); + + it("renders the Upload tab by default and shows the History trigger with a count badge", async () => { + const { container, unmount } = mountUpload({ + batches: [BATCH_837_A, BATCH_837_B], + }); + await settle(() => container.textContent?.includes("· 2") === true); + + // Both triggers are present. + const triggers = container.querySelectorAll('[role="tab"]'); + expect(triggers.length).toBeGreaterThanOrEqual(2); + expect(container.textContent).toContain("Upload"); + expect(container.textContent).toContain("History"); + + // Count badge shows the persisted-batch count. + expect(container.textContent).toContain("· 2"); + + unmount(); + }); + + it("clicking the History trigger shows the batch table and hides the dropzone", async () => { + const { container, unmount } = mountUpload({ + batches: [BATCH_837_A, BATCH_837_B], + }); + await flush(); + + // Before click: the dropzone is rendered. + expect( + container.querySelector('[aria-label="File upload"]'), + ).not.toBeNull(); + + // Click the History trigger. + const historyTrigger = Array.from( + container.querySelectorAll('[role="tab"]'), + ).find((el) => el.textContent?.startsWith("History")) as HTMLElement; + expect(historyTrigger).toBeDefined(); + await clickTab(historyTrigger); + + // Now the dropzone is hidden and the history table is visible. + expect(container.querySelector('[aria-label="File upload"]')).toBeNull(); + expect(container.textContent).toContain("Batch history"); + expect(container.textContent).toContain("tp-001-837P.txt"); + expect(container.textContent).toContain("tp-002-837P.txt"); + unmount(); + }); + + it("clicking the History trigger mirrors ?tab=history in the URL", async () => { + const { container, unmount, tracker } = mountUpload({ + batches: [BATCH_837_A], + }); + await flush(); + + const historyTrigger = Array.from( + container.querySelectorAll('[role="tab"]'), + ).find((el) => el.textContent?.startsWith("History")) as HTMLElement; + await clickTab(historyTrigger); + + expect(tracker.search).toContain("tab=history"); + unmount(); + }); + + it("?tab=history in the initial URL deep-links directly to the History tab", async () => { + const { container, unmount } = mountUpload({ + initialEntries: ["/upload?tab=history"], + batches: [BATCH_837_A, BATCH_837_B], + }); + await flush(); + + // Dropzone is hidden — we landed on the History tab. + expect(container.querySelector('[aria-label="File upload"]')).toBeNull(); + expect(container.textContent).toContain("Batch history"); + expect(container.textContent).toContain("tp-001-837P.txt"); + unmount(); + }); + + it("renders one row per batch with sequential # column", async () => { + const { container, unmount } = mountUpload({ + initialEntries: ["/upload?tab=history"], + batches: [BATCH_837_A, BATCH_837_B, BATCH_835], + }); + await settle(() => + container.querySelectorAll("tbody tr").length === 3, + ); + + const rows = container.querySelectorAll("tbody tr"); + expect(rows.length).toBe(3); + // Newest first → #03 (BATCH-A), #02 (BATCH-B), #01 (BATCH-C). + // The # column counts down from the total so the newest row reads + // as the highest ordinal. + expect(rows[0]?.textContent).toContain("#03"); + expect(rows[0]?.textContent).toContain("tp-001-837P.txt"); + expect(rows[1]?.textContent).toContain("#02"); + expect(rows[1]?.textContent).toContain("tp-002-837P.txt"); + expect(rows[2]?.textContent).toContain("#01"); + expect(rows[2]?.textContent).toContain("tp-003-835.txt"); + unmount(); + }); + + it("renders a Re-export ZIP button for each 837P row", async () => { + const { container, unmount } = mountUpload({ + initialEntries: ["/upload?tab=history"], + batches: [BATCH_837_A, BATCH_837_B, BATCH_835], + }); + await settle( + () => + container.querySelectorAll('[data-testid="history-row-reexport"]') + .length === 2, + ); + + const buttons = container.querySelectorAll( + '[data-testid="history-row-reexport"]', + ); + // Two 837P rows get a button; the 835 row does not. + expect(buttons.length).toBe(2); + unmount(); + }); + + it("hides the Re-export button on 835 rows (no claimIds)", async () => { + const { container, unmount } = mountUpload({ + initialEntries: ["/upload?tab=history"], + batches: [BATCH_835], + }); + await settle(() => + container.textContent?.includes("No re-export") === true, + ); + + expect( + container.querySelector('[data-testid="history-row-reexport"]'), + ).toBeNull(); + // A muted "No re-export" placeholder is shown instead. + expect(container.textContent).toContain("No re-export"); + unmount(); + }); + + it("clicking Re-export ZIP calls api.exportBatch837 with the row's claimIds and downloads the blob", async () => { + const { container, unmount } = mountUpload({ + initialEntries: ["/upload?tab=history"], + batches: [BATCH_837_A], + }); + await settle( + () => + container.querySelector('[data-testid="history-row-reexport"]') !== + null, + ); + + const exportMock = api.exportBatch837 as unknown as ReturnType< + typeof vi.fn + >; + exportMock.mockResolvedValue({ + blob: new Blob(["zip-bytes"]), + filename: "BATCH-A-claims.zip", + serializeErrors: [], + }); + + const button = container.querySelector( + '[data-testid="history-row-reexport"]', + ) as HTMLButtonElement; + expect(button).not.toBeNull(); + + await act(async () => { + button.click(); + await flush(); + }); + + expect(exportMock).toHaveBeenCalledTimes(1); + expect(exportMock).toHaveBeenCalledWith("BATCH-A", [ + "CLM-001", + "CLM-002", + "CLM-003", + ]); + expect(downloadBlob).toHaveBeenCalledTimes(1); + expect(downloadBlob).toHaveBeenCalledWith( + "BATCH-A-claims.zip", + expect.any(Blob), + ); + expect(toast.success).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("surfaces a toast.error when re-export fails", async () => { + const { container, unmount } = mountUpload({ + initialEntries: ["/upload?tab=history"], + batches: [BATCH_837_A], + }); + await settle( + () => + container.querySelector('[data-testid="history-row-reexport"]') !== + null, + ); + + const exportMock = api.exportBatch837 as unknown as ReturnType< + typeof vi.fn + >; + exportMock.mockRejectedValue(new Error("server gone")); + + const button = container.querySelector( + '[data-testid="history-row-reexport"]', + ) as HTMLButtonElement; + await act(async () => { + button.click(); + await flush(); + }); + + expect(toast.error).toHaveBeenCalledTimes(1); + expect(toast.success).not.toHaveBeenCalled(); + expect(downloadBlob).not.toHaveBeenCalled(); + unmount(); + }); + + it("shows a loading state on first paint while the query is pending", async () => { + const { container, unmount } = mountUpload({ + initialEntries: ["/upload?tab=history"], + batchesStatus: "pending", + }); + await flush(); + + expect(container.textContent).toContain("Loading archive"); + // No rows yet. + expect( + container.querySelector('[data-testid="history-row-reexport"]'), + ).toBeNull(); + unmount(); + }); + + it("shows an error state when the query rejects", async () => { + const { container, unmount } = mountUpload({ + initialEntries: ["/upload?tab=history"], + batchesStatus: "error", + }); + await settle(() => container.textContent?.includes("Couldn't load") === true); + + expect(container.textContent).toContain("Couldn't load the archive"); + unmount(); + }); + + it("shows an empty state when there are no batches", async () => { + const { container, unmount } = mountUpload({ + initialEntries: ["/upload?tab=history"], + batches: [], + }); + await flush(); + + expect(container.textContent).toContain("No batches in the archive"); + unmount(); + }); + + it("falls back to in-memory parsedBatches when the backend returns no rows (sample-data mode)", async () => { + const sample: ParsedBatch = { + id: "MEM-1", + kind: "837p", + inputFilename: "mem-batch.txt", + parsedAt: "2026-06-24T12:00:00Z", + claimCount: 2, + passed: 2, + failed: 0, + claimIds: ["SAMPLE-1", "SAMPLE-2"], + summary: { + input_file: "mem-batch.txt", + total_claims: 2, + passed: 2, + failed: 0, + failed_claim_ids: [], + issues_by_rule: {}, + }, + }; + useAppStore.setState({ parsedBatches: [sample] }); + + const { container, unmount } = mountUpload({ + initialEntries: ["/upload?tab=history"], + // Server returned nothing — fallback path activates. + batches: [], + }); + await settle(() => + container.textContent?.includes("mem-batch.txt") === true, + ); + + expect(container.textContent).toContain("mem-batch.txt"); + expect( + container.querySelector('[data-testid="history-row-reexport"]'), + ).not.toBeNull(); + unmount(); + }); +}); \ No newline at end of file diff --git a/src/pages/Upload.tsx b/src/pages/Upload.tsx index 2676c95..64e82f3 100644 --- a/src/pages/Upload.tsx +++ b/src/pages/Upload.tsx @@ -1,11 +1,13 @@ import { useMemo, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { AlertTriangle, ArrowRight, ChevronRight, CloudUpload, + Download, FileText, + History as HistoryIcon, Inbox, Loader2, Upload as UploadIcon, @@ -16,6 +18,7 @@ import { toast } from "sonner"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Tabs } from "@/components/ui/tabs"; import { Select, SelectContent, @@ -27,11 +30,13 @@ import { PageHeader } from "@/components/PageHeader"; import { ClaimCard837 } from "@/components/ClaimCard837"; import { ExportBar } from "@/components/ExportBar"; import { StatPill, ValidationDot } from "@/components/ClaimCard/shared"; -import { api, type ParseProgress } from "@/lib/api"; +import { api, ApiError, type BatchSummary, type ParseProgress } from "@/lib/api"; +import { downloadBlob } from "@/lib/download"; import { fmt, toNum } from "@/lib/format"; import { useAppStore } from "@/store"; import { useParse } from "@/hooks/useParse"; import { useBatchExport } from "@/hooks/useBatchExport"; +import { useBatches } from "@/hooks/useBatches"; import type { ClaimOutput, ClaimPayment, @@ -348,6 +353,21 @@ function HeroStat({ export function Upload() { const inputRef = useRef(null); + const [searchParams, setSearchParams] = useSearchParams(); + // Tab URL state — `?tab=history` deep-links the History tab; + // `?tab=upload` (or missing) is the default. Stored in the URL so + // the back button round-trips between the two surfaces. + const tab = searchParams.get("tab") === "history" ? "history" : "upload"; + const setTab = (next: "upload" | "history") => { + setSearchParams( + (prev) => { + if (next === "upload") prev.delete("tab"); + else prev.set("tab", next); + return prev; + }, + { replace: true }, + ); + }; const [file, setFile] = useState(null); const [kind, setKind] = useState("837p"); const [payer, setPayer] = useState(PAYERS_837[0]!.value); @@ -362,6 +382,14 @@ export function Upload() { const parsedBatches = useAppStore((s) => s.parsedBatches); const parseMutation = useParse(kind); + // Persisted batch count for the History tab badge. Disabled when + // there's no backend configured (sample-data mode); in that case we + // fall back to the in-memory parsedBatches so the badge still reads. + const batchesQuery = useBatches(); + const persistedBatchCount = batchesQuery.data?.length ?? 0; + const historyCount = + persistedBatchCount > 0 ? persistedBatchCount : parsedBatches.length; + // Batch-export wiring (SP9 — Upload → ZIP flow). The hook owns the // selection set, the exporting flag, the export handler, and the // captured server-side batch id. See `src/hooks/useBatchExport.ts`. @@ -607,6 +635,34 @@ export function Upload() { + {/* ================================================================= + TABS — the page's content switcher. Two surfaces: + · Upload — drop zone + stream + recent batches (the + "in-flight" instrument) + · History — persisted batch archive with one-click + Re-export ZIP per row + Tab is mirrored to `?tab=` so deep-links round-trip. + ================================================================= */} + setTab(v as "upload" | "history")} + > + + Upload + + History + {historyCount > 0 ? ( + + · {historyCount} + + ) : null} + + + + {/* ================================================================= DROP ZONE — the page's centerpiece. Single large surface-2 card with an inline payer-config header bar, a centered drop @@ -1113,6 +1169,278 @@ export function Upload() { ) : null} + + + + + + ); +} + +// --------------------------------------------------------------------------- +// UploadHistory — persisted batch archive rendered inside the History tab. +// +// Source: `useBatches()` (the backend's `/api/batches` list). Independent of +// the in-memory `parsedBatches` store so it survives a page reload — the +// store is purely session-local. When the backend isn't configured (sample- +// data mode) we fall back to the in-memory list so the tab still renders. +// --------------------------------------------------------------------------- + +function UploadHistory() { + const parsedBatches = useAppStore((s) => s.parsedBatches); + const batchesQuery = useBatches(); + const liveBatches: BatchSummary[] = useMemo(() => { + if (batchesQuery.data && batchesQuery.data.length > 0) { + return batchesQuery.data; + } + // Sample-data fallback — synthesize BatchSummary rows from the + // in-memory parsedBatches so the History tab is never empty in + // a demo. Newest first. + return [...parsedBatches] + .reverse() + .map((b) => ({ + id: b.id, + kind: b.kind, + inputFilename: b.inputFilename, + parsedAt: b.parsedAt, + claimCount: b.claimCount, + claimIds: b.claimIds, + })); + }, [batchesQuery.data, parsedBatches]); + + if (batchesQuery.isLoading && batchesQuery.data === undefined) { + return ( + + + +
+ Loading archive… +
+
+
+ ); + } + + if (batchesQuery.isError) { + return ( + + + +
+ Couldn't load the archive. +
+
+ {batchesQuery.error instanceof Error + ? batchesQuery.error.message + : "Network error"} +
+
+
+ ); + } + + if (liveBatches.length === 0) { + return ( + + +
+ +
+
+ No batches in the archive yet. +
+
+ Switch to Upload and drop a file to ingest your first batch. +
+
+
+ ); + } + + return ; +} + +function HistoryTable({ batches }: { batches: BatchSummary[] }) { + const totalClaims = batches.reduce((s, b) => s + b.claimCount, 0); + const lastAt = batches[0] ? fmt.dateShort(batches[0].parsedAt) : "—"; + + return ( + + +
+
+
+ + Batch history +
+

+ Archive{" "} + + · {batches.length} + +

+
+
+
+ Last ingest +
+
+ {lastAt} +
+
+ {fmt.num(totalClaims)} claims +
+
+
+ +
+ + + + + + + + + + + + + {batches.map((b, i) => ( + + ))} + +
+ # + + Kind + + File + + Claims + + Parsed + + Action +
+
+
+
+ ); +} + +function HistoryRow({ + batch, + index, +}: { + batch: BatchSummary; + index: number; +}) { + const [exporting, setExporting] = useState(false); + const canReexport = batch.kind === "837p" && batch.claimIds.length > 0; + + async function onReexport() { + if (!canReexport || exporting) return; + setExporting(true); + try { + const result = await api.exportBatch837(batch.id, batch.claimIds); + downloadBlob(result.filename, result.blob); + const warn = + result.serializeErrors.length > 0 + ? ` · ${result.serializeErrors.length} skipped` + : ""; + toast.success(`Re-exported ${batch.claimIds.length} claims${warn}`, { + description: result.filename, + }); + } catch (err) { + toast.error( + err instanceof ApiError + ? `Re-export failed (${err.status})` + : err instanceof Error + ? err.message + : "Re-export failed", + ); + } finally { + setExporting(false); + } + } + + return ( + + + #{String(index).padStart(2, "0")} + + + + {batch.kind === "837p" ? "837P" : "835"} + + + +
+ {batch.inputFilename} +
+ + + {fmt.num(batch.claimCount)} + + + {fmt.dateShort(batch.parsedAt)} + + + {canReexport ? ( + + ) : ( + + No re-export + + )} + + + ); } \ No newline at end of file