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 (
);
}
function ClaimCard837({ claim }: { claim: ClaimOutput }) {
const [open, setOpen] = useState(false);
const passed = claim.validation.passed;
const hasWarnings = claim.validation.warnings.length > 0;
return (
setOpen((v) => !v)}
className="w-full text-left px-4 py-3 hover:bg-muted/30 transition-colors"
aria-expanded={open}
>
{claim.claim_id}
{claim.subscriber.first_name} {claim.subscriber.last_name}
· {claim.payer.name}
NPI {claim.billing_provider.npi}
·
{claim.service_lines.length} line
{claim.service_lines.length === 1 ? "" : "s"}
·
{claim.diagnoses.length} dx
{fmt.usdDecimal(claim.claim.total_charge)}
{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
#
Code
Mods
Date
Units
Charge
{claim.service_lines.map((line) => (
))}
) : 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 (
setOpen((v) => !v)}
className="w-full text-left px-4 py-3 hover:bg-muted/30 transition-colors"
aria-expanded={open}
>
{claim.payer_claim_control_number}
{claim.status_label ?? `Status ${claim.status_code}`}
CLP {claim.status_code}
{claim.original_claim_id ? (
<>
·
Orig {claim.original_claim_id}
>
) : null}
·
{claim.service_payments.length} svc
{fmt.usdDecimal(claim.total_paid)}
of {fmt.usdDecimal(claim.total_charge)}
{open ? (
{claim.service_payments.length > 0 ? (
Service payments
#
Code
Mods
Date
Units
Charge
Paid
{claim.service_payments.map((svc) => (
))}
) : 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 (
File
.txt (X12 837P or 835). The file is sent to the FastAPI backend
configured by VITE_API_BASE_URL.
Kind
{
const next = v as ParsedBatchKind;
setKind(next);
setPayer(
next === "837p" ? PAYERS_837[0]!.value : PAYERS_835[0]!.value
);
}}
>
837P (Professional claim)
835 (ERA remittance)
Payer config
{payerOptions.map((p) => (
{p.label}
))}
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 ? (
pickFile(null)}
disabled={running}
className="min-h-[44px] sm:min-h-0"
>
Clear
) : null}
{running ? (
<>
Parsing…
>
) : (
<>
Parse
>
)}
{(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}
);
}