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