7e4bb4d2c8
Dashboard.tsx was reading claims/providers/activity directly from the zustand `useAppStore`, which returns the hardcoded sample fixtures (sampleClaims / sampleProviders / sampleActivity) regardless of whether a backend session is active. The hooks useClaims / useProviders / useActivity are wired correctly to /api/* when api.isConfigured, so the fix is to swap the three direct store reads for the hooks. buildMonthly's parameter is now typed as Claim[] (the live Claim shape) instead of the store's heterogeneous slice, and the `useAppStore` import is gone. Verified end-to-end: after logging in as admin via the live server, the Dashboard now shows $0 KPIs, "0 events / No activity yet.", and the live operator name in the greeting — proving the hooks are hitting /api/* and not falling back to the sample store.
432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
AlertCircle,
|
|
Banknote,
|
|
CircleDollarSign,
|
|
Clock,
|
|
Inbox,
|
|
Receipt,
|
|
TrendingDown,
|
|
} from "lucide-react";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { PageHeader } from "@/components/PageHeader";
|
|
import { KpiCard } from "@/components/KpiCard";
|
|
import { ActivityFeed } from "@/components/ActivityFeed";
|
|
import { AnimatedNumber } from "@/components/AnimatedNumber";
|
|
import { DrillableCell } from "@/components/drill/DrillableCell";
|
|
import { fmt } from "@/lib/format";
|
|
import { eventKindToUrl } from "@/lib/event-routing";
|
|
import { useAuth } from "@/auth/useAuth";
|
|
import { useClaims } from "@/hooks/useClaims";
|
|
import { useProviders } from "@/hooks/useProviders";
|
|
import { useActivity } from "@/hooks/useActivity";
|
|
import type { Claim } from "@/types";
|
|
import { toast } from "sonner";
|
|
|
|
const MONTHS_BACK = 6;
|
|
|
|
function buildMonthly(claims: Claim[]) {
|
|
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;
|
|
}
|
|
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() {
|
|
// Live data: hooks fetch from /api/* when api.isConfigured; otherwise
|
|
// they fall back to the in-memory store. Pulling from the hooks (not
|
|
// the store directly) is what wires the Dashboard to the backend.
|
|
const claimsQuery = useClaims({ limit: 100 });
|
|
const providersQuery = useProviders();
|
|
const activityQuery = useActivity({ limit: 10 });
|
|
|
|
const claims = claimsQuery.data?.items ?? [];
|
|
const providers = providersQuery.data?.items ?? [];
|
|
const activity = activityQuery.data?.items ?? [];
|
|
|
|
const navigate = useNavigate();
|
|
// Time-of-day greeting + live operator name from the auth context.
|
|
// Falls back to a neutral "there" while /api/auth/me is still in
|
|
// flight so we never flash "undefined" at the operator.
|
|
const auth = useAuth();
|
|
const greetingPart = (() => {
|
|
const h = new Date().getHours();
|
|
return h < 12 ? "morning" : h < 18 ? "afternoon" : "evening";
|
|
})();
|
|
const operatorName = auth.user?.username ?? "there";
|
|
|
|
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, 4),
|
|
[providers]
|
|
);
|
|
|
|
const topDenials = useMemo(
|
|
() => claims.filter((c) => c.status === "denied").slice(0, 5),
|
|
[claims]
|
|
);
|
|
|
|
// Stagger choreography — the hero lands first, then the KPIs in
|
|
// a left-to-right wave, then the supporting cards. Total
|
|
// choreography fits under 700ms.
|
|
const heroDelay = 0;
|
|
const kpiBase = 120;
|
|
const kpiStep = 60;
|
|
const sectionBase = kpiBase + kpiStep * 5 + 80;
|
|
|
|
return (
|
|
<div className="space-y-6 lg:space-y-10">
|
|
{/* Hero — the dashboard's only moment of editorial display. The
|
|
ghost total sits behind the greeting at single-digit opacity
|
|
so it reads as a watermark, not a competing headline. */}
|
|
<section
|
|
className="relative animate-fade-in overflow-hidden"
|
|
style={{ animationDelay: `${heroDelay}ms` }}
|
|
>
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none select-none absolute -left-4 top-1/2 -translate-y-1/2 whitespace-nowrap display text-foreground"
|
|
style={{
|
|
fontSize: "clamp(80px, 14vw, 168px)",
|
|
letterSpacing: "-0.04em",
|
|
opacity: 0.04,
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
{fmt.usd(kpis.billed)}
|
|
</div>
|
|
|
|
<div className="relative z-10 flex items-end justify-between gap-4 sm:gap-6 flex-wrap">
|
|
<div className="min-w-0">
|
|
<PageHeader
|
|
eyebrow={`Today · ${fmt.date(new Date().toISOString())}`}
|
|
title={
|
|
<>
|
|
Good {greetingPart}, {operatorName}.
|
|
</>
|
|
}
|
|
subtitle={
|
|
<>
|
|
<span className="display text-foreground">Three NPIs</span> are
|
|
in flight. {kpis.pending} claims pending, clearinghouse cycle
|
|
is normal.
|
|
</>
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3 py-1.5 text-[11px] mono uppercase tracking-[0.12em] 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>
|
|
</section>
|
|
|
|
{/* KPIs — five tiles, each carrying a sparkline. Staggered
|
|
fade-in on first paint for a "the instrument powers up"
|
|
feel. */}
|
|
<section
|
|
aria-label="Key performance indicators"
|
|
className="grid gap-3.5 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5"
|
|
>
|
|
<DrillableCell
|
|
onClick={() => navigate("/claims")}
|
|
ariaLabel="View all claims"
|
|
>
|
|
<KpiCard
|
|
label="Claims"
|
|
icon={Receipt}
|
|
sparkline={monthly.count}
|
|
className="animate-fade-in-up"
|
|
style={{ animationDelay: `${kpiBase + 0 * kpiStep}ms` }}
|
|
value={
|
|
<AnimatedNumber value={kpis.count} format={(n) => fmt.num(Math.round(n))} />
|
|
}
|
|
hint="last 6 months"
|
|
/>
|
|
</DrillableCell>
|
|
<DrillableCell
|
|
onClick={() => navigate("/claims?sort=-billedAmount")}
|
|
ariaLabel="View claims sorted by billed amount"
|
|
>
|
|
<KpiCard
|
|
label="Billed"
|
|
icon={CircleDollarSign}
|
|
accent="accent"
|
|
sparkline={monthly.billed}
|
|
className="animate-fade-in-up"
|
|
style={{ animationDelay: `${kpiBase + 1 * kpiStep}ms` }}
|
|
value={<AnimatedNumber value={kpis.billed} format={fmt.usd} />}
|
|
delta={{ value: "+12.4%", direction: "up", positive: true }}
|
|
/>
|
|
</DrillableCell>
|
|
<DrillableCell
|
|
onClick={() => navigate("/claims?sort=-receivedAmount")}
|
|
ariaLabel="View claims sorted by received amount"
|
|
>
|
|
<KpiCard
|
|
label="Received"
|
|
icon={Banknote}
|
|
accent="success"
|
|
sparkline={monthly.received}
|
|
className="animate-fade-in-up"
|
|
style={{ animationDelay: `${kpiBase + 2 * kpiStep}ms` }}
|
|
value={<AnimatedNumber value={kpis.received} format={fmt.usd} />}
|
|
delta={{ value: "+8.1%", direction: "up", positive: true }}
|
|
/>
|
|
</DrillableCell>
|
|
<DrillableCell
|
|
onClick={() => navigate("/claims")}
|
|
ariaLabel="View pending accounts receivable claims"
|
|
>
|
|
<KpiCard
|
|
label="Pending AR"
|
|
icon={Clock}
|
|
accent="warning"
|
|
sparkline={monthly.ar}
|
|
className="animate-fade-in-up"
|
|
style={{ animationDelay: `${kpiBase + 3 * kpiStep}ms` }}
|
|
value={
|
|
<AnimatedNumber
|
|
value={kpis.outstandingAr}
|
|
format={(n) => fmt.usd(Math.max(0, n))}
|
|
/>
|
|
}
|
|
hint={`${kpis.pending} in queue`}
|
|
/>
|
|
</DrillableCell>
|
|
<DrillableCell
|
|
onClick={() => navigate("/claims?status=denied")}
|
|
ariaLabel="View denied claims"
|
|
>
|
|
<KpiCard
|
|
label="Denial rate"
|
|
icon={TrendingDown}
|
|
accent="destructive"
|
|
sparkline={monthly.denialRate}
|
|
className="animate-fade-in-up"
|
|
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 }}
|
|
/>
|
|
</DrillableCell>
|
|
</section>
|
|
|
|
{/* Activity + Top providers */}
|
|
<section
|
|
className="grid gap-4 lg:grid-cols-3 animate-fade-in-up"
|
|
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="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground">
|
|
{activity.length} events
|
|
</span>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<ActivityFeed
|
|
items={activity.slice(0, 10)}
|
|
onItemClick={(evt) => {
|
|
// SP21 Task 2.5: route by event kind. claim_* kinds
|
|
// navigate to the matching claim drawer (URL-driven,
|
|
// drawer mounts in Phase 5); provider_added opens the
|
|
// ProviderDrawer. remit_received and any unmapped
|
|
// kind surface a "coming soon" toast so the click
|
|
// still gives feedback until the RemitDrawer lands in
|
|
// Phase 4.
|
|
const url = eventKindToUrl(evt);
|
|
if (url) navigate(url);
|
|
else
|
|
toast.info(
|
|
`Drill for ${evt.kind.replace(/_/g, " ")} coming in a later phase.`,
|
|
);
|
|
}}
|
|
/>
|
|
</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}
|
|
onClick={() =>
|
|
navigate(`/providers?provider=${encodeURIComponent(p.npi)}`)
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter")
|
|
navigate(
|
|
`/providers?provider=${encodeURIComponent(p.npi)}`
|
|
);
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`View provider ${p.name}`}
|
|
className="drillable flex items-center gap-3"
|
|
>
|
|
<div className="h-7 w-7 rounded-md bg-muted/60 ring-1 ring-inset ring-border/40 flex items-center justify-center mono text-[10.5px] text-muted-foreground">
|
|
{String(i + 1).padStart(2, "0")}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-[13px] font-medium truncate">{p.name}</div>
|
|
<div className="mono text-[10.5px] text-muted-foreground">
|
|
NPI {p.npi}
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="display mono text-[15px]">{fmt.num(p.claimCount)}</div>
|
|
<div className="mono text-[10.5px] text-muted-foreground">
|
|
{fmt.usd(p.outstandingAr)} AR
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
|
|
{topDenials.length > 0 ? (
|
|
<section
|
|
className="animate-fade-in-up"
|
|
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}
|
|
onClick={() => navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") navigate(`/claims?claim=${encodeURIComponent(c.id)}`);
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`View claim ${c.id}`}
|
|
className="drillable flex items-start gap-3 py-3 first:pt-0 last:pb-0"
|
|
>
|
|
<div className="display mono text-[12.5px] text-muted-foreground pt-0.5 w-24 shrink-0">
|
|
{c.id}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-[13px]">{c.patientName}</div>
|
|
<div className="text-[11.5px] text-muted-foreground">
|
|
{c.denialReason ?? "—"}
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<div className="display mono text-[13.5px]">
|
|
{fmt.usd(c.billedAmount)}
|
|
</div>
|
|
<div className="text-[10.5px] text-muted-foreground">
|
|
{c.payerName}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|