import { useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { AlertTriangle, ArrowRight, ChevronRight, CloudUpload, Download, FileText, History as HistoryIcon, Inbox, Loader2, Upload as UploadIcon, X, XCircle, } from "lucide-react"; 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, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { PageHeader } from "@/components/PageHeader"; import { ClaimCard837 } from "@/components/ClaimCard837"; import { ExportBar } from "@/components/ExportBar"; import { StatPill, ValidationDot } from "@/components/ClaimCard/shared"; import { api, ApiError, type BatchSummary, type ParseProgress } from "@/lib/api"; import { downloadBlob } from "@/lib/download"; import { fmt, toNum } from "@/lib/format"; import { detectKindFromFile, detectedKindToParsedBatchKind, } from "@/lib/x12-detect"; import { useAppStore } from "@/store"; import { useParse } from "@/hooks/useParse"; import { useBatchExport } from "@/hooks/useBatchExport"; import { useBatches } from "@/hooks/useBatches"; import type { ClaimOutput, ClaimPayment, ParsedBatch, ParsedBatchKind, ServicePayment, } from "@/types"; import { cn } from "@/lib/utils"; import { RoleGate } from "@/auth/RoleGate"; // --------------------------------------------------------------------------- // Streaming state — every claim that arrives from the backend accumulates // in this list. The 837P and 835 cases are kept as separate slots so the UI // can render the right "card" type for each. // // NOTE: ClaimCard837 and ClaimCard835 use the --surface (warm paper) family // on purpose — they read as "ingestion documents" within an otherwise dark // instrument. The dark page chrome around them is what gives the section // its place in the rest of the app. // --------------------------------------------------------------------------- type StreamedClaim837 = { kind: "837p"; data: ClaimOutput }; type StreamedClaim835 = { kind: "835"; data: ClaimPayment }; type StreamedClaim = StreamedClaim837 | StreamedClaim835; interface StreamState { items: StreamedClaim[]; expectedTotal: number | null; passed: number; failed: number; } const PAYERS_837 = [ { value: "co_medicaid", label: "CO Medicaid" }, { value: "generic_837p", label: "Generic 837P" }, ]; const PAYERS_835 = [ { value: "co_medicaid_835", label: "CO Medicaid" }, { value: "generic_835", label: "Generic 835" }, ]; function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } export function ClaimCard835({ claim }: { claim: ClaimPayment }) { const [open, setOpen] = useState(false); const passed = claim.service_payments.length > 0; // SP21 Phase 5 Task 5.7: same drill gate as ClaimCard837. Only show // "See claim in detail" once the claim_id is in the persisted // batches (otherwise ClaimDrawer would 404). const parsedBatches = useAppStore((s) => s.parsedBatches); const persistedClaimIds = useMemo( () => new Set(parsedBatches.flatMap((b) => b.claimIds)), [parsedBatches], ); const canDrill = persistedClaimIds.has(claim.payer_claim_control_number); const navigate = useNavigate(); return (
{open ? (
{claim.service_payments.length > 0 ? (
Service payments
{claim.service_payments.map((svc) => ( ))}
# Code Mods Date Units Charge Paid
) : null} {/* SP21 Phase 5 Task 5.7: drill to the persisted remit. The 835 cards have no separate "claim" (they're the payment side), so the link goes to /remittances?remit=PCN which opens the RemitDrawer for this payment. Same persisted- gate: only show when the PCN is in a saved batch. */} {canDrill ? (
) : null}
) : null}
); } function ServicePaymentRow({ svc }: { svc: ServicePayment }) { return ( {svc.line_number} {svc.procedure_qualifier}·{svc.procedure_code} {svc.modifiers.length > 0 ? svc.modifiers.join(", ") : "—"} {svc.service_date ?? "—"} {svc.units ? `${toNum(svc.units)} ${svc.unit_type ?? ""}`.trim() : "—"} {fmt.usdDecimal(svc.charge)} {fmt.usdDecimal(svc.payment)} ); } // --------------------------------------------------------------------------- // Hero stat cell — compact KPI-style cell for the page's hero strip. // Echoes the dashboard's KPI rhythm in a tighter, single-row form. // --------------------------------------------------------------------------- function HeroStat({ label, value, tone = "default", }: { label: string; value: React.ReactNode; tone?: "default" | "accent" | "success"; }) { const toneClass = tone === "accent" ? "text-accent" : tone === "success" ? "text-[hsl(var(--success))]" : "text-foreground"; return (
{label}
{value}
); } // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- 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); const [stream, setStream] = useState({ items: [], expectedTotal: null, passed: 0, failed: 0, }); const [dragging, setDragging] = useState(false); const addParsedBatch = useAppStore((s) => s.addParsedBatch); 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`. const { selectedClaimIds, exporting, total837InStream, onToggleSelect, onToggleAll, onExport, recordBatchId, } = useBatchExport(stream.items); const payerOptions = kind === "837p" ? PAYERS_837 : PAYERS_835; const expectedTotal = stream.expectedTotal; const totalSoFar = stream.items.length; const running = parseMutation.isPending; const progressPct = useMemo(() => { if (!expectedTotal || expectedTotal <= 0) return 0; return Math.min(100, Math.round((totalSoFar / expectedTotal) * 100)); }, [expectedTotal, totalSoFar]); // Hero stat derivations — anchors the page in the same rhythm as the // Dashboard's KPI row. Computed from the parsedBatches store. const heroStats = useMemo(() => { const totalClaims = parsedBatches.reduce((s, b) => s + b.claimCount, 0); const totalPassed = parsedBatches.reduce((s, b) => s + b.passed, 0); const passRate = totalClaims > 0 ? (totalPassed / totalClaims) * 100 : 0; const last = parsedBatches[0]; return { batches: parsedBatches.length, totalClaims, passRate, lastAt: last ? fmt.dateShort(last.parsedAt) : "—", }; }, [parsedBatches]); // Auto-select newly streamed 837P claims. Keyed on stream.items.length // so this only fires when the count changes (i.e. a new claim arrived // or the stream was reset). 835 streams don't carry the // claim-id-into-export flow (out of scope for SP9), so their items // are deliberately ignored. // (Selection + export logic now lives in useBatchExport — see the // hook call above. Keeping the comment as a breadcrumb for future // readers who grep "Auto-select".) async function pickFile(f: File | null) { setFile(f); setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 }); if (!f) return; // SP35: auto-detect the kind from the file's first few KB and switch // the dropdown before the user hits Parse. This is layer A of the // defense-in-depth fix (layer B is the server-side envelope guard // in cyclone.api). Before SP35 the dropdown defaulted to "837p" // silently — dropping an 835 file on the page routed it to // /api/parse-837 and produced a bogus empty batch. Detection runs // asynchronously on a 4KB slice; if it can't determine the kind // (unknown file, read error) we keep the user's current selection // and the server guard will surface a clean 400 if it's wrong. const detected = await detectKindFromFile(f); const matched = detectedKindToParsedBatchKind(detected); if (matched && matched !== kind) { setKind(matched); setPayer(matched === "837p" ? PAYERS_837[0]!.value : PAYERS_835[0]!.value); toast.message( `Detected ${matched === "837p" ? "837P" : "835"} file — switched the dropdown.`, { description: f.name }, ); } else if (detected === "999" || detected === "277ca" || detected === "ta1") { // The Upload page only supports 837P and 835; for the other X12 // kinds (which have their own endpoints), surface a hint instead // of silently leaving the dropdown on whatever the user picked. toast.error( `Detected ${detected.toUpperCase()} file — the Upload page only accepts 837P or 835.`, { description: "Use the matching endpoint from the History tab or the API." }, ); } } async function onParse() { if (!file) return; if (!api.isConfigured) { toast.error( "Backend not configured. Set VITE_API_BASE_URL in .env.local to use the Upload page." ); return; } setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 }); const items: StreamedClaim[] = []; const onProgress: ParseProgress = (event) => { if (event.type === "claim") { items.push({ kind: "837p", data: event.data }); setStream((s) => ({ ...s, items: [...items], })); } else if (event.type === "claim_payment") { items.push({ kind: "835", data: event.data }); setStream((s) => ({ ...s, items: [...items], })); } else if (event.type === "summary") { setStream((s) => ({ ...s, expectedTotal: event.data.total_claims, passed: event.data.passed, failed: event.data.failed, })); } }; try { const summary = await parseMutation.mutateAsync({ file, options: { payer, onProgress }, }); const finalSummary = summary && typeof summary === "object" && "total_claims" in summary ? summary : null; if (finalSummary) { const id = `BATCH-${Date.now()}`; const claimIds = items .map((c) => c.kind === "837p" ? c.data.claim_id : c.data.payer_claim_control_number ) .filter(Boolean); const batch: ParsedBatch = { id, kind, inputFilename: file.name, parsedAt: new Date().toISOString(), claimCount: finalSummary.total_claims, passed: finalSummary.passed, failed: finalSummary.failed, claimIds, summary: finalSummary, }; addParsedBatch(batch); // Capture the server-side batch id returned by the parser so // onExport can call /api/batches/{server_id}/export-837. The // client-side `id` (BATCH-{timestamp}) is the in-memory key; // the server uses a UUID stored in the DB. recordBatchId(finalSummary.batch_id ?? null); toast.success( `Parsed ${finalSummary.total_claims} ${kind === "837p" ? "claims" : "payments"} · ${ finalSummary.passed } passed · ${finalSummary.failed} failed`, { description: file.name } ); } } catch (err) { toast.error( err instanceof Error ? err.message : "Failed to parse file" ); } } function onDrop(e: React.DragEvent) { e.preventDefault(); setDragging(false); const f = e.dataTransfer.files?.[0]; if (f) pickFile(f); } // ----------------------------------------------------------------- // Stagger choreography — hero lands first, drop zone at 140ms, // stream at 240ms, batches at 320ms. Total < 500ms so it feels // like the instrument powering up, not a long intro. // ----------------------------------------------------------------- const heroDelay = 0; const dropDelay = 140; const streamDelay = 240; const batchesDelay = 320; return (
{/* ================================================================= HERO — same PageHeader pattern as Dashboard, with a subtle ghost editorial watermark behind it (echoes the Dashboard's ghost USD watermark, but uses claims count instead so the Upload page's "scale" reads as "things ingested"). ================================================================= */}
Upload an X12 file. } subtitle={ <> Drop an 837P professional claim or 835 ERA remittance. The parser streams claims back as they're produced, so the instrument updates live. } />
{api.isConfigured ? ( ) : null} {api.isConfigured ? "Backend ready" : "No backend"}
{/* Hero stat strip — four compact cells, anchored to the parsedBatches store. Lives below the header, separated by a hairline, in the same rhythm as Dashboard's KPI row but tighter. */}
0 ? `${heroStats.passRate.toFixed(1)}%` : "—" } tone={heroStats.passRate >= 95 ? "success" : "default"} />
{/* ================================================================= 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 area, and an action bar at the bottom. ================================================================= */}
{/* Top hairline + machine-tag header bar */}
Ingest · .txt · 837P / 835
{api.isConfigured ? "Ready" : "No backend"}
{/* Payer config — Bloomberg-style form field row: small mono label + control. Two selects side-by-side. */}
{/* Write affordances — dropzone + Parse button — are gated by RoleGate so a viewer-role account can SEE the configuration controls (kind, payer) above but can't actually ingest a file. */}
Read-only access
Your role (viewer) cannot ingest new files.
} > {/* Drop area — dashed border at idle, accent glow on drag */}
{ e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={onDrop} onClick={() => inputRef.current?.click()} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); }} aria-label="Drop a file here, or click to choose" className={cn( "relative rounded-lg border border-dashed transition-all duration-200", "px-6 py-12 min-h-[200px] cursor-pointer overflow-hidden", dragging ? "border-accent bg-accent/[0.06] shadow-[inset_0_0_0_1px_hsl(var(--accent)/0.35),0_0_0_4px_hsl(var(--accent)/0.08)]" : "border-border/70 bg-background/40 hover:bg-background/60 hover:border-border" )} > {/* On drag — an accent scan-line sweeps across to reinforce that the dropzone is hot. Pure CSS via the `animate-scan` keyframe already in the Tailwind config. */} {dragging ? (
) : null}
{dragging ? ( ) : ( )}
{file ? (
{file.name} · {formatBytes(file.size)}
) : ( <>
{dragging ? ( <> Release to parse ) : ( <> Drop a file, or click to choose )}
.txt — X12 837/835 as exported from your clearinghouse
)} pickFile(e.target.files?.[0] ?? null)} />
{/* Action bar — backend status on the left, controls on the right */}
{api.isConfigured ? ( Target{" "} {api.baseUrl} ) : ( Set VITE_API_BASE_URL to enable parsing )}
{/* Parse button — the second half of the write affordance. Wrapped independently so the dropzone's `fallback` message doesn't sit next to a disabled-looking Parse button when the role is viewer. */}
{file ? ( ) : null}
{/* ================================================================= STREAM — appears while parsing or after a parse. Standard Card wrapper so the section reads as the rest of the app. ClaimCard837 / ClaimCard835 keep their warm-paper surface so each claim reads as a document inside the panel. ================================================================= */} {(running || stream.items.length > 0) ? (
{/* Stream header — eyebrow + display title + progress stats */}
Stream {running ? ( ) : null}

{running ? ( <> Parsing live. ) : ( <> Stream complete. )}

Parsed
{totalSoFar} / {expectedTotal ?? "—"}
Progress
{expectedTotal ? `${progressPct}%` : "…"}
{/* Progress bar — accent gradient, like the Inbox stream bar. */}
{/* Streamed claim list — ClaimCards stay as "documents". For 837P streams, render the ExportBar above the list so the user can select all / pick a subset / export. */} {kind === "837p" && !running && total837InStream > 0 ? ( ) : null}
{stream.items.length === 0 && running ? (
Waiting for first claim…
) : null} {stream.items.map((item, i) => item.kind === "837p" ? ( ) : ( ) )}
) : null} {/* ================================================================= RECENT BATCHES — ledger-style list with sequential numbering. Standard Card wrapper, numbered rows, kind badge + counts. ================================================================= */} {parsedBatches.length > 0 ? (
Recent batches

Archive{" "} · {parsedBatches.length}

Pass rate
{heroStats.totalClaims > 0 ? `${heroStats.passRate.toFixed(1)}%` : "—"}
    {parsedBatches.map((b, i) => { const passRatio = b.claimCount > 0 ? ((b.passed / b.claimCount) * 100).toFixed(1) : "—"; const idx = String(parsedBatches.length - i).padStart(2, "0"); return (
  • #{idx} {b.kind === "837p" ? "837P" : "835"}
    {b.inputFilename}
    {fmt.dateShort(b.parsedAt)} · {b.claimCount}{" "} {b.kind === "837p" ? "claims" : "payments"}
    {b.passed} /{" "} {b.claimCount}
    0 ? "text-[hsl(var(--warning))]" : "text-muted-foreground/70" )} > {passRatio}% pass
  • ); })}
) : null} {/* ================================================================= EMPTY STATE — shown only on a fresh browser with no parsed batches and nothing currently streaming. ================================================================= */} {parsedBatches.length === 0 && stream.items.length === 0 && !running ? (
No batches yet.
Drop a file above to start a parse.
) : 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 )} ); }