feat(frontend): wire Vite app to FastAPI parser with NDJSON streaming + Upload page

This commit is contained in:
Tyler
2026-06-19 17:31:59 -06:00
parent 999889ecb3
commit 3d3bdfb07c
44 changed files with 7811 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
import { useAppStore } from "@/store";
import { ActivityFeed } from "@/components/ActivityFeed";
export function ActivityLog() {
const activity = useAppStore((s) => s.activity);
return (
<div className="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" />
Activity
</div>
<h1 className="text-[22px] font-semibold tracking-tight">Activity log</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Every claim submission, denial, payment, and provider event, in
reverse chronological order.
</p>
</header>
<div className="surface rounded-xl p-6">
<ActivityFeed items={activity} emptyMessage="No activity recorded yet." />
</div>
</div>
);
}
+235
View File
@@ -0,0 +1,235 @@
import { useMemo, useRef, useState } from "react";
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ClaimStatusBadge } from "@/components/StatusBadge";
import { NewClaimDialog } from "@/components/NewClaimDialog";
import { useAppStore } from "@/store";
import { fmt } from "@/lib/format";
import type { ClaimStatus } from "@/types";
const ALL = "all" as const;
const statuses: (ClaimStatus | typeof ALL)[] = [
ALL,
"submitted",
"accepted",
"denied",
"paid",
"pending",
];
export function Claims() {
const claims = useAppStore((s) => s.claims);
const providers = useAppStore((s) => s.providers);
const [query, setQuery] = useState("");
const [status, setStatus] = useState<ClaimStatus | typeof ALL>(ALL);
const [npi, setNpi] = useState<string>(ALL);
const searchRef = useRef<HTMLInputElement>(null);
const providerMap = useMemo(
() => Object.fromEntries(providers.map((p) => [p.npi, p])),
[providers]
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return claims.filter((c) => {
if (status !== ALL && c.status !== status) return false;
if (npi !== ALL && c.providerNpi !== npi) return false;
if (!q) return true;
return (
c.id.toLowerCase().includes(q) ||
c.patientName.toLowerCase().includes(q) ||
c.payerName.toLowerCase().includes(q) ||
c.cptCode.toLowerCase().includes(q)
);
});
}, [claims, query, status, npi]);
const totals = useMemo(
() => ({
billed: filtered.reduce((s, c) => s + c.billedAmount, 0),
received: filtered.reduce((s, c) => s + c.receivedAmount, 0),
}),
[filtered]
);
return (
<div className="space-y-8 animate-fade-in">
<header className="flex items-end justify-between gap-6 flex-wrap">
<div>
<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" />
Claims
</div>
<h1 className="text-[22px] font-semibold tracking-tight">All claims</h1>
</div>
<NewClaimDialog />
</header>
<div className="surface rounded-xl p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[240px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchRef}
placeholder="Search by claim ID, patient, payer, CPT…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 pr-16"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground p-1.5 rounded-md"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<button
type="button"
onClick={() => searchRef.current?.focus()}
className="absolute right-2 top-1/2 -translate-y-1/2 kbd hover:text-foreground"
aria-label="Focus search"
>
K
</button>
)}
</div>
<Select
value={status}
onValueChange={(v) => setStatus(v as ClaimStatus | typeof ALL)}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statuses.map((s) => (
<SelectItem key={s} value={s}>
{s === ALL ? "All statuses" : s[0]!.toUpperCase() + s.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={npi} onValueChange={setNpi}>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All providers</SelectItem>
{providers.map((p) => (
<SelectItem key={p.npi} value={p.npi}>
{p.npi} {p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-6 mt-4 pt-4 border-t border-border/40 text-xs text-muted-foreground">
<span>
<span className="num text-foreground font-medium">
{fmt.num(filtered.length)}
</span>{" "}
claims
</span>
<span>
Billed{" "}
<span className="num text-foreground font-medium">
{fmt.usd(totals.billed)}
</span>
</span>
<span>
Received{" "}
<span className="num text-foreground font-medium">
{fmt.usd(totals.received)}
</span>
</span>
</div>
</div>
<div className="surface rounded-xl overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Claim</TableHead>
<TableHead>Patient</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Payer</TableHead>
<TableHead className="text-right">Billed</TableHead>
<TableHead className="text-right">Received</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
className="text-center py-12 text-muted-foreground"
>
No claims match the current filters.
</TableCell>
</TableRow>
) : (
filtered.map((c) => {
const provider = providerMap[c.providerNpi];
return (
<TableRow key={c.id}>
<TableCell>
<div className="display num text-[13px]">{c.id}</div>
<div className="text-[11px] text-muted-foreground num">
CPT {c.cptCode}
</div>
</TableCell>
<TableCell className="font-medium">{c.patientName}</TableCell>
<TableCell>
<div className="text-sm">{provider?.name ?? "Unknown"}</div>
<div className="text-[11px] text-muted-foreground num">
{c.providerNpi}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{c.payerName}
</TableCell>
<TableCell className="text-right display num">
{fmt.usd(c.billedAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{c.receivedAmount > 0 ? fmt.usd(c.receivedAmount) : "—"}
</TableCell>
<TableCell>
<ClaimStatusBadge status={c.status} />
</TableCell>
<TableCell className="text-muted-foreground text-[13px] num">
{fmt.dateShort(c.submissionDate)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
);
}
+330
View File
@@ -0,0 +1,330 @@
import { useMemo } from "react";
import {
AlertCircle,
Banknote,
CircleDollarSign,
Clock,
Inbox,
Receipt,
TrendingDown,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { KpiCard } from "@/components/KpiCard";
import { ActivityFeed } from "@/components/ActivityFeed";
import { AnimatedNumber } from "@/components/AnimatedNumber";
import { fmt } from "@/lib/format";
import { useAppStore } from "@/store";
const MONTHS_BACK = 6;
function buildMonthly(claims: ReturnType<typeof useAppStore.getState>["claims"]) {
const now = new Date();
const months: {
key: string;
label: string;
count: number;
billed: number;
received: number;
denied: number;
}[] = [];
for (let i = MONTHS_BACK - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push({
key: `${d.getFullYear()}-${d.getMonth()}`,
label: d.toLocaleString("en-US", { month: "short" }),
count: 0,
billed: 0,
received: 0,
denied: 0,
});
}
const index = new Map(months.map((m, i) => [m.key, i]));
for (const c of claims) {
const d = new Date(c.submissionDate);
const k = `${d.getFullYear()}-${d.getMonth()}`;
const i = index.get(k);
if (i === undefined) continue;
months[i]!.count += 1;
months[i]!.billed += c.billedAmount;
months[i]!.received += c.receivedAmount;
if (c.status === "denied") months[i]!.denied += 1;
}
// Running outstanding AR (cumulative billed cumulative received)
let running = 0;
const ar: number[] = [];
for (const m of months) {
running += m.billed - m.received;
ar.push(Math.max(0, running));
}
return {
count: months.map((m) => m.count),
billed: months.map((m) => m.billed),
received: months.map((m) => m.received),
ar,
denialRate: months.map((m) => (m.count ? (m.denied / m.count) * 100 : 0)),
};
}
export function Dashboard() {
const claims = useAppStore((s) => s.claims);
const providers = useAppStore((s) => s.providers);
const activity = useAppStore((s) => s.activity);
const kpis = useMemo(() => {
const billed = claims.reduce((s, c) => s + c.billedAmount, 0);
const received = claims.reduce((s, c) => s + c.receivedAmount, 0);
const outstandingAr = billed - received;
const denied = claims.filter((c) => c.status === "denied").length;
const denialRate = claims.length > 0 ? (denied / claims.length) * 100 : 0;
const pending = claims.filter(
(c) => c.status === "submitted" || c.status === "pending"
).length;
return {
count: claims.length,
billed,
received,
outstandingAr,
denialRate,
pending,
};
}, [claims]);
const monthly = useMemo(() => buildMonthly(claims), [claims]);
const topProviders = useMemo(
() => [...providers].sort((a, b) => b.claimCount - a.claimCount).slice(0, 3),
[providers]
);
const topDenials = useMemo(
() => claims.filter((c) => c.status === "denied").slice(0, 5),
[claims]
);
// Stagger choreography
const heroDelay = 0;
const kpiBase = 120;
const kpiStep = 70;
const sectionBase = kpiBase + kpiStep * 5 + 60; // after the last KPI
return (
<div className="space-y-10">
{/* Hero */}
<header
className="relative animate-fade-in overflow-hidden"
style={{ animationDelay: `${heroDelay}ms` }}
>
<div className="relative">
{/* The signature element: a ghosted total behind the greeting. */}
<div
aria-hidden="true"
className="pointer-events-none select-none absolute left-0 top-1/2 -translate-y-1/2 whitespace-nowrap display font-medium text-foreground"
style={{
fontSize: "clamp(80px, 14vw, 168px)",
letterSpacing: "-0.05em",
opacity: 0.055,
lineHeight: 1,
}}
>
{fmt.usd(kpis.billed)}
</div>
<div className="relative z-10 flex items-end justify-between gap-6 flex-wrap">
<div>
<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" />
Today · {fmt.date(new Date().toISOString())}
</div>
<h1 className="text-[28px] leading-[1.15] font-semibold tracking-tight">
Good morning, Jordan.
</h1>
<p className="text-muted-foreground mt-1.5 max-w-xl text-[14px] leading-relaxed">
Three NPIs are in flight. {kpis.pending} claims pending,
clearinghouse cycle is normal.
</p>
</div>
<div className="flex items-center gap-2.5 rounded-full border border-border/60 bg-card/60 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur">
<span className="relative inline-flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--success))] opacity-60 animate-ping" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
</span>
Clearinghouse live
</div>
</div>
</div>
</header>
{/* KPIs — every card carries a trend. */}
<section
aria-label="Key performance indicators"
className="grid gap-4 grid-cols-2 lg:grid-cols-5"
>
<KpiCard
label="Claims"
icon={Receipt}
sparkline={monthly.count}
className="animate-fade-in"
style={{ animationDelay: `${kpiBase + 0 * kpiStep}ms` }}
value={
<AnimatedNumber value={kpis.count} format={(n) => fmt.num(Math.round(n))} />
}
hint="last 6 months"
/>
<KpiCard
label="Billed"
icon={CircleDollarSign}
sparkline={monthly.billed}
className="animate-fade-in"
style={{ animationDelay: `${kpiBase + 1 * kpiStep}ms` }}
value={<AnimatedNumber value={kpis.billed} format={fmt.usd} />}
delta={{ value: "+12.4%", direction: "up", positive: true }}
/>
<KpiCard
label="Received"
icon={Banknote}
sparkline={monthly.received}
className="animate-fade-in"
style={{ animationDelay: `${kpiBase + 2 * kpiStep}ms` }}
value={<AnimatedNumber value={kpis.received} format={fmt.usd} />}
delta={{ value: "+8.1%", direction: "up", positive: true }}
/>
<KpiCard
label="Pending AR"
icon={Clock}
sparkline={monthly.ar}
className="animate-fade-in"
style={{ animationDelay: `${kpiBase + 3 * kpiStep}ms` }}
value={
<AnimatedNumber
value={kpis.outstandingAr}
format={(n) => fmt.usd(Math.max(0, n))}
/>
}
hint={`${kpis.pending} in queue`}
/>
<KpiCard
label="Denial rate"
icon={TrendingDown}
sparkline={monthly.denialRate}
className="animate-fade-in"
style={{ animationDelay: `${kpiBase + 4 * kpiStep}ms` }}
value={
<AnimatedNumber
value={kpis.denialRate}
format={(n) => fmt.pct(n)}
/>
}
delta={{ value: "-1.2 pts", direction: "down", positive: true }}
/>
</section>
{/* Activity + Top providers */}
<section
className="grid gap-4 lg:grid-cols-3 animate-fade-in"
style={{ animationDelay: `${sectionBase}ms` }}
>
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div>
<CardTitle className="flex items-center gap-2 text-[14px]">
<Inbox className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.75} />
Recent activity
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Newest submissions, denials, and remittances across all providers.
</p>
</div>
<span className="text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground num">
{activity.length} events
</span>
</CardHeader>
<CardContent className="pt-0">
<ActivityFeed items={activity.slice(0, 10)} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-[14px]">Top providers</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Ranked by claim volume, last 6 months.
</p>
</CardHeader>
<CardContent className="pt-0">
<ul className="space-y-4">
{topProviders.map((p, i) => (
<li key={p.npi} className="flex items-center gap-3">
<div className="h-7 w-7 rounded-md bg-muted/60 flex items-center justify-center text-[11px] font-medium num text-muted-foreground">
{String(i + 1).padStart(2, "0")}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{p.name}</div>
<div className="text-[11px] text-muted-foreground num">
NPI {p.npi}
</div>
</div>
<div className="text-right">
<div className="display text-[15px]">{fmt.num(p.claimCount)}</div>
<div className="text-[11px] text-muted-foreground num">
{fmt.usd(p.outstandingAr)} AR
</div>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
</section>
{topDenials.length > 0 ? (
<section
className="animate-fade-in"
style={{ animationDelay: `${sectionBase + 90}ms` }}
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-[14px]">
<AlertCircle
className="h-3.5 w-3.5 text-destructive"
strokeWidth={1.75}
/>
Recent denials
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Investigate and resubmit where appropriate.
</p>
</CardHeader>
<CardContent className="pt-0">
<ul className="divide-y divide-border/40">
{topDenials.map((c) => (
<li
key={c.id}
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
>
<div className="display text-[13px] num text-muted-foreground pt-0.5 w-24 shrink-0">
{c.id}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm">{c.patientName}</div>
<div className="text-[12px] text-muted-foreground">
{c.denialReason ?? "—"}
</div>
</div>
<div className="text-right shrink-0">
<div className="display text-[14px] num">
{fmt.usd(c.billedAmount)}
</div>
<div className="text-[11px] text-muted-foreground">
{c.payerName}
</div>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
</section>
) : null}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { Building2, MapPin, Phone } from "lucide-react";
import { useAppStore } from "@/store";
import { fmt } from "@/lib/format";
export function Providers() {
const providers = useAppStore((s) => s.providers);
return (
<div className="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" />
Providers
</div>
<h1 className="text-[22px] font-semibold tracking-tight">Provider directory</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
NPIs registered with the clearinghouse for 837P submission and 835
remittance retrieval.
</p>
</header>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{providers.map((p) => (
<article
key={p.npi}
className="surface rounded-xl p-6 flex flex-col gap-4 hover:bg-muted/20 transition-colors"
>
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-muted/60 flex items-center justify-center text-foreground">
<Building2 className="h-5 w-5" strokeWidth={1.5} />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-[15px] font-semibold tracking-tight">
{p.name}
</h3>
<p className="text-[11px] text-muted-foreground num mt-0.5">
NPI {p.npi} · TIN {p.taxId}
</p>
</div>
</div>
<div className="space-y-1.5 text-[13px] text-muted-foreground">
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5" strokeWidth={1.75} />
<span>
{p.address}, {p.city}, {p.state} {p.zip}
</span>
</div>
<div className="flex items-center gap-2">
<Phone className="h-3.5 w-3.5" strokeWidth={1.75} />
<span className="num">{p.phone}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
Claims
</div>
<div className="display text-[20px] leading-none mt-1.5">
{fmt.num(p.claimCount)}
</div>
</div>
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
Outstanding AR
</div>
<div className="display text-[20px] leading-none mt-1.5">
{fmt.usd(p.outstandingAr)}
</div>
</div>
</div>
</article>
))}
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { RemitStatusBadge } from "@/components/StatusBadge";
import { useAppStore } from "@/store";
import { fmt } from "@/lib/format";
export function Remittances() {
const remits = useAppStore((s) => s.remittances);
const total = remits.reduce(
(acc, r) => ({
paid: acc.paid + r.paidAmount,
adjustments: acc.adjustments + r.adjustmentAmount,
}),
{ paid: 0, adjustments: 0 }
);
return (
<div className="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" />
Remittances
</div>
<h1 className="text-[22px] font-semibold tracking-tight">835 remittances</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Electronic remittance advice from payers, matched to submitted claims.
</p>
</header>
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3">
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Remits
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.num(remits.length)}</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Total paid
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.paid)}</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Adjustments
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.adjustments)}</div>
</div>
</div>
<div className="surface rounded-xl overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Remit</TableHead>
<TableHead>Claim</TableHead>
<TableHead>Payer</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Adjustment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Received</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{remits.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
No remittances yet.
</TableCell>
</TableRow>
) : (
remits.map((r) => (
<TableRow key={r.id}>
<TableCell className="display num text-[13px]">{r.id}</TableCell>
<TableCell className="display num text-[13px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display num">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{fmt.usdPrecise(r.adjustmentAmount)}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground num text-[13px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
+858
View File
@@ -0,0 +1,858 @@
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 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-hidden">
<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-hidden">
<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 [running, setRunning] = useState(false);
const [stream, setStream] = useState<StreamState>({
items: [],
expectedTotal: null,
passed: 0,
failed: 0,
});
const addParsedBatch = useAppStore((s) => s.addParsedBatch);
const parsedBatches = useAppStore((s) => s.parsedBatches);
const payerOptions = kind === "837p" ? PAYERS_837 : PAYERS_835;
const expectedTotal = stream.expectedTotal;
const totalSoFar = stream.items.length;
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;
}
setRunning(true);
setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 });
const startedAt = new Date().toISOString();
const items: StreamedClaim[] = [];
try {
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.
};
const summary = await (kind === "837p"
? api.parse837(file, { payer, onProgress })
: api.parse835(file, { 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: startedAt,
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"
);
} finally {
setRunning(false);
}
}
function onDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
const f = e.dataTransfer.files?.[0];
if (f) pickFile(f);
}
return (
<div className="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] 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-8 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 items-center justify-between gap-3">
<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">
{file ? (
<Button
variant="ghost"
onClick={() => pickFile(null)}
disabled={running}
>
Clear
</Button>
) : null}
<Button
onClick={onParse}
disabled={!file || running}
>
{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>
);
}