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:
+330
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user