feat(frontend): wire Vite app to FastAPI parser with NDJSON streaming + Upload page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user