import { useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { AlertTriangle, ArrowRight, ChevronRight, CloudUpload, FileText, 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 { 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, type ParseProgress } from "@/lib/api"; import { fmt, toNum } from "@/lib/format"; import { useAppStore } from "@/store"; import { useParse } from "@/hooks/useParse"; import { useBatchExport } from "@/hooks/useBatchExport"; 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 [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); // 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".) function pickFile(f: File | null) { setFile(f); setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 }); } 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"} />
{/* ================================================================= 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}
); }