import { useMemo, useRef, useState } from "react"; import { AlertTriangle, CheckCircle2, ChevronRight, FileText, Loader2, Upload as UploadIcon, XCircle, } from "lucide-react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { api, type ParseProgress } from "@/lib/api"; import { fmt, toNum } from "@/lib/format"; import { useAppStore } from "@/store"; import { useParse } from "@/hooks/useParse"; import type { ClaimOutput, ClaimPayment, ParsedBatch, ParsedBatchKind, ServiceLine, ServicePayment, } from "@/types"; import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- // 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. // --------------------------------------------------------------------------- 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`; } // --------------------------------------------------------------------------- // Small reusable card bits // --------------------------------------------------------------------------- function ValidationDot({ passed, hasWarnings, }: { passed: boolean; hasWarnings?: boolean; }) { if (passed) { return ( Passed ); } if (hasWarnings) { return ( Warnings ); } return ( Failed ); } function StatPill({ label, value, mono = true, }: { label: string; value: React.ReactNode; mono?: boolean; }) { return (
{label}
{value}
); } function ClaimCard837({ claim }: { claim: ClaimOutput }) { const [open, setOpen] = useState(false); const passed = claim.validation.passed; const hasWarnings = claim.validation.warnings.length > 0; return (
{open ? (
{claim.diagnoses.length > 0 ? (
Diagnoses
{claim.diagnoses.map((d, i) => ( {d.qualifier ? `${d.qualifier}·` : ""} {d.code} ))}
) : null} {claim.service_lines.length > 0 ? (
Service lines
{claim.service_lines.map((line) => ( ))}
# Code Mods Date Units Charge
) : null} {(claim.validation.errors.length > 0 || claim.validation.warnings.length > 0) ? (
Validation
    {claim.validation.errors.map((issue, i) => (
  • {issue.rule} — {issue.message}
  • ))} {claim.validation.warnings.map((issue, i) => (
  • {issue.rule} — {issue.message}
  • ))}
) : null}
) : null}
); } function ServiceLine837Row({ line }: { line: ServiceLine }) { return ( {line.line_number} {line.procedure.qualifier}·{line.procedure.code} {line.procedure.modifiers.length > 0 ? line.procedure.modifiers.join(", ") : "—"} {line.service_date ?? "—"} {line.units ? `${toNum(line.units)} ${line.unit_type ?? ""}`.trim() : "—"} {fmt.usdDecimal(line.charge)} ); } function ClaimCard835({ claim }: { claim: ClaimPayment }) { const [open, setOpen] = useState(false); const passed = claim.service_payments.length > 0; // placeholder; the // 835 batch-level validation is on summary. We treat the per-card view as // "ok" if the claim parsed at all. The card-level dot still gives a visual // anchor; the summary toast shows the real pass/fail. return (
{open ? (
{claim.service_payments.length > 0 ? (
Service payments
{claim.service_payments.map((svc) => ( ))}
# Code Mods Date Units Charge Paid
) : 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)} ); } // --------------------------------------------------------------------------- // 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 addParsedBatch = useAppStore((s) => s.addParsedBatch); const parsedBatches = useAppStore((s) => s.parsedBatches); // useParse wires the mutation; on success it invalidates every list query // (batches, claims, remittances, providers, activity) so the other pages // see the new data without manual refetching. const parseMutation = useParse(kind); 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]); 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, })); } // financial_info / trace / payer / payee are accepted silently; // the page summary at the end reports the real pass/fail counts. }; try { // mutateAsync triggers the mutation; the streaming callbacks above // populate `stream` as claims arrive, and the resolver returns the // final BatchSummary from the "summary" NDJSON line. const summary = await parseMutation.mutateAsync({ file, options: { payer, onProgress }, }); // The final return value is a BatchSummary in the streaming case. 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); 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(); const f = e.dataTransfer.files?.[0]; if (f) pickFile(f); } return (
Upload · EDI parser

Parse an X12 file

Upload an 837P professional claim or 835 ERA remittance file. The parser streams claims back as they're produced so the UI updates in real time.

File .txt (X12 837P or 835). The file is sent to the FastAPI backend configured by VITE_API_BASE_URL.
Kind
Payer config
e.preventDefault()} onDrop={onDrop} onClick={() => inputRef.current?.click()} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); }} className={cn( "relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-10 sm:py-8 min-h-[140px] sm:min-h-[120px] cursor-pointer transition-colors", "hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" )} > {file ? (
{file.name} · {formatBytes(file.size)}
) : ( <>
Drop a file here, or click to choose
.txt — the X12 837/835 file as exported from your clearinghouse
)} pickFile(e.target.files?.[0] ?? null)} />
{api.isConfigured ? ( Backend ready · {api.baseUrl} ) : ( No backend configured — set VITE_API_BASE_URL to enable parsing )}
{file ? ( ) : null}
{(running || stream.items.length > 0) ? (
{running ? ( ) : ( )} {kind === "837p" ? "Claims" : "Claim payments"}

{totalSoFar} of{" "} {expectedTotal ?? "?"} parsed {stream.failed > 0 ? ` · ${stream.failed} failed validation` : ""}

{expectedTotal ? `${progressPct}%` : "…"}
progress
{stream.items.length === 0 && running ? (
Waiting for first claim…
) : null} {stream.items.map((item, i) => item.kind === "837p" ? ( ) : ( ) )}
) : null} {parsedBatches.length > 0 ? ( Recent batches

Parsed files, newest first. Click a row to revisit it (in a future view).

    {parsedBatches.map((b) => (
  • {b.kind === "837p" ? "837P" : "835"}
    {b.inputFilename}
    {fmt.dateShort(b.parsedAt)} · {b.claimCount} {b.kind === "837p" ? "claims" : "payments"}
    {b.passed} / {b.claimCount}
    passed
  • ))}
) : null}
); }