866 lines
31 KiB
TypeScript
866 lines
31 KiB
TypeScript
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 (
|
|
<span
|
|
className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--success))] font-medium"
|
|
title="Validation passed"
|
|
>
|
|
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.75} />
|
|
Passed
|
|
</span>
|
|
);
|
|
}
|
|
if (hasWarnings) {
|
|
return (
|
|
<span
|
|
className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--warning))] font-medium"
|
|
title="Validation passed with warnings"
|
|
>
|
|
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={1.75} />
|
|
Warnings
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span
|
|
className="inline-flex items-center gap-1.5 text-[11px] text-destructive font-medium"
|
|
title="Validation failed"
|
|
>
|
|
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
|
|
Failed
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function StatPill({
|
|
label,
|
|
value,
|
|
mono = true,
|
|
}: {
|
|
label: string;
|
|
value: React.ReactNode;
|
|
mono?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-0.5">
|
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
|
{label}
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"text-[13px] text-foreground",
|
|
mono && "display num"
|
|
)}
|
|
>
|
|
{value}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
|
const [open, setOpen] = useState(false);
|
|
const passed = claim.validation.passed;
|
|
const hasWarnings = claim.validation.warnings.length > 0;
|
|
return (
|
|
<div className="surface rounded-lg overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="w-full text-left px-4 py-3 hover:bg-muted/30 transition-colors"
|
|
aria-expanded={open}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<ChevronRight
|
|
className={cn(
|
|
"h-3.5 w-3.5 text-muted-foreground transition-transform shrink-0",
|
|
open && "rotate-90"
|
|
)}
|
|
strokeWidth={1.75}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2.5 flex-wrap">
|
|
<span className="display num text-[13px]">{claim.claim_id}</span>
|
|
<span className="text-[12px] text-muted-foreground truncate">
|
|
{claim.subscriber.first_name} {claim.subscriber.last_name}
|
|
</span>
|
|
<span className="text-[12px] text-muted-foreground">
|
|
· {claim.payer.name}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground num">
|
|
<span>
|
|
NPI {claim.billing_provider.npi}
|
|
</span>
|
|
<span>·</span>
|
|
<span>
|
|
{claim.service_lines.length} line
|
|
{claim.service_lines.length === 1 ? "" : "s"}
|
|
</span>
|
|
<span>·</span>
|
|
<span>
|
|
{claim.diagnoses.length} dx
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<div className="display num text-[14px]">
|
|
{fmt.usdDecimal(claim.claim.total_charge)}
|
|
</div>
|
|
<div className="mt-0.5">
|
|
<ValidationDot passed={passed} hasWarnings={hasWarnings} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{open ? (
|
|
<div className="border-t border-border/40 px-4 py-3 grid gap-4 bg-muted/20 animate-fade-in">
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<StatPill label="Member" value={claim.subscriber.member_id} />
|
|
<StatPill
|
|
label="Place of service"
|
|
value={claim.claim.place_of_service ?? "—"}
|
|
/>
|
|
<StatPill label="Frequency" value={claim.claim.frequency_code ?? "—"} />
|
|
<StatPill
|
|
label="Prior auth"
|
|
value={claim.claim.prior_auth ?? "—"}
|
|
/>
|
|
</div>
|
|
|
|
{claim.diagnoses.length > 0 ? (
|
|
<div>
|
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
|
|
Diagnoses
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{claim.diagnoses.map((d, i) => (
|
|
<Badge key={`${d.code}-${i}`} variant="muted">
|
|
{d.qualifier ? `${d.qualifier}·` : ""}
|
|
{d.code}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{claim.service_lines.length > 0 ? (
|
|
<div>
|
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
|
|
Service lines
|
|
</div>
|
|
<div className="rounded-md border border-border/40 overflow-x-auto">
|
|
<table className="w-full text-[12px]">
|
|
<thead className="bg-muted/30">
|
|
<tr className="text-left text-muted-foreground">
|
|
<th className="px-2.5 py-1.5 font-medium">#</th>
|
|
<th className="px-2.5 py-1.5 font-medium">Code</th>
|
|
<th className="px-2.5 py-1.5 font-medium">Mods</th>
|
|
<th className="px-2.5 py-1.5 font-medium">Date</th>
|
|
<th className="px-2.5 py-1.5 font-medium">Units</th>
|
|
<th className="px-2.5 py-1.5 font-medium text-right">Charge</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{claim.service_lines.map((line) => (
|
|
<ServiceLine837Row key={line.line_number} line={line} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{(claim.validation.errors.length > 0 ||
|
|
claim.validation.warnings.length > 0) ? (
|
|
<div>
|
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
|
|
Validation
|
|
</div>
|
|
<ul className="space-y-1 text-[12px]">
|
|
{claim.validation.errors.map((issue, i) => (
|
|
<li
|
|
key={`e-${i}`}
|
|
className="flex items-start gap-2 text-destructive"
|
|
>
|
|
<XCircle
|
|
className="h-3.5 w-3.5 mt-0.5 shrink-0"
|
|
strokeWidth={1.75}
|
|
/>
|
|
<span>
|
|
<span className="num">{issue.rule}</span> — {issue.message}
|
|
</span>
|
|
</li>
|
|
))}
|
|
{claim.validation.warnings.map((issue, i) => (
|
|
<li
|
|
key={`w-${i}`}
|
|
className="flex items-start gap-2 text-[hsl(var(--warning))]"
|
|
>
|
|
<AlertTriangle
|
|
className="h-3.5 w-3.5 mt-0.5 shrink-0"
|
|
strokeWidth={1.75}
|
|
/>
|
|
<span>
|
|
<span className="num">{issue.rule}</span> — {issue.message}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ServiceLine837Row({ line }: { line: ServiceLine }) {
|
|
return (
|
|
<tr className="border-t border-border/30">
|
|
<td className="px-2.5 py-1.5 num">{line.line_number}</td>
|
|
<td className="px-2.5 py-1.5 num">
|
|
{line.procedure.qualifier}·{line.procedure.code}
|
|
</td>
|
|
<td className="px-2.5 py-1.5 num text-muted-foreground">
|
|
{line.procedure.modifiers.length > 0
|
|
? line.procedure.modifiers.join(", ")
|
|
: "—"}
|
|
</td>
|
|
<td className="px-2.5 py-1.5 num text-muted-foreground">
|
|
{line.service_date ?? "—"}
|
|
</td>
|
|
<td className="px-2.5 py-1.5 num text-muted-foreground">
|
|
{line.units ? `${toNum(line.units)} ${line.unit_type ?? ""}`.trim() : "—"}
|
|
</td>
|
|
<td className="px-2.5 py-1.5 num text-right display">
|
|
{fmt.usdDecimal(line.charge)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="surface rounded-lg overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="w-full text-left px-4 py-3 hover:bg-muted/30 transition-colors"
|
|
aria-expanded={open}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<ChevronRight
|
|
className={cn(
|
|
"h-3.5 w-3.5 text-muted-foreground transition-transform shrink-0",
|
|
open && "rotate-90"
|
|
)}
|
|
strokeWidth={1.75}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2.5 flex-wrap">
|
|
<span className="display num text-[13px]">
|
|
{claim.payer_claim_control_number}
|
|
</span>
|
|
<span className="text-[12px] text-muted-foreground">
|
|
{claim.status_label ?? `Status ${claim.status_code}`}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground num">
|
|
<span>CLP {claim.status_code}</span>
|
|
{claim.original_claim_id ? (
|
|
<>
|
|
<span>·</span>
|
|
<span>Orig {claim.original_claim_id}</span>
|
|
</>
|
|
) : null}
|
|
<span>·</span>
|
|
<span>
|
|
{claim.service_payments.length} svc
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<div className="display num text-[14px] text-[hsl(var(--success))]">
|
|
{fmt.usdDecimal(claim.total_paid)}
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground num">
|
|
of {fmt.usdDecimal(claim.total_charge)}
|
|
</div>
|
|
<div className="mt-0.5">
|
|
<ValidationDot passed={passed} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{open ? (
|
|
<div className="border-t border-border/40 px-4 py-3 grid gap-4 bg-muted/20 animate-fade-in">
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<StatPill label="Filing" value={claim.claim_filing_indicator ?? "—"} />
|
|
<StatPill label="Facility" value={claim.facility_type ?? "—"} />
|
|
<StatPill label="Frequency" value={claim.frequency_code ?? "—"} />
|
|
<StatPill
|
|
label="Patient resp"
|
|
value={
|
|
claim.patient_responsibility
|
|
? fmt.usdDecimal(claim.patient_responsibility)
|
|
: "—"
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{claim.service_payments.length > 0 ? (
|
|
<div>
|
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
|
|
Service payments
|
|
</div>
|
|
<div className="rounded-md border border-border/40 overflow-x-auto">
|
|
<table className="w-full text-[12px]">
|
|
<thead className="bg-muted/30">
|
|
<tr className="text-left text-muted-foreground">
|
|
<th className="px-2.5 py-1.5 font-medium">#</th>
|
|
<th className="px-2.5 py-1.5 font-medium">Code</th>
|
|
<th className="px-2.5 py-1.5 font-medium">Mods</th>
|
|
<th className="px-2.5 py-1.5 font-medium">Date</th>
|
|
<th className="px-2.5 py-1.5 font-medium">Units</th>
|
|
<th className="px-2.5 py-1.5 font-medium text-right">Charge</th>
|
|
<th className="px-2.5 py-1.5 font-medium text-right">Paid</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{claim.service_payments.map((svc) => (
|
|
<ServicePaymentRow key={svc.line_number} svc={svc} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ServicePaymentRow({ svc }: { svc: ServicePayment }) {
|
|
return (
|
|
<tr className="border-t border-border/30">
|
|
<td className="px-2.5 py-1.5 num">{svc.line_number}</td>
|
|
<td className="px-2.5 py-1.5 num">
|
|
{svc.procedure_qualifier}·{svc.procedure_code}
|
|
</td>
|
|
<td className="px-2.5 py-1.5 num text-muted-foreground">
|
|
{svc.modifiers.length > 0 ? svc.modifiers.join(", ") : "—"}
|
|
</td>
|
|
<td className="px-2.5 py-1.5 num text-muted-foreground">
|
|
{svc.service_date ?? "—"}
|
|
</td>
|
|
<td className="px-2.5 py-1.5 num text-muted-foreground">
|
|
{svc.units ? `${toNum(svc.units)} ${svc.unit_type ?? ""}`.trim() : "—"}
|
|
</td>
|
|
<td className="px-2.5 py-1.5 num text-right display">
|
|
{fmt.usdDecimal(svc.charge)}
|
|
</td>
|
|
<td className="px-2.5 py-1.5 num text-right display text-[hsl(var(--success))]">
|
|
{fmt.usdDecimal(svc.payment)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function Upload() {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [kind, setKind] = useState<ParsedBatchKind>("837p");
|
|
const [payer, setPayer] = useState<string>(PAYERS_837[0]!.value);
|
|
const [stream, setStream] = useState<StreamState>({
|
|
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<HTMLDivElement>) {
|
|
e.preventDefault();
|
|
const f = e.dataTransfer.files?.[0];
|
|
if (f) pickFile(f);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 md:space-y-8 animate-fade-in">
|
|
<header>
|
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
|
<span className="inline-block h-px w-6 bg-border" />
|
|
Upload · EDI parser
|
|
</div>
|
|
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
|
|
Parse an X12 file
|
|
</h1>
|
|
<p className="text-muted-foreground mt-1.5 text-[14px] max-w-2xl">
|
|
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.
|
|
</p>
|
|
</header>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-[14px] flex items-center gap-2">
|
|
<UploadIcon className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.75} />
|
|
File
|
|
</CardTitle>
|
|
<CardDescription>
|
|
.txt (X12 837P or 835). The file is sent to the FastAPI backend
|
|
configured by <code className="text-[12px]">VITE_API_BASE_URL</code>.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="pt-0 space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div className="grid gap-1.5">
|
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
Kind
|
|
</div>
|
|
<Select
|
|
value={kind}
|
|
onValueChange={(v) => {
|
|
const next = v as ParsedBatchKind;
|
|
setKind(next);
|
|
setPayer(
|
|
next === "837p" ? PAYERS_837[0]!.value : PAYERS_835[0]!.value
|
|
);
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="837p">837P (Professional claim)</SelectItem>
|
|
<SelectItem value="835">835 (ERA remittance)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid gap-1.5">
|
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
Payer config
|
|
</div>
|
|
<Select value={payer} onValueChange={setPayer}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{payerOptions.map((p) => (
|
|
<SelectItem key={p.value} value={p.value}>
|
|
{p.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
onDragOver={(e) => 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"
|
|
)}
|
|
>
|
|
<UploadIcon
|
|
className="h-5 w-5 text-muted-foreground"
|
|
strokeWidth={1.5}
|
|
/>
|
|
{file ? (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<FileText
|
|
className="h-3.5 w-3.5 text-muted-foreground"
|
|
strokeWidth={1.75}
|
|
/>
|
|
<span className="font-medium">{file.name}</span>
|
|
<span className="text-muted-foreground num">
|
|
· {formatBytes(file.size)}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="text-sm font-medium">
|
|
Drop a file here, or click to choose
|
|
</div>
|
|
<div className="text-[12px] text-muted-foreground">
|
|
.txt — the X12 837/835 file as exported from your
|
|
clearinghouse
|
|
</div>
|
|
</>
|
|
)}
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept=".txt"
|
|
className="sr-only"
|
|
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="text-[12px] text-muted-foreground">
|
|
{api.isConfigured ? (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
|
|
Backend ready · {api.baseUrl}
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5 text-[hsl(var(--warning))]">
|
|
<AlertTriangle
|
|
className="h-3 w-3"
|
|
strokeWidth={1.75}
|
|
/>
|
|
No backend configured — set VITE_API_BASE_URL to enable parsing
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{file ? (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => pickFile(null)}
|
|
disabled={running}
|
|
className="min-h-[44px] sm:min-h-0"
|
|
>
|
|
Clear
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
onClick={onParse}
|
|
disabled={!file || running}
|
|
className="min-h-[44px] sm:min-h-0"
|
|
>
|
|
{running ? (
|
|
<>
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
Parsing…
|
|
</>
|
|
) : (
|
|
<>
|
|
<UploadIcon className="h-3.5 w-3.5" />
|
|
Parse
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{(running || stream.items.length > 0) ? (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="text-[14px] flex items-center gap-2">
|
|
{running ? (
|
|
<Loader2
|
|
className="h-3.5 w-3.5 animate-spin text-muted-foreground"
|
|
strokeWidth={1.75}
|
|
/>
|
|
) : (
|
|
<CheckCircle2
|
|
className="h-3.5 w-3.5 text-[hsl(var(--success))]"
|
|
strokeWidth={1.75}
|
|
/>
|
|
)}
|
|
{kind === "837p" ? "Claims" : "Claim payments"}
|
|
</CardTitle>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{totalSoFar} of{" "}
|
|
{expectedTotal ?? "?"} parsed
|
|
{stream.failed > 0
|
|
? ` · ${stream.failed} failed validation`
|
|
: ""}
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="display num text-[16px]">
|
|
{expectedTotal ? `${progressPct}%` : "…"}
|
|
</div>
|
|
<div className="text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground">
|
|
progress
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 h-1 w-full rounded-full bg-muted overflow-hidden">
|
|
<div
|
|
className="h-full bg-accent transition-[width] duration-200"
|
|
style={{ width: `${progressPct}%` }}
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<div className="max-h-[640px] overflow-y-auto -mx-2 px-2 space-y-2">
|
|
{stream.items.length === 0 && running ? (
|
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
|
Waiting for first claim…
|
|
</div>
|
|
) : null}
|
|
{stream.items.map((item, i) =>
|
|
item.kind === "837p" ? (
|
|
<ClaimCard837 key={`837-${item.data.claim_id}-${i}`} claim={item.data} />
|
|
) : (
|
|
<ClaimCard835
|
|
key={`835-${item.data.payer_claim_control_number}-${i}`}
|
|
claim={item.data}
|
|
/>
|
|
)
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
{parsedBatches.length > 0 ? (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-[14px]">Recent batches</CardTitle>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Parsed files, newest first. Click a row to revisit it (in a
|
|
future view).
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<ul className="divide-y divide-border/40">
|
|
{parsedBatches.map((b) => (
|
|
<li
|
|
key={b.id}
|
|
className="flex items-center gap-3 py-3 first:pt-0 last:pb-0"
|
|
>
|
|
<Badge variant={b.kind === "837p" ? "default" : "muted"}>
|
|
{b.kind === "837p" ? "837P" : "835"}
|
|
</Badge>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">
|
|
{b.inputFilename}
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground num">
|
|
{fmt.dateShort(b.parsedAt)} · {b.claimCount} {b.kind === "837p" ? "claims" : "payments"}
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<div className="display num text-[13px]">
|
|
{b.passed} / {b.claimCount}
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground">
|
|
passed
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|