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 (
{/* 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. */}
Good {greetingPart}, {operatorName}. } subtitle={ <> Three NPIs are in flight. {kpis.pending} claims pending, clearinghouse cycle is normal. } />
Clearinghouse live
{/* KPIs — five tiles, each carrying a sparkline. Staggered fade-in on first paint for a "the instrument powers up" feel. */}
navigate("/claims")} ariaLabel="View all claims" > fmt.num(Math.round(n))} /> } hint="last 6 months" /> navigate("/claims?sort=-billedAmount")} ariaLabel="View claims sorted by billed amount" > } delta={{ value: "+12.4%", direction: "up", positive: true }} /> navigate("/claims?sort=-receivedAmount")} ariaLabel="View claims sorted by received amount" > } delta={{ value: "+8.1%", direction: "up", positive: true }} /> navigate("/claims")} ariaLabel="View pending accounts receivable claims" > fmt.usd(Math.max(0, n))} /> } hint={`${kpis.pending} in queue`} /> navigate("/claims?status=denied")} ariaLabel="View denied claims" > fmt.pct(n)} /> } delta={{ value: "-1.2 pts", direction: "down", positive: true }} />
{/* Activity + Top providers */}
Recent activity

Newest submissions, denials, and remittances across all providers.

{activity.length} events
{ // 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.`, ); }} />
Top providers

Ranked by claim volume, last 6 months.

    {topProviders.map((p, i) => (
  • 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" >
    {String(i + 1).padStart(2, "0")}
    {p.name}
    NPI {p.npi}
    {fmt.num(p.claimCount)}
    {fmt.usd(p.outstandingAr)} AR
  • ))}
{topDenials.length > 0 ? (
Recent denials

Investigate and resubmit where appropriate.

    {topDenials.map((c) => (
  • 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" >
    {c.id}
    {c.patientName}
    {c.denialReason ?? "—"}
    {fmt.usd(c.billedAmount)}
    {c.payerName}
  • ))}
) : null}
); }