feat: History tab on Upload page with one-click Re-export ZIP

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.
This commit is contained in:
Nora
2026-06-24 13:57:12 -06:00
parent 24fbf945c9
commit f4bafc1c94
5 changed files with 934 additions and 3 deletions
+330 -2
View File
@@ -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<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 [kind, setKind] = useState<ParsedBatchKind>("837p");
const [payer, setPayer] = useState<string>(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() {
</div>
</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
card with an inline payer-config header bar, a centered drop
@@ -1113,6 +1169,278 @@ export function Upload() {
</Card>
</section>
) : null}
</Tabs.Content>
<Tabs.Content value="history">
<UploadHistory />
</Tabs.Content>
</Tabs.Root>
</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>
);
}