wip: dashboard wiring (real backend) + Docker compose for production
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 0–100. */
|
||||
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 0–100; 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
|
||||
|
||||
Reference in New Issue
Block a user