merge: History tab on Upload page into main
This commit is contained in:
@@ -1628,12 +1628,39 @@ def _batch_summary_claim_count(rec: BatchRecord) -> int:
|
|||||||
return 0
|
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)])
|
@app.get("/api/batches", dependencies=[Depends(matrix_gate)])
|
||||||
def list_batches(
|
def list_batches(
|
||||||
request: Request,
|
request: Request,
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
) -> Any:
|
) -> 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)
|
records = store.list(limit=limit)
|
||||||
items = [
|
items = [
|
||||||
{
|
{
|
||||||
@@ -1642,6 +1669,7 @@ def list_batches(
|
|||||||
"inputFilename": r.input_filename,
|
"inputFilename": r.input_filename,
|
||||||
"parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"),
|
"parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"),
|
||||||
"claimCount": _batch_summary_claim_count(r),
|
"claimCount": _batch_summary_claim_count(r),
|
||||||
|
"claimIds": _batch_summary_claim_ids(r),
|
||||||
}
|
}
|
||||||
for r in records
|
for r in records
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -65,6 +65,42 @@ def test_batches_returns_summary_after_parse(seeded_store):
|
|||||||
assert body["items"][0]["inputFilename"] == "x.txt"
|
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}
|
# /api/batches/{id}
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -171,6 +171,14 @@ export interface BatchSummary {
|
|||||||
inputFilename: string;
|
inputFilename: string;
|
||||||
parsedAt: string;
|
parsedAt: string;
|
||||||
claimCount: number;
|
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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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<typeof import("@/lib/api")>();
|
||||||
|
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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+330
-2
@@ -1,11 +1,13 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CloudUpload,
|
CloudUpload,
|
||||||
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
|
History as HistoryIcon,
|
||||||
Inbox,
|
Inbox,
|
||||||
Loader2,
|
Loader2,
|
||||||
Upload as UploadIcon,
|
Upload as UploadIcon,
|
||||||
@@ -16,6 +18,7 @@ import { toast } from "sonner";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -27,11 +30,13 @@ import { PageHeader } from "@/components/PageHeader";
|
|||||||
import { ClaimCard837 } from "@/components/ClaimCard837";
|
import { ClaimCard837 } from "@/components/ClaimCard837";
|
||||||
import { ExportBar } from "@/components/ExportBar";
|
import { ExportBar } from "@/components/ExportBar";
|
||||||
import { StatPill, ValidationDot } from "@/components/ClaimCard/shared";
|
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 { fmt, toNum } from "@/lib/format";
|
||||||
import { useAppStore } from "@/store";
|
import { useAppStore } from "@/store";
|
||||||
import { useParse } from "@/hooks/useParse";
|
import { useParse } from "@/hooks/useParse";
|
||||||
import { useBatchExport } from "@/hooks/useBatchExport";
|
import { useBatchExport } from "@/hooks/useBatchExport";
|
||||||
|
import { useBatches } from "@/hooks/useBatches";
|
||||||
import type {
|
import type {
|
||||||
ClaimOutput,
|
ClaimOutput,
|
||||||
ClaimPayment,
|
ClaimPayment,
|
||||||
@@ -348,6 +353,21 @@ function HeroStat({
|
|||||||
|
|
||||||
export function Upload() {
|
export function Upload() {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(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<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [kind, setKind] = useState<ParsedBatchKind>("837p");
|
const [kind, setKind] = useState<ParsedBatchKind>("837p");
|
||||||
const [payer, setPayer] = useState<string>(PAYERS_837[0]!.value);
|
const [payer, setPayer] = useState<string>(PAYERS_837[0]!.value);
|
||||||
@@ -362,6 +382,14 @@ export function Upload() {
|
|||||||
const parsedBatches = useAppStore((s) => s.parsedBatches);
|
const parsedBatches = useAppStore((s) => s.parsedBatches);
|
||||||
const parseMutation = useParse(kind);
|
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
|
// Batch-export wiring (SP9 — Upload → ZIP flow). The hook owns the
|
||||||
// selection set, the exporting flag, the export handler, and the
|
// selection set, the exporting flag, the export handler, and the
|
||||||
// captured server-side batch id. See `src/hooks/useBatchExport.ts`.
|
// captured server-side batch id. See `src/hooks/useBatchExport.ts`.
|
||||||
@@ -607,6 +635,34 @@ export function Upload() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* =================================================================
|
||||||
|
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.
|
||||||
|
================================================================= */}
|
||||||
|
<Tabs.Root
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => setTab(v as "upload" | "history")}
|
||||||
|
>
|
||||||
|
<Tabs.List aria-label="Upload page sections">
|
||||||
|
<Tabs.Trigger value="upload">Upload</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="history">
|
||||||
|
History
|
||||||
|
{historyCount > 0 ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/60 ml-1.5"
|
||||||
|
>
|
||||||
|
· {historyCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Content value="upload">
|
||||||
{/* =================================================================
|
{/* =================================================================
|
||||||
DROP ZONE — the page's centerpiece. Single large surface-2
|
DROP ZONE — the page's centerpiece. Single large surface-2
|
||||||
card with an inline payer-config header bar, a centered drop
|
card with an inline payer-config header bar, a centered drop
|
||||||
@@ -1113,6 +1169,278 @@ export function Upload() {
|
|||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="history">
|
||||||
|
<UploadHistory />
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<BatchSummary>((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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-10 lg:p-14 flex flex-col items-center justify-center text-center">
|
||||||
|
<Loader2
|
||||||
|
className="h-4 w-4 animate-spin text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70 mt-3">
|
||||||
|
Loading archive…
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batchesQuery.isError) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-10 lg:p-14 flex flex-col items-center justify-center text-center">
|
||||||
|
<XCircle
|
||||||
|
className="h-5 w-5 text-[hsl(var(--destructive))]"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="display text-[18px] tracking-tight mt-3">
|
||||||
|
Couldn't load the archive.
|
||||||
|
</div>
|
||||||
|
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70 mt-2">
|
||||||
|
{batchesQuery.error instanceof Error
|
||||||
|
? batchesQuery.error.message
|
||||||
|
: "Network error"}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liveBatches.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-10 lg:p-14 flex flex-col items-center justify-center text-center">
|
||||||
|
<div className="h-10 w-10 rounded-md bg-muted/50 ring-1 ring-inset ring-border/60 flex items-center justify-center text-muted-foreground mb-3">
|
||||||
|
<HistoryIcon className="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<div className="display text-[20px] tracking-tight">
|
||||||
|
No batches in the archive yet.
|
||||||
|
</div>
|
||||||
|
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70 mt-2">
|
||||||
|
Switch to Upload and drop a file to ingest your first batch.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <HistoryTable batches={liveBatches} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 lg:p-7">
|
||||||
|
<div className="flex items-end justify-between gap-6 flex-wrap mb-5">
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow flex items-center gap-2 mb-2">
|
||||||
|
<span className="inline-block h-px w-6 bg-foreground/20" />
|
||||||
|
Batch history
|
||||||
|
</div>
|
||||||
|
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
|
||||||
|
Archive{" "}
|
||||||
|
<span className="italic text-muted-foreground/85">
|
||||||
|
· {batches.length}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
|
||||||
|
Last ingest
|
||||||
|
</div>
|
||||||
|
<div className="display mono text-[22px] leading-[1.05] mt-1.5 tracking-tight">
|
||||||
|
{lastAt}
|
||||||
|
</div>
|
||||||
|
<div className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/60 mt-1">
|
||||||
|
{fmt.num(totalClaims)} claims
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-md border overflow-hidden"
|
||||||
|
style={{ borderColor: "hsl(30 14% 14% / 0.10)" }}
|
||||||
|
>
|
||||||
|
<table className="w-full text-[12.5px]">
|
||||||
|
<thead style={{ backgroundColor: "hsl(36 22% 90%)" }}>
|
||||||
|
<tr
|
||||||
|
className="text-left"
|
||||||
|
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||||
|
>
|
||||||
|
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-12">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-20">
|
||||||
|
Kind
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px]">
|
||||||
|
File
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-20 text-right">
|
||||||
|
Claims
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-28">
|
||||||
|
Parsed
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-44 text-right">
|
||||||
|
Action
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{batches.map((b, i) => (
|
||||||
|
<HistoryRow
|
||||||
|
key={b.id}
|
||||||
|
batch={b}
|
||||||
|
index={batches.length - i}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<tr
|
||||||
|
className="border-t align-middle"
|
||||||
|
style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="px-3 py-2.5 mono text-[11px] text-muted-foreground/60"
|
||||||
|
>
|
||||||
|
#{String(index).padStart(2, "0")}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<Badge variant={batch.kind === "837p" ? "default" : "muted"}>
|
||||||
|
{batch.kind === "837p" ? "837P" : "835"}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="font-medium truncate max-w-[42ch]">
|
||||||
|
{batch.inputFilename}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right display mono">
|
||||||
|
{fmt.num(batch.claimCount)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="px-3 py-2.5 mono text-[11px]"
|
||||||
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||||
|
>
|
||||||
|
{fmt.dateShort(batch.parsedAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right">
|
||||||
|
{canReexport ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onReexport}
|
||||||
|
disabled={exporting}
|
||||||
|
data-testid="history-row-reexport"
|
||||||
|
data-batch-id={batch.id}
|
||||||
|
>
|
||||||
|
{exporting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Exporting…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
Re-export ZIP
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/50">
|
||||||
|
No re-export
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user