wip: dashboard wiring (real backend) + Docker compose for production

This commit is contained in:
Nora
2026-06-22 14:07:31 -06:00
parent 4a382c0b16
commit 0677e4fd65
18 changed files with 1879 additions and 155 deletions
+5 -3
View File
@@ -55,9 +55,11 @@ function Sparkline({ breakdown }: { breakdown: ScoreBreakdown | null }) {
);
}
type Props =
| { row: InboxClaimRow; accent: Accent; onClick: () => void }
| { row: InboxCandidateRow; accent: Accent; onClick: () => void };
type Props = {
row: InboxClaimRow | InboxCandidateRow;
accent: Accent;
onClick: () => void;
};
export function InboxRow({ row, accent, onClick }: Props) {
const isCandidate = row.kind === "remit";
+169
View File
@@ -0,0 +1,169 @@
import { useQuery } from "@tanstack/react-query";
import { useSyncExternalStore } from "react";
import {
api,
type DashboardSummaryParams,
} from "@/lib/api";
import { useAppStore } from "@/store";
import {
type Claim,
type DashboardKpis,
type DashboardSummary,
type MonthlyBucket,
type Provider,
} from "@/types";
/**
* Aggregated Dashboard view (KPIs + 6-month sparkline + top providers +
* recent denials). One round-trip replaces the four hook calls the
* Dashboard SPA previously needed (`useClaims` for KPIs, `useClaims` for
* denials, `useProviders`, `useActivity`).
*
* Falls back to the in-memory zustand store when no backend is
* configured. The fallback computes the same shape from `sampleClaims`
* + `sampleProviders` so the Dashboard renders consistently in both
* modes.
*/
export function useDashboardSummary(params: DashboardSummaryParams = {}) {
const fallback = useSyncExternalStore(
(cb) => useAppStore.subscribe(cb),
() => useAppStore.getState(),
() => useAppStore.getState()
);
const q = useQuery<DashboardSummary>({
queryKey: ["dashboard-summary", params],
queryFn: () => api.getDashboardSummary(params),
enabled: api.isConfigured,
});
if (!api.isConfigured) {
return {
data: synthesizeFromSamples(fallback.claims, fallback.providers, params),
isLoading: false,
isError: false,
error: null,
refetch: () => Promise.resolve(),
} as const;
}
return q;
}
const MONTH_LABELS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
/**
* Mirror of the backend's `Store.get_dashboard_summary` for the sample
* fallback. Buckets in local time (matches the existing Dashboard's
* behavior pre-summary); the live backend buckets in UTC. Counts,
* sums, top providers, and denials are computed from the in-memory
* arrays exactly the same way the backend computes them from the DB.
*/
function synthesizeFromSamples(
claims: Claim[],
providers: Provider[],
params: DashboardSummaryParams,
): DashboardSummary {
const months = params.months ?? 6;
const topProvidersCap = params.topProviders ?? 4;
const denialsCap = params.denials ?? 5;
const now = new Date();
const bucketKeys: { year: number; month: number; label: string }[] = [];
for (let i = months - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
bucketKeys.push({
year: d.getFullYear(),
month: d.getMonth(),
label: MONTH_LABELS[d.getMonth()]!,
});
}
const perBucket: Record<
string,
{ count: number; billed: number; received: number; denied: number }
> = {};
for (const k of bucketKeys) {
perBucket[`${k.year}-${k.month}`] = {
count: 0, billed: 0, received: 0, denied: 0,
};
}
let billedTotal = 0;
let receivedTotal = 0;
let deniedCount = 0;
let pendingCount = 0;
const denialsList: Claim[] = [];
for (const c of claims) {
const billed = c.billedAmount ?? 0;
const received = c.receivedAmount ?? 0;
billedTotal += billed;
receivedTotal += received;
const status = (c.status ?? "").toLowerCase();
if (status === "denied") {
deniedCount += 1;
denialsList.push(c);
}
if (status === "submitted" || status === "pending") pendingCount += 1;
const sub = c.submissionDate ? new Date(c.submissionDate) : null;
if (sub) {
const key = `${sub.getFullYear()}-${sub.getMonth()}`;
if (perBucket[key]) {
const b = perBucket[key]!;
b.count += 1;
b.billed += billed;
b.received += received;
if (status === "denied") b.denied += 1;
}
}
}
let runningAr = 0;
const monthly: MonthlyBucket[] = bucketKeys.map((k) => {
const key = `${k.year}-${k.month}`;
const b = perBucket[key]!;
runningAr = Math.max(0, runningAr + b.billed - b.received);
return {
month: `${k.year}-${String(k.month + 1).padStart(2, "0")}`,
label: k.label,
count: b.count,
billed: b.billed,
received: b.received,
denied: b.denied,
ar: runningAr,
denialRate: b.count ? (b.denied / b.count) * 100 : 0,
};
});
denialsList.sort(
(a, b) =>
new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime()
);
const topProviders = [...providers]
.sort((a, b) => b.claimCount - a.claimCount)
.slice(0, topProvidersCap);
const kpis: DashboardKpis = {
claimCount: claims.length,
billedTotal,
receivedTotal,
outstandingAr: billedTotal - receivedTotal,
deniedCount,
pendingCount,
denialRate: claims.length ? (deniedCount / claims.length) * 100 : 0,
};
return {
kpis,
monthly,
topProviders,
recentDenials: denialsList.slice(0, denialsCap),
providerCount: providers.length,
asOf: now.toISOString(),
};
}
+59 -2
View File
@@ -27,6 +27,7 @@ import type {
ClaimDetail,
ClaimOutput,
ClaimPayment,
DashboardSummary,
Envelope,
FinancialInfo,
MatchResponse,
@@ -42,9 +43,27 @@ import type {
BatchSummary as ParserBatchSummary,
} from "@/types";
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
/**
* Base URL the SPA uses for API calls. Comes from `VITE_API_BASE_URL` at
* build time.
*
* Two distinct cases matter here:
*
* - **Env var absent** (undefined) → the SPA was built with no backend
* in mind. Hooks fall back to the in-memory zustand sample store so
* the UI still renders. Treat as "not configured".
*
* - **Env var present**, even set to an empty string → the deployer
* explicitly opted into "the SPA talks to a backend reachable from
* the browser". An empty value is meaningful: it's how the Docker
* frontend ([frontend/Dockerfile]) asks for same-origin `/api/*`
* calls (nginx proxies those to the backend service). Both empty
* and a full URL count as "configured".
*/
const BASE_URL_RAW = import.meta.env.VITE_API_BASE_URL as string | undefined;
const BASE_URL = BASE_URL_RAW ?? "";
export const isConfigured = BASE_URL.length > 0;
export const isConfigured = BASE_URL_RAW !== undefined;
// ---------------------------------------------------------------------------
// Shared types
@@ -124,6 +143,15 @@ export interface ListActivityParams {
limit?: number;
}
export interface DashboardSummaryParams {
/** Trailing-months window for the sparkline buckets. */
months?: number;
/** Cap on `topProviders`. */
topProviders?: number;
/** Cap on `recentDenials`. */
denials?: number;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
@@ -603,6 +631,34 @@ async function listActivity<T = unknown>(
return (await res.json()) as PaginatedResponse<T>;
}
/**
* Fetch the aggregated Dashboard view (KPIs, monthly sparkline buckets,
* top providers, recent denials) in one round-trip.
*
* Drives `GET /api/dashboard/summary`. Aggregations are server-side so
* totals stay accurate past the 1000-row cap on `/api/claims`. The
* `useDashboardSummary` hook synthesizes the same shape from the
* in-memory sample store when no backend is configured.
*/
async function getDashboardSummary(
params: DashboardSummaryParams = {}
): Promise<DashboardSummary> {
if (!isConfigured) throw notConfiguredError();
const query: Record<string, unknown> = {};
if (params.months !== undefined) query.months = params.months;
if (params.topProviders !== undefined) query.top_providers = params.topProviders;
if (params.denials !== undefined) query.denials = params.denials;
const res = await fetch(
joinUrl(`/api/dashboard/summary${qs(query)}`),
{ headers: { Accept: "application/json" } }
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
return (await res.json()) as DashboardSummary;
}
// ---------------------------------------------------------------------------
// Public surface — reconciliation endpoints (sub-project 2)
// POSTs throw `ApiError` so callers can inspect `.status`; the GET is shaped
@@ -741,6 +797,7 @@ export const api = {
getRemittance,
listProviders,
listActivity,
getDashboardSummary,
listUnmatched,
matchRemit,
unmatchClaim,
+240 -147
View File
@@ -13,93 +13,104 @@ import { PageHeader } from "@/components/PageHeader";
import { KpiCard } from "@/components/KpiCard";
import { ActivityFeed } from "@/components/ActivityFeed";
import { AnimatedNumber } from "@/components/AnimatedNumber";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { fmt } from "@/lib/format";
import { useAppStore } from "@/store";
import { useDashboardSummary } from "@/hooks/useDashboardSummary";
import { useActivity } from "@/hooks/useActivity";
import type { MonthlyBucket } from "@/types";
const MONTHS_BACK = 6;
function greetingForHour(hour: number): string {
if (hour < 12) return "Good morning.";
if (hour < 18) return "Good afternoon.";
return "Good evening.";
}
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;
}
let running = 0;
const ar: number[] = [];
for (const m of months) {
running += m.billed - m.received;
ar.push(Math.max(0, running));
}
interface Delta {
value: string;
direction: "up" | "down";
positive: boolean;
}
/**
* Percent change from `prev` to `curr`. Returns undefined when the prior
* period is zero (no meaningful baseline).
*/
function pctDelta(curr: number, prev: number): Delta | undefined {
if (prev === 0) return undefined;
const change = ((curr - prev) / prev) * 100;
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)),
value: `${change >= 0 ? "+" : ""}${change.toFixed(1)}%`,
direction: change >= 0 ? "up" : "down",
positive: change >= 0,
};
}
/**
* Absolute percentage-point change from `prev` to `curr`. For metrics
* where "down is good" (denial rate), `positive: true` means the change
* is favorable.
*/
function ptsDelta(curr: number, prev: number, lowerIsBetter: boolean): Delta | undefined {
const change = curr - prev;
return {
value: `${change >= 0 ? "+" : ""}${change.toFixed(1)} pts`,
direction: change >= 0 ? "up" : "down",
positive: lowerIsBetter ? change <= 0 : change >= 0,
};
}
function bucketField<T extends keyof MonthlyBucket>(
monthly: MonthlyBucket[],
key: T
): MonthlyBucket[T][] {
return monthly.map((m) => m[key]);
}
export function Dashboard() {
const claims = useAppStore((s) => s.claims);
const providers = useAppStore((s) => s.providers);
const activity = useAppStore((s) => s.activity);
const summaryQ = useDashboardSummary({
months: 6,
topProviders: 4,
denials: 5,
});
// Activity has its own 30s polling cadence (see useActivity); keep it
// separate so the summary's single fetch doesn't gate activity freshness.
const activityQ = useActivity({ limit: 10 });
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 summary = summaryQ.data;
const kpis = summary?.kpis;
const monthly = summary?.monthly ?? [];
const topProviders = summary?.topProviders ?? [];
const denials = summary?.recentDenials ?? [];
const providerCount = summary?.providerCount ?? 0;
const activity = activityQ.data?.items ?? [];
const monthly = useMemo(() => buildMonthly(claims), [claims]);
const isError = summaryQ.isError || activityQ.isError;
const firstError = summaryQ.error ?? activityQ.error;
const topProviders = useMemo(
() => [...providers].sort((a, b) => b.claimCount - a.claimCount).slice(0, 4),
[providers]
);
const refetchAll = () => {
void summaryQ.refetch();
void activityQ.refetch();
};
const topDenials = useMemo(
() => claims.filter((c) => c.status === "denied").slice(0, 5),
[claims]
);
// "This month vs. last month" deltas from the sparkline series.
// With fewer than two months of data we suppress the delta entirely
// (KpiCard handles a missing delta gracefully).
const lastIdx = monthly.length - 1;
const prevIdx = lastIdx - 1;
const billedDelta =
prevIdx >= 0 && kpis
? pctDelta(monthly[lastIdx]!.billed, monthly[prevIdx]!.billed)
: undefined;
const receivedDelta =
prevIdx >= 0 && kpis
? pctDelta(monthly[lastIdx]!.received, monthly[prevIdx]!.received)
: undefined;
const denialDelta =
prevIdx >= 0 && kpis
? ptsDelta(monthly[lastIdx]!.denialRate, monthly[prevIdx]!.denialRate, true)
: undefined;
// Stagger choreography — the hero lands first, then the KPIs in
// a left-to-right wave, then the supporting cards. Total
@@ -109,8 +120,19 @@ export function Dashboard() {
const kpiStep = 60;
const sectionBase = kpiBase + kpiStep * 5 + 80;
const greeting = useMemo(() => greetingForHour(new Date().getHours()), []);
const summaryLoaded = summaryQ.data !== undefined;
return (
<div className="space-y-6 lg:space-y-10">
{isError ? (
<ErrorState
message="Couldn't load dashboard data from the backend."
detail={firstError instanceof Error ? firstError.message : String(firstError)}
onRetry={refetchAll}
/>
) : null}
{/* 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. */}
@@ -128,20 +150,27 @@ export function Dashboard() {
lineHeight: 1,
}}
>
{fmt.usd(kpis.billed)}
{fmt.usd(kpis?.billedTotal ?? 0)}
</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 morning, Jordan.</>}
title={<>{greeting}</>}
subtitle={
<>
<span className="display text-foreground">Three NPIs</span> are
in flight. {kpis.pending} claims pending, clearinghouse cycle
is normal.
</>
summaryLoaded ? (
<>
<span className="display text-foreground">
{providerCount} NPI{providerCount === 1 ? "" : "s"}
</span>{" "}
{providerCount === 1 ? "is" : "are"} in flight.{" "}
{kpis?.pendingCount ?? 0} claims pending, clearinghouse
cycle is normal.
</>
) : (
<>Pulling the latest from the clearinghouse</>
)
}
/>
</div>
@@ -165,11 +194,18 @@ export function Dashboard() {
<KpiCard
label="Claims"
icon={Receipt}
sparkline={monthly.count}
sparkline={bucketField(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))} />
summaryQ.isLoading ? (
<Skeleton variant="text" className="h-8 w-20" />
) : (
<AnimatedNumber
value={kpis?.claimCount ?? 0}
format={(n) => fmt.num(Math.round(n))}
/>
)
}
hint="last 6 months"
/>
@@ -177,51 +213,71 @@ export function Dashboard() {
label="Billed"
icon={CircleDollarSign}
accent="accent"
sparkline={monthly.billed}
sparkline={bucketField(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 }}
value={
summaryQ.isLoading ? (
<Skeleton variant="text" className="h-8 w-28" />
) : (
<AnimatedNumber value={kpis?.billedTotal ?? 0} format={fmt.usd} />
)
}
delta={billedDelta}
/>
<KpiCard
label="Received"
icon={Banknote}
accent="success"
sparkline={monthly.received}
sparkline={bucketField(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 }}
value={
summaryQ.isLoading ? (
<Skeleton variant="text" className="h-8 w-28" />
) : (
<AnimatedNumber value={kpis?.receivedTotal ?? 0} format={fmt.usd} />
)
}
delta={receivedDelta}
/>
<KpiCard
label="Pending AR"
icon={Clock}
accent="warning"
sparkline={monthly.ar}
sparkline={bucketField(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))}
/>
summaryQ.isLoading ? (
<Skeleton variant="text" className="h-8 w-28" />
) : (
<AnimatedNumber
value={kpis?.outstandingAr ?? 0}
format={(n) => fmt.usd(Math.max(0, n))}
/>
)
}
hint={`${kpis.pending} in queue`}
hint={`${kpis?.pendingCount ?? 0} in queue`}
/>
<KpiCard
label="Denial rate"
icon={TrendingDown}
accent="destructive"
sparkline={monthly.denialRate}
sparkline={bucketField(monthly, "denialRate")}
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 4 * kpiStep}ms` }}
value={
<AnimatedNumber
value={kpis.denialRate}
format={(n) => fmt.pct(n)}
/>
summaryQ.isLoading ? (
<Skeleton variant="text" className="h-8 w-16" />
) : (
<AnimatedNumber
value={kpis?.denialRate ?? 0}
format={(n) => fmt.pct(n)}
/>
)
}
delta={{ value: "-1.2 pts", direction: "down", positive: true }}
delta={denialDelta}
/>
</section>
@@ -246,7 +302,20 @@ export function Dashboard() {
</span>
</CardHeader>
<CardContent className="pt-0">
<ActivityFeed items={activity.slice(0, 10)} />
{activityQ.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : activity.length === 0 ? (
<EmptyState
eyebrow="Activity · log idle"
message="Activity will appear here after the first parse."
/>
) : (
<ActivityFeed items={activity.slice(0, 10)} />
)}
</CardContent>
</Card>
@@ -258,52 +327,76 @@ export function Dashboard() {
</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 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}
{summaryQ.isLoading ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : topProviders.length === 0 ? (
<EmptyState
eyebrow="Providers · directory empty"
message="Providers appear here as soon as an 837P file is parsed."
/>
) : (
<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 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>
<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 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>
</li>
))}
</ul>
<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">
<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">
{summaryQ.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : denials.length === 0 ? (
<EmptyState
eyebrow="Denials · none in window"
message="No denied claims in the latest batch."
/>
) : (
<ul className="divide-y divide-border/40">
{topDenials.map((c) => (
{denials.map((c) => (
<li
key={c.id}
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
@@ -328,10 +421,10 @@ export function Dashboard() {
</li>
))}
</ul>
</CardContent>
</Card>
</section>
) : null}
)}
</CardContent>
</Card>
</section>
</div>
);
}
}
+49
View File
@@ -105,6 +105,55 @@ export interface Activity {
amount?: number;
}
// ---------------------------------------------------------------------------
// Dashboard summary surface (SP10)
//
// Aggregated view of the whole clearinghouse, returned by
// `GET /api/dashboard/summary`. The shape is server-stable; the same
// shape is synthesized by `useDashboardSummary` when no backend is
// configured (see `sampleClaims` / `sampleProviders`).
// ---------------------------------------------------------------------------
export interface DashboardKpis {
claimCount: number;
billedTotal: number;
receivedTotal: number;
outstandingAr: number;
deniedCount: number;
pendingCount: number;
/** Percentage 0100. */
denialRate: number;
}
export interface MonthlyBucket {
/** YYYY-MM (UTC). */
month: string;
/** English short label, e.g. "Jan". */
label: string;
count: number;
billed: number;
received: number;
denied: number;
/** Cumulative billed cumulative received, clamped at 0. */
ar: number;
/** Percentage 0100; 0 when count is 0. */
denialRate: number;
}
export interface DashboardSummary {
kpis: DashboardKpis;
/** Oldest → newest, sized to the requested `months` window. */
monthly: MonthlyBucket[];
/** Top N by claim count (descending). */
topProviders: Provider[];
/** Latest N denied claims, newest first. */
recentDenials: Claim[];
/** Distinct provider count across the whole store — independent of topProviders cap. */
providerCount: number;
/** UTC ISO timestamp the aggregation was computed at. */
asOf: string;
}
// ---------------------------------------------------------------------------
// Backend-aligned types (snake_case to match FastAPI JSON output 1:1).
// These mirror the Pydantic models in