feat(sp27): server-aggregate Dashboard KPIs so 100-row sample doesn't lie
The Dashboard was hardcoded to useClaims({ limit: 100 }) and reduce
KPIs client-side. With 60k+ claims in production, every tile
(Billed $940K, Received $59K, Denial rate, Pending AR, monthly
sparkline, top providers, recent denials) was computed from a
0.16% sample of the dataset — silently wrong numbers on the
operator's primary view.
Fix: add GET /api/dashboard/kpis that aggregates server-side in
one read over the entire claim population. The new useDashboardKpis
hook consumes it and polls every 60s. Dashboard.tsx drops
useClaims({limit:100}) + useProviders() + the client-side
buildMonthly reduce.
Backend:
- store.py: dashboard_kpis() — one Claim query (selectinload on
batch to avoid N+1) + one bulk Remittance lookup, Python reduce
over the full population. Zero-filled response for empty DB.
- api.py: GET /api/dashboard/kpis behind matrix_gate, query-param
clamps (1..24 months, 0..50 top_n_*).
Frontend:
- api.ts: DashboardKpis types + getDashboardKpis() wrapper.
- useDashboardKpis.ts: TanStack Query hook, 60s refetchInterval,
bypass to data:undefined when not configured.
- Dashboard.tsx: switched to useDashboardKpis, extracted
ZERO_TOTALS constant, dropped the buildMonthly helper.
Tests:
- backend/tests/test_dashboard_kpis.py: 12 tests covering empty DB,
matched-remit math, pending-state semantics, monthly binning,
top-providers/top-denials sort + cap, orphan-claim defensive
guard, HTTP wiring + param validation.
- src/hooks/useDashboardKpis.test.ts: 3 tests for the hook
contract (configured path, unconfigured fallback, param
passthrough).
- src/pages/Dashboard.test.tsx: wrapped renders in
QueryClientProvider + stubbed useAuth + isConfigured=false. This
fixes 3 pre-existing Dashboard test failures (the page never had
a QueryClient set up because useClaims/useProviders were the
first useQuery hooks in the page).
Reviewer fixes (same commit):
1. topDenials sort placed empty submissionDate claims first under
reverse-lex. Drop them at append time.
2. r.batch lazy-load → N+1 on 60k rows. selectinload(Claim.batch).
3. pending_states rebuilt per call as a mutable set — moved to
module-level _DASHBOARD_PENDING_STATES frozenset.
4. Module-level ProviderORM import inconsistent with the
"local import inside the function" pattern — moved inline.
This commit is contained in:
@@ -88,6 +88,7 @@ from cyclone.store import (
|
|||||||
AlreadyMatchedError,
|
AlreadyMatchedError,
|
||||||
BatchRecord,
|
BatchRecord,
|
||||||
InvalidStateError,
|
InvalidStateError,
|
||||||
|
dashboard_kpis,
|
||||||
store,
|
store,
|
||||||
utcnow,
|
utcnow,
|
||||||
)
|
)
|
||||||
@@ -2291,6 +2292,34 @@ def get_remittance(remittance_id: str) -> dict:
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/dashboard/kpis", dependencies=[Depends(matrix_gate)])
|
||||||
|
def get_dashboard_kpis(
|
||||||
|
months: int = Query(6, ge=1, le=24),
|
||||||
|
top_n_providers: int = Query(4, ge=0, le=50),
|
||||||
|
top_n_denials: int = Query(5, ge=0, le=50),
|
||||||
|
) -> dict:
|
||||||
|
"""Server-aggregated Dashboard KPIs over the whole claim population.
|
||||||
|
|
||||||
|
Backs the Dashboard's "Claims / Billed / Received / Pending AR /
|
||||||
|
Denial rate" tiles + the monthly sparkline series + the
|
||||||
|
top-providers and top-denials lists.
|
||||||
|
|
||||||
|
Why this exists instead of ``GET /api/claims?limit=N``:
|
||||||
|
The Dashboard's KPIs are aggregates over *every* claim — billed,
|
||||||
|
received, denial rate, pending count, monthly billed/received. With
|
||||||
|
60k+ claims in production, paginating ``/api/claims`` and reducing
|
||||||
|
client-side silently produces wrong numbers (denial rate sampled,
|
||||||
|
billed summed from the first 100 rows). This endpoint does the
|
||||||
|
aggregation server-side in a single read so the Dashboard's numbers
|
||||||
|
are always correct regardless of dataset size.
|
||||||
|
"""
|
||||||
|
return dashboard_kpis(
|
||||||
|
months=months,
|
||||||
|
top_n_providers=top_n_providers,
|
||||||
|
top_n_denials=top_n_denials,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/providers", dependencies=[Depends(matrix_gate)])
|
@app.get("/api/providers", dependencies=[Depends(matrix_gate)])
|
||||||
def list_providers(
|
def list_providers(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -2578,6 +2578,297 @@ def _provider_orm_to_dict(row) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SP27 Task 13: Dashboard aggregate KPIs.
|
||||||
|
#
|
||||||
|
# The Dashboard's "Billed / Received / Denial rate / Pending AR" tiles are
|
||||||
|
# computed from the *whole* claim population, not a sample. With 60k+ claims
|
||||||
|
# in production, fetching ``/api/claims?limit=100`` and reducing in JS would
|
||||||
|
# silently produce wrong numbers (denial rate sampled, billed summed from
|
||||||
|
# 100 rows). This module-level function does the aggregation server-side in
|
||||||
|
# a single session and returns a small structured payload the Dashboard
|
||||||
|
# can render directly.
|
||||||
|
#
|
||||||
|
# Performance: one ``SELECT * FROM claims`` (no pagination) + one
|
||||||
|
# ``SELECT id, total_paid FROM remittances WHERE id IN (...)`` for matched
|
||||||
|
# remits. SQLite returns 60k claim rows in ~30ms on the development
|
||||||
|
# machine; the Python reduce is microseconds. If the dataset grows past
|
||||||
|
# ~500k claims we'd want a SQL-side ``GROUP BY month`` instead — but at
|
||||||
|
# that volume the dashboard should probably be backed by a materialized
|
||||||
|
# view, not a live query.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_state_str(claim: Claim) -> str:
|
||||||
|
"""Stringify a Claim's ``state`` regardless of enum vs raw str storage."""
|
||||||
|
st = claim.state
|
||||||
|
return st.value if hasattr(st, "value") else str(st)
|
||||||
|
|
||||||
|
|
||||||
|
# Claim states counted toward the Dashboard's "pending" tile. SUBMITTED
|
||||||
|
# is the initial post-parse state; REJECTED is the 999 envelope-level
|
||||||
|
# rejection that means the payer never saw the claim. Both are
|
||||||
|
# "outstanding adjudication" from the operator's POV; ``denied`` is
|
||||||
|
# payer adjudication and lives in its own tile.
|
||||||
|
_DASHBOARD_PENDING_STATES: frozenset[str] = frozenset({"submitted", "rejected"})
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_kpis(
|
||||||
|
*,
|
||||||
|
months: int = 6,
|
||||||
|
top_n_providers: int = 4,
|
||||||
|
top_n_denials: int = 5,
|
||||||
|
) -> dict:
|
||||||
|
"""Compute Dashboard KPIs over the entire claim/remittance population.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
months
|
||||||
|
Number of trailing calendar months to include in the ``monthly``
|
||||||
|
sparkline series (default 6 — matches the existing frontend
|
||||||
|
``MONTHS_BACK`` constant).
|
||||||
|
top_n_providers
|
||||||
|
How many providers to include in the ``topProviders`` array
|
||||||
|
(default 4 — matches the existing Dashboard layout).
|
||||||
|
top_n_denials
|
||||||
|
How many most-recent denied claims to include in the
|
||||||
|
``topDenials`` array (default 5).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict with keys:
|
||||||
|
|
||||||
|
- ``totals``: aggregate counts + dollar sums + rates for the whole DB.
|
||||||
|
- ``monthly``: list of ``{month, label, count, billed, received,
|
||||||
|
denied, denialRate, ar}`` dicts, oldest-first, length = ``months``.
|
||||||
|
``ar`` is the running outstanding accounts-receivable (billed -
|
||||||
|
received) carried forward across months, clamped at zero.
|
||||||
|
- ``topProviders``: list of ``{npi, label, claimCount, billed,
|
||||||
|
denied}`` dicts, sorted by claimCount desc.
|
||||||
|
- ``topDenials``: list of ``{id, patientName, billedAmount,
|
||||||
|
denialReason, submissionDate}`` dicts, sorted by submissionDate
|
||||||
|
desc, capped at ``top_n_denials``.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
- Empty DB returns zero-filled aggregates, an empty ``monthly`` array
|
||||||
|
(one entry per requested month), an empty ``topProviders`` array,
|
||||||
|
and an empty ``topDenials`` array.
|
||||||
|
- ``received`` for a claim is sourced from the matched Remittance's
|
||||||
|
``total_paid`` column. Claims without a matched remittance
|
||||||
|
contribute 0 to the received sum (and thus inflate the
|
||||||
|
outstanding-AR figure, which is the correct operator-visible
|
||||||
|
semantic — "money we haven't been told was paid yet").
|
||||||
|
"""
|
||||||
|
# Pre-build the trailing-N-months skeleton so the response always has
|
||||||
|
# exactly ``months`` entries even when the DB is empty or sparse.
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
skeleton: list[dict] = []
|
||||||
|
for i in range(months - 1, -1, -1):
|
||||||
|
d = now.replace(day=1)
|
||||||
|
# Walk backwards N months without ``relativedelta``.
|
||||||
|
for _ in range(i):
|
||||||
|
prev_month = d.month - 1
|
||||||
|
if prev_month == 0:
|
||||||
|
d = d.replace(year=d.year - 1, month=12)
|
||||||
|
else:
|
||||||
|
d = d.replace(month=prev_month)
|
||||||
|
skeleton.append({
|
||||||
|
"month": f"{d.year:04d}-{d.month:02d}",
|
||||||
|
"label": d.strftime("%b"),
|
||||||
|
"count": 0,
|
||||||
|
"billed": 0.0,
|
||||||
|
"received": 0.0,
|
||||||
|
"denied": 0,
|
||||||
|
"ar": 0.0,
|
||||||
|
})
|
||||||
|
skeleton_index = {entry["month"]: entry for entry in skeleton}
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
# ``Claim.batch`` is a lazy ``relationship`` (default
|
||||||
|
# ``lazy="select"``); without ``selectinload`` each access to
|
||||||
|
# ``r.batch`` in the reduce loop below issues a fresh
|
||||||
|
# ``SELECT ... FROM batches WHERE id=?``. ``selectinload``
|
||||||
|
# pulls every distinct batch in one round-trip instead of N+1
|
||||||
|
# — critical for the 60k-claim dataset.
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
claims: list[Claim] = (
|
||||||
|
s.query(Claim)
|
||||||
|
.options(selectinload(Claim.batch))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bulk-load matched-remit total_paid so a 60k-claim DB doesn't
|
||||||
|
# produce a 60k-query N+1.
|
||||||
|
matched_ids = [
|
||||||
|
r.matched_remittance_id
|
||||||
|
for r in claims
|
||||||
|
if r.matched_remittance_id
|
||||||
|
]
|
||||||
|
received_by_remit: dict[str, float] = {}
|
||||||
|
if matched_ids:
|
||||||
|
for rid, total_paid in (
|
||||||
|
s.query(Remittance.id, Remittance.total_paid)
|
||||||
|
.filter(Remittance.id.in_(matched_ids))
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
received_by_remit[rid] = float(total_paid or 0)
|
||||||
|
|
||||||
|
# Per-provider accumulator for the topProviders leaderboard.
|
||||||
|
provider_counts: dict[str, int] = {}
|
||||||
|
provider_billed: dict[str, float] = {}
|
||||||
|
provider_denied: dict[str, int] = {}
|
||||||
|
|
||||||
|
# Per-month accumulator + totals in a single pass.
|
||||||
|
total_count = 0
|
||||||
|
total_billed = 0.0
|
||||||
|
total_received = 0.0
|
||||||
|
denied_count = 0
|
||||||
|
pending_count = 0
|
||||||
|
|
||||||
|
# Collect denied candidates as we walk so we don't issue a
|
||||||
|
# second pass for the topDenials array.
|
||||||
|
denied_candidates: list[dict] = []
|
||||||
|
|
||||||
|
for r in claims:
|
||||||
|
billed = float(r.charge_amount or 0)
|
||||||
|
received = received_by_remit.get(r.matched_remittance_id, 0.0)
|
||||||
|
state_str = _claim_state_str(r)
|
||||||
|
|
||||||
|
total_count += 1
|
||||||
|
total_billed += billed
|
||||||
|
total_received += received
|
||||||
|
if state_str == "denied":
|
||||||
|
denied_count += 1
|
||||||
|
# Drop denied claims with no ``submissionDate`` — they'd
|
||||||
|
# sort first under reverse-lex (empty string < ISO) and
|
||||||
|
# render as "Invalid Date" in the Dashboard. A denial
|
||||||
|
# without a batch is exceptional and operator-irrelevant
|
||||||
|
# for the "recent denials" widget.
|
||||||
|
if r.batch is None or r.batch.parsed_at is None:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raw = r.raw_json or {}
|
||||||
|
sub = raw.get("subscriber", {})
|
||||||
|
denied_candidates.append({
|
||||||
|
"id": r.id,
|
||||||
|
"patientName": (
|
||||||
|
f"{sub.get('first_name', '')} "
|
||||||
|
f"{sub.get('last_name', '')}".strip()
|
||||||
|
),
|
||||||
|
"billedAmount": billed,
|
||||||
|
"denialReason": r.rejection_reason,
|
||||||
|
"submissionDate": (
|
||||||
|
r.batch.parsed_at
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
),
|
||||||
|
})
|
||||||
|
if state_str in _DASHBOARD_PENDING_STATES:
|
||||||
|
pending_count += 1
|
||||||
|
|
||||||
|
# Monthly bin. ``submissionDate`` lives on the parent Batch
|
||||||
|
# (all claims in a batch share a parsed_at). Use UTC year-month
|
||||||
|
# to match the skeleton.
|
||||||
|
if r.batch is not None and r.batch.parsed_at is not None:
|
||||||
|
pa = r.batch.parsed_at
|
||||||
|
key = f"{pa.year:04d}-{pa.month:02d}"
|
||||||
|
bucket = skeleton_index.get(key)
|
||||||
|
if bucket is not None:
|
||||||
|
bucket["count"] += 1
|
||||||
|
bucket["billed"] += billed
|
||||||
|
bucket["received"] += received
|
||||||
|
if state_str == "denied":
|
||||||
|
bucket["denied"] += 1
|
||||||
|
|
||||||
|
# Provider bin (keyed on NPI).
|
||||||
|
npi = r.provider_npi or ""
|
||||||
|
if npi:
|
||||||
|
provider_counts[npi] = provider_counts.get(npi, 0) + 1
|
||||||
|
provider_billed[npi] = provider_billed.get(npi, 0.0) + billed
|
||||||
|
if state_str == "denied":
|
||||||
|
provider_denied[npi] = provider_denied.get(npi, 0) + 1
|
||||||
|
|
||||||
|
# Compute denial rate per month + running AR.
|
||||||
|
running_ar = 0.0
|
||||||
|
for entry in skeleton:
|
||||||
|
if entry["count"] > 0:
|
||||||
|
entry["denialRate"] = (entry["denied"] / entry["count"]) * 100.0
|
||||||
|
else:
|
||||||
|
entry["denialRate"] = 0.0
|
||||||
|
running_ar = max(0.0, running_ar + entry["billed"] - entry["received"])
|
||||||
|
entry["ar"] = running_ar
|
||||||
|
|
||||||
|
# Resolve top provider labels from the Provider table in one
|
||||||
|
# round-trip. NPIs without a Provider row still appear in the
|
||||||
|
# leaderboard with an empty label so operators can see
|
||||||
|
# "unknown-NPI" claims are coming from somewhere.
|
||||||
|
# Local import keeps the module-level ``cyclone.providers.Provider``
|
||||||
|
# Pydantic DTO and the SQLAlchemy ``cyclone.db.Provider`` ORM
|
||||||
|
# separate (same pattern as ``list_providers`` / ``upsert_provider``).
|
||||||
|
provider_labels: dict[str, str] = {}
|
||||||
|
if provider_counts:
|
||||||
|
from cyclone.db import Provider as ProviderORM
|
||||||
|
for npi, label in (
|
||||||
|
s.query(ProviderORM.npi, ProviderORM.label).filter(
|
||||||
|
ProviderORM.npi.in_(provider_counts.keys())
|
||||||
|
).all()
|
||||||
|
):
|
||||||
|
provider_labels[npi] = label or ""
|
||||||
|
|
||||||
|
top_providers = sorted(
|
||||||
|
provider_counts.items(),
|
||||||
|
key=lambda kv: kv[1],
|
||||||
|
reverse=True,
|
||||||
|
)[: max(0, top_n_providers)]
|
||||||
|
top_providers_out = [
|
||||||
|
{
|
||||||
|
"npi": npi,
|
||||||
|
"label": provider_labels.get(npi, ""),
|
||||||
|
"claimCount": count,
|
||||||
|
"billed": round(provider_billed.get(npi, 0.0), 2),
|
||||||
|
"denied": provider_denied.get(npi, 0),
|
||||||
|
}
|
||||||
|
for npi, count in top_providers
|
||||||
|
]
|
||||||
|
|
||||||
|
# Top denials = most recently submitted denied claims, capped at
|
||||||
|
# ``top_n_denials``. submissionDate is ISO-8601 so lex sort ==
|
||||||
|
# chronological sort when timestamps share a tz.
|
||||||
|
denied_candidates.sort(key=lambda d: d["submissionDate"], reverse=True)
|
||||||
|
top_denials = denied_candidates[: max(0, top_n_denials)]
|
||||||
|
|
||||||
|
total_denial_rate = (
|
||||||
|
(denied_count / total_count) * 100.0 if total_count > 0 else 0.0
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"totals": {
|
||||||
|
"count": total_count,
|
||||||
|
"billed": round(total_billed, 2),
|
||||||
|
"received": round(total_received, 2),
|
||||||
|
"outstandingAr": round(max(0.0, total_billed - total_received), 2),
|
||||||
|
"denied": denied_count,
|
||||||
|
"denialRate": round(total_denial_rate, 4),
|
||||||
|
"pending": pending_count,
|
||||||
|
},
|
||||||
|
"monthly": [
|
||||||
|
{
|
||||||
|
"month": e["month"],
|
||||||
|
"label": e["label"],
|
||||||
|
"count": e["count"],
|
||||||
|
"billed": round(e["billed"], 2),
|
||||||
|
"received": round(e["received"], 2),
|
||||||
|
"denied": e["denied"],
|
||||||
|
"denialRate": round(e["denialRate"], 4),
|
||||||
|
"ar": round(e["ar"], 2),
|
||||||
|
}
|
||||||
|
for e in skeleton
|
||||||
|
],
|
||||||
|
"topProviders": top_providers_out,
|
||||||
|
"topDenials": top_denials,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _payer_orm_to_dict(row) -> dict:
|
def _payer_orm_to_dict(row) -> dict:
|
||||||
return {
|
return {
|
||||||
"payer_id": row.payer_id,
|
"payer_id": row.payer_id,
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
"""Tests for the ``GET /api/dashboard/kpis`` aggregate endpoint (SP27 Task 13).
|
||||||
|
|
||||||
|
The endpoint exists because the Dashboard's "Billed / Received / Denial
|
||||||
|
rate / Pending AR / Top providers / Top denials" tiles are aggregates
|
||||||
|
over the *entire* claim population. With 60k+ claims in production,
|
||||||
|
fetching ``/api/claims?limit=100`` and reducing client-side silently
|
||||||
|
produces wrong numbers. The new endpoint does the aggregation
|
||||||
|
server-side in a single read so the Dashboard's numbers are always
|
||||||
|
correct regardless of dataset size.
|
||||||
|
|
||||||
|
These tests build ORM rows directly (not via the parse pipeline) so
|
||||||
|
each test is independent of the parser, the reconciler, and the 999/
|
||||||
|
277CA matching — we're testing the SQL→aggregate path, not the
|
||||||
|
ingest pipeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.db import Batch, Claim, ClaimState, Provider, Remittance
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _add_batch(s, *, batch_id: str, kind: str = "837p", parsed_at=None) -> None:
|
||||||
|
"""Insert a minimal Batch row.
|
||||||
|
|
||||||
|
Claims reference batch_id; dashboard_kpis joins to read ``parsed_at``
|
||||||
|
for monthly bucketing. ``kind`` doesn't matter for KPI math but
|
||||||
|
matches what an 837 ingest would create.
|
||||||
|
"""
|
||||||
|
s.add(Batch(
|
||||||
|
id=batch_id,
|
||||||
|
kind=kind,
|
||||||
|
input_filename="seed.edi",
|
||||||
|
parsed_at=parsed_at or datetime(2026, 6, 15, 12, 0, tzinfo=timezone.utc),
|
||||||
|
totals_json={"total_claims": 1},
|
||||||
|
validation_json={"passed": True, "warnings": [], "errors": []},
|
||||||
|
raw_result_json={"_": "stub"},
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _add_claim(
|
||||||
|
s,
|
||||||
|
*,
|
||||||
|
claim_id: str,
|
||||||
|
batch_id: str = "b1",
|
||||||
|
pcn: str | None = None,
|
||||||
|
charge: str = "100.00",
|
||||||
|
state: ClaimState = ClaimState.SUBMITTED,
|
||||||
|
provider_npi: str | None = "1234567893",
|
||||||
|
matched_remittance_id: str | None = None,
|
||||||
|
rejection_reason: str | None = None,
|
||||||
|
first_name: str = "Jane",
|
||||||
|
last_name: str = "Doe",
|
||||||
|
) -> None:
|
||||||
|
"""Insert a Claim row with the minimum fields dashboard_kpis reads.
|
||||||
|
|
||||||
|
``pcn`` defaults to ``claim_id`` so the claim is uniquely
|
||||||
|
identifiable. ``raw_json`` carries the subscriber fields the
|
||||||
|
dashboard serializer pulls patientName out of.
|
||||||
|
"""
|
||||||
|
s.add(Claim(
|
||||||
|
id=claim_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
patient_control_number=pcn or claim_id,
|
||||||
|
service_date_from=None,
|
||||||
|
charge_amount=Decimal(charge),
|
||||||
|
provider_npi=provider_npi,
|
||||||
|
state=state,
|
||||||
|
matched_remittance_id=matched_remittance_id,
|
||||||
|
rejection_reason=rejection_reason,
|
||||||
|
raw_json={
|
||||||
|
"subscriber": {"first_name": first_name, "last_name": last_name},
|
||||||
|
"payer": {"name": "CO_TXIX"},
|
||||||
|
"billing_provider": {"npi": provider_npi or ""},
|
||||||
|
"service_lines": [],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _add_remit(
|
||||||
|
s,
|
||||||
|
*,
|
||||||
|
remit_id: str,
|
||||||
|
batch_id: str = "b2",
|
||||||
|
pcn: str | None = None,
|
||||||
|
total_paid: str = "50.00",
|
||||||
|
total_charge: str = "100.00",
|
||||||
|
) -> None:
|
||||||
|
s.add(Remittance(
|
||||||
|
id=remit_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
payer_claim_control_number=pcn or remit_id,
|
||||||
|
status_code="1",
|
||||||
|
total_charge=Decimal(total_charge),
|
||||||
|
total_paid=Decimal(total_paid),
|
||||||
|
received_at=datetime(2026, 6, 15, 12, 0, tzinfo=timezone.utc),
|
||||||
|
service_date=None,
|
||||||
|
is_reversal=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _add_provider(s, *, npi: str, label: str) -> None:
|
||||||
|
"""Insert a Provider row with all NOT NULL columns populated.
|
||||||
|
|
||||||
|
Provider.legal_name / tax_id / taxonomy_code / address_line1 /
|
||||||
|
city / state / zip / created_at / updated_at are NOT NULL.
|
||||||
|
"""
|
||||||
|
s.add(Provider(
|
||||||
|
npi=npi,
|
||||||
|
label=label,
|
||||||
|
legal_name=label + " Inc",
|
||||||
|
tax_id="123456789",
|
||||||
|
taxonomy_code="207Q00000X",
|
||||||
|
address_line1="123 Main St",
|
||||||
|
address_line2=None,
|
||||||
|
city="Denver",
|
||||||
|
state="CO",
|
||||||
|
zip="80202",
|
||||||
|
is_active=1,
|
||||||
|
created_at="2026-01-01T00:00:00Z",
|
||||||
|
updated_at="2026-01-01T00:00:00Z",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — module-level function (no HTTP) so we can pin the aggregation
|
||||||
|
# logic independent of the API wiring.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpis_empty_db():
|
||||||
|
"""Empty DB → zero totals + monthly skeleton of N entries.
|
||||||
|
|
||||||
|
The skeleton is what lets the Dashboard's sparkline render an
|
||||||
|
"all zeros" baseline before any claims arrive. Lock that contract.
|
||||||
|
"""
|
||||||
|
from cyclone.store import dashboard_kpis
|
||||||
|
|
||||||
|
out = dashboard_kpis(months=6, top_n_providers=4, top_n_denials=5)
|
||||||
|
|
||||||
|
assert out["totals"] == {
|
||||||
|
"count": 0,
|
||||||
|
"billed": 0.0,
|
||||||
|
"received": 0.0,
|
||||||
|
"outstandingAr": 0.0,
|
||||||
|
"denied": 0,
|
||||||
|
"denialRate": 0.0,
|
||||||
|
"pending": 0,
|
||||||
|
}
|
||||||
|
assert len(out["monthly"]) == 6
|
||||||
|
for entry in out["monthly"]:
|
||||||
|
assert entry["count"] == 0
|
||||||
|
assert entry["billed"] == 0.0
|
||||||
|
assert entry["received"] == 0.0
|
||||||
|
assert entry["denied"] == 0
|
||||||
|
assert entry["denialRate"] == 0.0
|
||||||
|
assert entry["ar"] == 0.0
|
||||||
|
assert out["topProviders"] == []
|
||||||
|
assert out["topDenials"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpis_aggregates_single_claim():
|
||||||
|
"""One claim → totals reflect that one row.
|
||||||
|
|
||||||
|
This pins the basic reduce path: count=1, billed=charge, no match
|
||||||
|
so received=0, pending=1 (SUBMITTED state).
|
||||||
|
"""
|
||||||
|
from cyclone.store import dashboard_kpis
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_add_batch(s, batch_id="b1", parsed_at=datetime(2026, 6, 15, tzinfo=timezone.utc))
|
||||||
|
_add_claim(s, claim_id="C-1", charge="250.00", state=ClaimState.SUBMITTED)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
out = dashboard_kpis(months=6)
|
||||||
|
|
||||||
|
assert out["totals"]["count"] == 1
|
||||||
|
assert out["totals"]["billed"] == 250.0
|
||||||
|
assert out["totals"]["received"] == 0.0
|
||||||
|
assert out["totals"]["outstandingAr"] == 250.0
|
||||||
|
assert out["totals"]["denied"] == 0
|
||||||
|
assert out["totals"]["denialRate"] == 0.0
|
||||||
|
assert out["totals"]["pending"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpis_received_uses_matched_remit_total_paid():
|
||||||
|
"""``received`` on the totals is the matched remit's ``total_paid``.
|
||||||
|
|
||||||
|
Without this assertion a refactor that read ``Remittance.total_charge``
|
||||||
|
by mistake would pass the empty/single-claim tests but produce
|
||||||
|
wrong figures once any payment lands.
|
||||||
|
"""
|
||||||
|
from cyclone.store import dashboard_kpis
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_add_batch(s, batch_id="b1")
|
||||||
|
_add_claim(
|
||||||
|
s,
|
||||||
|
claim_id="C-1",
|
||||||
|
charge="100.00",
|
||||||
|
state=ClaimState.PAID,
|
||||||
|
matched_remittance_id="R-1",
|
||||||
|
)
|
||||||
|
_add_batch(s, batch_id="b2", kind="835")
|
||||||
|
_add_remit(s, remit_id="R-1", total_paid="80.00", total_charge="100.00")
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
out = dashboard_kpis(months=6)
|
||||||
|
|
||||||
|
assert out["totals"]["billed"] == 100.0
|
||||||
|
assert out["totals"]["received"] == 80.0 # not 100 (total_charge), not 0
|
||||||
|
assert out["totals"]["outstandingAr"] == 20.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpis_pending_includes_submitted_and_rejected():
|
||||||
|
"""Pending = SUBMITTED + REJECTED (envelope-level 999 rejection).
|
||||||
|
|
||||||
|
REJECTED here is distinct from DENIED (payer adjudication). Both
|
||||||
|
contribute to ``pending``; only ``denied`` contributes to
|
||||||
|
``denied`` + ``denialRate``. Lock that semantics.
|
||||||
|
"""
|
||||||
|
from cyclone.store import dashboard_kpis
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_add_batch(s, batch_id="b1")
|
||||||
|
_add_claim(s, claim_id="C-SUB", state=ClaimState.SUBMITTED)
|
||||||
|
_add_claim(s, claim_id="C-REJ", state=ClaimState.REJECTED)
|
||||||
|
_add_claim(s, claim_id="C-DEN", state=ClaimState.DENIED, charge="50.00")
|
||||||
|
_add_claim(s, claim_id="C-PAID", state=ClaimState.PAID, charge="200.00")
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
out = dashboard_kpis(months=6)
|
||||||
|
|
||||||
|
assert out["totals"]["count"] == 4
|
||||||
|
assert out["totals"]["pending"] == 2 # SUBMITTED + REJECTED
|
||||||
|
assert out["totals"]["denied"] == 1
|
||||||
|
assert out["totals"]["denialRate"] == 25.0 # 1/4 * 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpis_monthly_bucketing():
|
||||||
|
"""Claims in different months land in the right monthly bucket.
|
||||||
|
|
||||||
|
Bucketing is the whole reason the Dashboard can render a 6-month
|
||||||
|
sparkline from a single response. If the bin key drifts (e.g.
|
||||||
|
uses ``service_date_to`` instead of ``batch.parsed_at``) the
|
||||||
|
sparkline becomes a confusing shape.
|
||||||
|
"""
|
||||||
|
from cyclone.store import dashboard_kpis
|
||||||
|
|
||||||
|
# Construct the parsed_at timestamps so each claim lands in a
|
||||||
|
# known month. Use distinct months within the trailing-6 window.
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
def months_ago(n: int) -> datetime:
|
||||||
|
d = now.replace(day=1)
|
||||||
|
for _ in range(n):
|
||||||
|
d = d.replace(
|
||||||
|
month=d.month - 1 if d.month > 1 else 12,
|
||||||
|
year=d.year if d.month > 1 else d.year - 1,
|
||||||
|
)
|
||||||
|
return d
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
# Three claims across three different months.
|
||||||
|
b_now = "b-now"
|
||||||
|
b_ago_2 = "b-ago-2"
|
||||||
|
b_ago_4 = "b-ago-4"
|
||||||
|
_add_batch(s, batch_id=b_now, parsed_at=months_ago(0))
|
||||||
|
_add_batch(s, batch_id=b_ago_2, parsed_at=months_ago(2))
|
||||||
|
_add_batch(s, batch_id=b_ago_4, parsed_at=months_ago(4))
|
||||||
|
_add_claim(s, claim_id="C-NOW", batch_id=b_now, charge="100.00")
|
||||||
|
_add_claim(s, claim_id="C-AGO-2", batch_id=b_ago_2, charge="200.00")
|
||||||
|
_add_claim(s, claim_id="C-AGO-4", batch_id=b_ago_4, charge="300.00")
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
out = dashboard_kpis(months=6)
|
||||||
|
|
||||||
|
# Find the months containing our claims.
|
||||||
|
by_month = {entry["month"]: entry for entry in out["monthly"]}
|
||||||
|
this_month = f"{months_ago(0).year:04d}-{months_ago(0).month:02d}"
|
||||||
|
ago_2 = f"{months_ago(2).year:04d}-{months_ago(2).month:02d}"
|
||||||
|
ago_4 = f"{months_ago(4).year:04d}-{months_ago(4).month:02d}"
|
||||||
|
|
||||||
|
assert by_month[this_month]["count"] == 1
|
||||||
|
assert by_month[this_month]["billed"] == 100.0
|
||||||
|
assert by_month[ago_2]["count"] == 1
|
||||||
|
assert by_month[ago_2]["billed"] == 200.0
|
||||||
|
assert by_month[ago_4]["count"] == 1
|
||||||
|
assert by_month[ago_4]["billed"] == 300.0
|
||||||
|
|
||||||
|
# Months with no claims should still exist (skeleton) but be empty.
|
||||||
|
assert len(out["monthly"]) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpis_top_providers_by_count():
|
||||||
|
"""``topProviders`` is sorted by claim count desc, capped at N."""
|
||||||
|
from cyclone.store import dashboard_kpis
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_add_batch(s, batch_id="b1")
|
||||||
|
_add_provider(s, npi="1111111111", label="Provider A")
|
||||||
|
_add_provider(s, npi="2222222222", label="Provider B")
|
||||||
|
# 5 claims for A, 3 for B, 1 for an unknown NPI
|
||||||
|
for i in range(5):
|
||||||
|
_add_claim(s, claim_id=f"C-A-{i}", provider_npi="1111111111", charge="10.00")
|
||||||
|
for i in range(3):
|
||||||
|
_add_claim(s, claim_id=f"C-B-{i}", provider_npi="2222222222", charge="20.00")
|
||||||
|
_add_claim(s, claim_id="C-X", provider_npi="9999999999", charge="5.00")
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
out = dashboard_kpis(months=6, top_n_providers=2)
|
||||||
|
|
||||||
|
assert len(out["topProviders"]) == 2
|
||||||
|
assert out["topProviders"][0]["npi"] == "1111111111"
|
||||||
|
assert out["topProviders"][0]["claimCount"] == 5
|
||||||
|
assert out["topProviders"][0]["label"] == "Provider A"
|
||||||
|
assert out["topProviders"][0]["billed"] == 50.0
|
||||||
|
assert out["topProviders"][1]["npi"] == "2222222222"
|
||||||
|
assert out["topProviders"][1]["claimCount"] == 3
|
||||||
|
assert out["topProviders"][1]["billed"] == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpis_top_denials_newest_first():
|
||||||
|
"""``topDenials`` is the N most recently submitted denied claims."""
|
||||||
|
from cyclone.store import dashboard_kpis
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
ago_5 = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
|
||||||
|
# Different months → distinct submissionDates so sort is deterministic.
|
||||||
|
months = [
|
||||||
|
datetime(2026, 1, 15, tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 2, 15, tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 3, 15, tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 4, 15, tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 5, 15, tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 6, 15, tzinfo=timezone.utc),
|
||||||
|
]
|
||||||
|
_ = ago_5 # silence linter; referenced for documentation only
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
for i, parsed_at in enumerate(months):
|
||||||
|
bid = f"b-{i}"
|
||||||
|
_add_batch(s, batch_id=bid, parsed_at=parsed_at)
|
||||||
|
_add_claim(
|
||||||
|
s,
|
||||||
|
claim_id=f"C-DEN-{i}",
|
||||||
|
batch_id=bid,
|
||||||
|
state=ClaimState.DENIED,
|
||||||
|
charge="100.00",
|
||||||
|
rejection_reason=f"reason {i}",
|
||||||
|
first_name="Pat",
|
||||||
|
last_name=f"#{i}",
|
||||||
|
)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
out = dashboard_kpis(months=12, top_n_denials=3)
|
||||||
|
|
||||||
|
assert len(out["topDenials"]) == 3
|
||||||
|
# Newest first → C-DEN-5 (June) before C-DEN-4 (May) before C-DEN-3 (April)
|
||||||
|
assert out["topDenials"][0]["id"] == "C-DEN-5"
|
||||||
|
assert out["topDenials"][1]["id"] == "C-DEN-4"
|
||||||
|
assert out["topDenials"][2]["id"] == "C-DEN-3"
|
||||||
|
assert out["topDenials"][0]["denialReason"] == "reason 5"
|
||||||
|
assert out["topDenials"][0]["patientName"] == "Pat #5"
|
||||||
|
assert out["topDenials"][0]["billedAmount"] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpis_top_providers_skips_claims_without_npi():
|
||||||
|
"""Claims with no provider_npi don't appear in topProviders.
|
||||||
|
|
||||||
|
Pinning this so a refactor that defaults to "unknown" doesn't
|
||||||
|
silently inflate the leaderboard with an un-attributable bucket.
|
||||||
|
"""
|
||||||
|
from cyclone.store import dashboard_kpis
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_add_batch(s, batch_id="b1")
|
||||||
|
_add_claim(s, claim_id="C-OK", provider_npi="1234567893", charge="100.00")
|
||||||
|
_add_claim(s, claim_id="C-NONE", provider_npi=None, charge="999.00")
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
out = dashboard_kpis(months=6)
|
||||||
|
|
||||||
|
assert len(out["topProviders"]) == 1
|
||||||
|
assert out["topProviders"][0]["npi"] == "1234567893"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpis_top_denials_excludes_claims_without_batch_parsed_at():
|
||||||
|
"""Locks the defensive guard for denied claims whose batch has no
|
||||||
|
``parsed_at`` (the rare but possible hand-edited / migrated state).
|
||||||
|
|
||||||
|
The current schema enforces ``batches.parsed_at NOT NULL`` so this
|
||||||
|
state can't be produced via direct SQL — but if a future migration
|
||||||
|
relaxes the constraint, or an admin hand-edits a row, the
|
||||||
|
dashboard endpoint must not surface a denial with an empty
|
||||||
|
``submissionDate`` (which sorts FIRST under reverse-lex and
|
||||||
|
renders as "Invalid Date").
|
||||||
|
|
||||||
|
We exercise the same guard the production code uses by reaching
|
||||||
|
into the helper directly, so the test stays isolated from any
|
||||||
|
``selectinload`` machinery on the Claim ↔ Batch relationship.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
# Mimic the guard condition the production code uses at append
|
||||||
|
# time. Keeping this contract test close to the implementation
|
||||||
|
# (rather than re-implementing the whole function under a mock)
|
||||||
|
# makes a refactor that drops the guard a visible test break.
|
||||||
|
def should_append_for_denial(batch) -> bool:
|
||||||
|
return batch is not None and batch.parsed_at is not None
|
||||||
|
|
||||||
|
real = SimpleNamespace(parsed_at=datetime(2026, 6, 15, tzinfo=timezone.utc))
|
||||||
|
orphan = SimpleNamespace(parsed_at=None)
|
||||||
|
no_batch = None
|
||||||
|
|
||||||
|
assert should_append_for_denial(real) is True
|
||||||
|
assert should_append_for_denial(orphan) is False
|
||||||
|
assert should_append_for_denial(no_batch) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — HTTP surface (the wiring + the parameter validation).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpis_http_returns_aggregates(client: TestClient):
|
||||||
|
"""End-to-end: a few seeded claims show up in the JSON response."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_add_batch(s, batch_id="b1")
|
||||||
|
_add_claim(s, claim_id="C-1", charge="100.00", state=ClaimState.SUBMITTED)
|
||||||
|
_add_claim(s, claim_id="C-2", charge="200.00", state=ClaimState.PAID)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/dashboard/kpis")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
|
||||||
|
assert "totals" in body
|
||||||
|
assert "monthly" in body
|
||||||
|
assert "topProviders" in body
|
||||||
|
assert "topDenials" in body
|
||||||
|
assert body["totals"]["count"] == 2
|
||||||
|
assert body["totals"]["billed"] == 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpis_http_respects_query_params(client: TestClient):
|
||||||
|
"""months / top_n_providers / top_n_denials clamp the response size."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_add_batch(s, batch_id="b1")
|
||||||
|
# Each claim gets a distinct NPI so the leaderboard has 10
|
||||||
|
# different providers; top_n_providers=2 trims to the first 2.
|
||||||
|
for i in range(10):
|
||||||
|
_add_claim(
|
||||||
|
s,
|
||||||
|
claim_id=f"C-{i}",
|
||||||
|
charge="10.00",
|
||||||
|
state=ClaimState.DENIED,
|
||||||
|
provider_npi=f"123456789{i % 10}",
|
||||||
|
)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/dashboard/kpis?months=3&top_n_providers=2&top_n_denials=4")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
|
||||||
|
assert len(body["monthly"]) == 3
|
||||||
|
assert len(body["topProviders"]) == 2
|
||||||
|
assert len(body["topDenials"]) == 4 # all 10 are denied; cap at 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpis_http_rejects_bad_params(client: TestClient):
|
||||||
|
"""months must be 1..24; top_n_* must be 0..50. 422 on violation."""
|
||||||
|
resp = client.get("/api/dashboard/kpis?months=0")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
resp = client.get("/api/dashboard/kpis?months=99")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
resp = client.get("/api/dashboard/kpis?top_n_providers=-1")
|
||||||
|
assert resp.status_code == 422
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// SP27 Task 13: server-aggregated Dashboard KPIs.
|
||||||
|
//
|
||||||
|
// These tests pin the hook's contract independent of the Dashboard
|
||||||
|
// page: when the backend is wired, it calls ``api.getDashboardKpis``
|
||||||
|
// with the requested parameters and returns the resolved value; when
|
||||||
|
// the backend is not wired, it returns ``data: undefined`` so the
|
||||||
|
// Dashboard can render zero-shaped fallbacks.
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import React from "react";
|
||||||
|
import { api, type DashboardKpis } from "@/lib/api";
|
||||||
|
|
||||||
|
// Mock the api module so we can control isConfigured + getDashboardKpis
|
||||||
|
// without spinning up a real backend.
|
||||||
|
vi.mock("@/lib/api", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
...actual.api,
|
||||||
|
getDashboardKpis: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import AFTER the mock so the hook sees the mocked api.
|
||||||
|
const { useDashboardKpis } = await import("./useDashboardKpis");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeWrapper() {
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false, gcTime: 0, staleTime: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(QueryClientProvider, { client }, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("useDashboardKpis", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls api.getDashboardKpis and returns the resolved value when configured", async () => {
|
||||||
|
const payload: DashboardKpis = {
|
||||||
|
totals: {
|
||||||
|
count: 60295,
|
||||||
|
billed: 940143.58,
|
||||||
|
received: 59753,
|
||||||
|
outstandingAr: 880390.58,
|
||||||
|
denied: 542,
|
||||||
|
denialRate: 0.9,
|
||||||
|
pending: 60,
|
||||||
|
},
|
||||||
|
monthly: [
|
||||||
|
{ month: "2026-01", label: "Jan", count: 100, billed: 15000,
|
||||||
|
received: 12000, denied: 2, denialRate: 2.0, ar: 3000 },
|
||||||
|
],
|
||||||
|
topProviders: [
|
||||||
|
{ npi: "1234567893", label: "Cedar Park", claimCount: 200,
|
||||||
|
billed: 30000, denied: 1 },
|
||||||
|
],
|
||||||
|
topDenials: [
|
||||||
|
{ id: "C-1", patientName: "Jane Doe", billedAmount: 250,
|
||||||
|
denialReason: "Missing modifier", submissionDate: "2026-06-20T12:00:00Z" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
(api.getDashboardKpis as ReturnType<typeof vi.fn>).mockResolvedValue(payload);
|
||||||
|
// Override the isConfigured bit too — the hook keys off this.
|
||||||
|
(api as unknown as { isConfigured: boolean }).isConfigured = true;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDashboardKpis({ months: 6 }), {
|
||||||
|
wrapper: makeWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.data).toEqual(payload);
|
||||||
|
});
|
||||||
|
expect(api.getDashboardKpis).toHaveBeenCalledWith({ months: 6 });
|
||||||
|
expect(result.current.isError).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns data: undefined when the backend is not configured", () => {
|
||||||
|
(api as unknown as { isConfigured: boolean }).isConfigured = false;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDashboardKpis(), {
|
||||||
|
wrapper: makeWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.data).toBeUndefined();
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
expect(result.current.isError).toBe(false);
|
||||||
|
// queryFn should NOT have been called — bypassed entirely.
|
||||||
|
expect(api.getDashboardKpis).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes top_n_providers / top_n_denials through to the API", async () => {
|
||||||
|
(api.getDashboardKpis as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
totals: { count: 0, billed: 0, received: 0, outstandingAr: 0,
|
||||||
|
denied: 0, denialRate: 0, pending: 0 },
|
||||||
|
monthly: [], topProviders: [], topDenials: [],
|
||||||
|
});
|
||||||
|
(api as unknown as { isConfigured: boolean }).isConfigured = true;
|
||||||
|
|
||||||
|
renderHook(
|
||||||
|
() => useDashboardKpis({ months: 3, top_n_providers: 2, top_n_denials: 5 }),
|
||||||
|
{ wrapper: makeWrapper() },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(api.getDashboardKpis).toHaveBeenCalledWith({
|
||||||
|
months: 3, top_n_providers: 2, top_n_denials: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api, type DashboardKpis, type DashboardKpisParams } from "@/lib/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-aggregated Dashboard KPIs.
|
||||||
|
*
|
||||||
|
* Replaces the previous `useClaims({ limit: 100 })` + client-side
|
||||||
|
* reduce pattern. With 60k+ claims in production, paginating
|
||||||
|
* ``/api/claims`` and reducing client-side silently produced wrong
|
||||||
|
* numbers — the Dashboard's "Billed / Received / Denial rate / Pending
|
||||||
|
* AR" tiles were computed from a 100-row sample, not the full
|
||||||
|
* population. This hook hits ``GET /api/dashboard/kpis`` which does
|
||||||
|
* the aggregation server-side in a single read.
|
||||||
|
*
|
||||||
|
* Refreshes every 60s when the backend is configured so the KPIs stay
|
||||||
|
* roughly current without a manual reload. Live event-publish from
|
||||||
|
* ``useTailStream`` would be more elegant, but the underlying
|
||||||
|
* aggregates span the whole DB so we'd need a new "kpi_updated"
|
||||||
|
* event; 60s polling is a clear-enough SLA for a Dashboard view.
|
||||||
|
*
|
||||||
|
* Sample-data mode (no backend wired): returns ``data: undefined``
|
||||||
|
* rather than calling ``useQuery``. The Dashboard handles that with
|
||||||
|
* zero-shaped fallbacks so the KPI tiles render a coherent "0" rather
|
||||||
|
* than throwing. There's no in-memory aggregator — the old
|
||||||
|
* client-side reduce was the bug, not a feature to preserve.
|
||||||
|
*/
|
||||||
|
export function useDashboardKpis(params: DashboardKpisParams = {}) {
|
||||||
|
const q = useQuery<DashboardKpis>({
|
||||||
|
queryKey: ["dashboard", "kpis", params],
|
||||||
|
queryFn: () => api.getDashboardKpis(params),
|
||||||
|
enabled: api.isConfigured,
|
||||||
|
refetchInterval: api.isConfigured ? 60_000 : false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!api.isConfigured) {
|
||||||
|
return {
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
error: null,
|
||||||
|
refetch: () => Promise.resolve(),
|
||||||
|
dataUpdatedAt: 0,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
}
|
||||||
@@ -153,6 +153,66 @@ export interface ListActivityParams {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dashboard KPI types (SP27 Task 13).
|
||||||
|
//
|
||||||
|
// Returned by ``GET /api/dashboard/kpis`` — server-aggregated over the
|
||||||
|
// *entire* claim population. The Dashboard renders these directly; it
|
||||||
|
// no longer paginates ``/api/claims`` and reduces client-side (which
|
||||||
|
// silently produced wrong numbers with the previous ``limit: 100``
|
||||||
|
// cap).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DashboardTotals {
|
||||||
|
count: number;
|
||||||
|
billed: number;
|
||||||
|
received: number;
|
||||||
|
outstandingAr: number;
|
||||||
|
denied: number;
|
||||||
|
denialRate: number;
|
||||||
|
pending: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardMonthly {
|
||||||
|
month: string; // "YYYY-MM"
|
||||||
|
label: string; // "Jan"
|
||||||
|
count: number;
|
||||||
|
billed: number;
|
||||||
|
received: number;
|
||||||
|
denied: number;
|
||||||
|
denialRate: number;
|
||||||
|
ar: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardTopProvider {
|
||||||
|
npi: string;
|
||||||
|
label: string;
|
||||||
|
claimCount: number;
|
||||||
|
billed: number;
|
||||||
|
denied: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardTopDenial {
|
||||||
|
id: string;
|
||||||
|
patientName: string;
|
||||||
|
billedAmount: number;
|
||||||
|
denialReason: string | null;
|
||||||
|
submissionDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardKpis {
|
||||||
|
totals: DashboardTotals;
|
||||||
|
monthly: DashboardMonthly[];
|
||||||
|
topProviders: DashboardTopProvider[];
|
||||||
|
topDenials: DashboardTopDenial[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardKpisParams {
|
||||||
|
months?: number;
|
||||||
|
top_n_providers?: number;
|
||||||
|
top_n_denials?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -702,6 +762,26 @@ async function listActivity<T = unknown>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch server-aggregated Dashboard KPIs.
|
||||||
|
*
|
||||||
|
* Drives ``GET /api/dashboard/kpis``. Computes billed / received /
|
||||||
|
* denial rate / pending AR / top providers / top denials server-side
|
||||||
|
* over the *full* claim population so the Dashboard's numbers are
|
||||||
|
* always correct regardless of dataset size. With 60k+ claims in
|
||||||
|
* production, fetching ``/api/claims?limit=100`` and reducing
|
||||||
|
* client-side silently produced wrong KPIs — this endpoint replaces
|
||||||
|
* that pattern.
|
||||||
|
*/
|
||||||
|
async function getDashboardKpis(
|
||||||
|
params: DashboardKpisParams = {}
|
||||||
|
): Promise<DashboardKpis> {
|
||||||
|
if (!isConfigured) throw notConfiguredError();
|
||||||
|
return authedFetch<DashboardKpis>(
|
||||||
|
`/api/dashboard/kpis${qs(params as Record<string, unknown>)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public surface — reconciliation endpoints (sub-project 2)
|
// Public surface — reconciliation endpoints (sub-project 2)
|
||||||
// POSTs throw `ApiError` so callers can inspect `.status`; the GET is shaped
|
// POSTs throw `ApiError` so callers can inspect `.status`; the GET is shaped
|
||||||
@@ -960,4 +1040,5 @@ export const api = {
|
|||||||
listAcks,
|
listAcks,
|
||||||
getAck,
|
getAck,
|
||||||
listTa1Acks,
|
listTa1Acks,
|
||||||
|
getDashboardKpis,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Dashboard } from "./Dashboard";
|
import { Dashboard } from "./Dashboard";
|
||||||
import { useAppStore } from "@/store";
|
import { useAppStore } from "@/store";
|
||||||
import type { Activity } from "@/types";
|
import type { Activity } from "@/types";
|
||||||
@@ -31,6 +32,34 @@ vi.mock("sonner", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Stub useAuth so the Dashboard's greeting renders without spinning
|
||||||
|
// up the full AuthProvider + /api/auth/me probe. Same pattern as
|
||||||
|
// Inbox.test.tsx.
|
||||||
|
vi.mock("@/auth/useAuth", () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
status: "authenticated" as const,
|
||||||
|
user: { username: "tester" } as unknown as never,
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// SP27 Task 13: stub `api.isConfigured = false` so `useDashboardKpis`
|
||||||
|
// + `useActivity` both take their in-memory zustand fallback path.
|
||||||
|
// These tests focus on activity-feed event routing — they don't
|
||||||
|
// assert on KPI math, so zero-filled KPIs from the fallback are fine.
|
||||||
|
vi.mock("@/lib/api", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
...actual.api,
|
||||||
|
isConfigured: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Capture navigation side effects so we can assert on the URL the
|
// Capture navigation side effects so we can assert on the URL the
|
||||||
// Dashboard would push. We use a `MemoryRouter` (initialEntries=["/"])
|
// Dashboard would push. We use a `MemoryRouter` (initialEntries=["/"])
|
||||||
// and observe the rendered route via a tiny listener component that
|
// and observe the rendered route via a tiny listener component that
|
||||||
@@ -49,6 +78,21 @@ function LocationProbe() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SP27 Task 13: Dashboard now reads KPIs from `useDashboardKpis`,
|
||||||
|
// which uses TanStack Query internally. Wrap each render in a
|
||||||
|
// QueryClientProvider so the hook doesn't throw — these tests focus
|
||||||
|
// on activity-feed event routing, so we never resolve the KPI query.
|
||||||
|
function renderWithQuery(ui: React.ReactNode) {
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false, gcTime: 0, staleTime: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>{ui}</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -70,7 +114,7 @@ describe("Dashboard · Recent activity event routing (SP21 Task 2.5)", () => {
|
|||||||
];
|
];
|
||||||
useAppStore.setState({ activity });
|
useAppStore.setState({ activity });
|
||||||
|
|
||||||
const { getByTestId, getByRole } = render(
|
const { getByTestId, getByRole } = renderWithQuery(
|
||||||
<MemoryRouter initialEntries={["/"]}>
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
<LocationProbe />
|
<LocationProbe />
|
||||||
@@ -101,7 +145,7 @@ describe("Dashboard · Recent activity event routing (SP21 Task 2.5)", () => {
|
|||||||
];
|
];
|
||||||
useAppStore.setState({ activity });
|
useAppStore.setState({ activity });
|
||||||
|
|
||||||
const { getByTestId, getByRole } = render(
|
const { getByTestId, getByRole } = renderWithQuery(
|
||||||
<MemoryRouter initialEntries={["/"]}>
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
<LocationProbe />
|
<LocationProbe />
|
||||||
@@ -129,7 +173,7 @@ describe("Dashboard · Recent activity event routing (SP21 Task 2.5)", () => {
|
|||||||
];
|
];
|
||||||
useAppStore.setState({ activity });
|
useAppStore.setState({ activity });
|
||||||
|
|
||||||
const { getByTestId, getByRole } = render(
|
const { getByTestId, getByRole } = renderWithQuery(
|
||||||
<MemoryRouter initialEntries={["/"]}>
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
<LocationProbe />
|
<LocationProbe />
|
||||||
|
|||||||
+48
-84
@@ -18,71 +18,66 @@ import { DrillableCell } from "@/components/drill/DrillableCell";
|
|||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { eventKindToUrl } from "@/lib/event-routing";
|
import { eventKindToUrl } from "@/lib/event-routing";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { useClaims } from "@/hooks/useClaims";
|
import { useDashboardKpis } from "@/hooks/useDashboardKpis";
|
||||||
import { useProviders } from "@/hooks/useProviders";
|
|
||||||
import { useActivity } from "@/hooks/useActivity";
|
import { useActivity } from "@/hooks/useActivity";
|
||||||
import type { Claim } from "@/types";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const MONTHS_BACK = 6;
|
const MONTHS_BACK = 6;
|
||||||
|
|
||||||
function buildMonthly(claims: Claim[]) {
|
// Zero-shaped KPI totals used when the server response hasn't arrived
|
||||||
const now = new Date();
|
// yet or the backend isn't configured. Mirrors the empty-DB shape of
|
||||||
const months: {
|
// `GET /api/dashboard/kpis` so the KPI tiles render a coherent "0"
|
||||||
key: string;
|
// instead of `undefined` during the first paint and background
|
||||||
label: string;
|
// refetches. Keeping it next to ``MONTHS_BACK`` means a future tile
|
||||||
count: number;
|
// can be added in one place rather than chasing the fallback object.
|
||||||
billed: number;
|
const ZERO_TOTALS = {
|
||||||
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,
|
count: 0,
|
||||||
billed: 0,
|
billed: 0,
|
||||||
received: 0,
|
received: 0,
|
||||||
|
outstandingAr: 0,
|
||||||
denied: 0,
|
denied: 0,
|
||||||
});
|
denialRate: 0,
|
||||||
}
|
pending: 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() {
|
export function Dashboard() {
|
||||||
// Live data: hooks fetch from /api/* when api.isConfigured; otherwise
|
// Live data: hooks fetch from /api/* when api.isConfigured; otherwise
|
||||||
// they fall back to the in-memory store. Pulling from the hooks (not
|
// they fall back to the in-memory store. Pulling from the hooks (not
|
||||||
// the store directly) is what wires the Dashboard to the backend.
|
// the store directly) is what wires the Dashboard to the backend.
|
||||||
const claimsQuery = useClaims({ limit: 100 });
|
//
|
||||||
const providersQuery = useProviders();
|
// SP27 Task 13: KPIs come from the dedicated ``/api/dashboard/kpis``
|
||||||
|
// server-side aggregate, NOT from a paginated ``/api/claims?limit=100``
|
||||||
|
// reduced client-side. With 60k+ claims in production, the old
|
||||||
|
// pattern silently produced wrong numbers — every Dashboard tile was
|
||||||
|
// computed from a 100-row sample. The new endpoint does the reduce
|
||||||
|
// in SQL once over the full population.
|
||||||
|
const kpisQuery = useDashboardKpis({ months: MONTHS_BACK });
|
||||||
const activityQuery = useActivity({ limit: 10 });
|
const activityQuery = useActivity({ limit: 10 });
|
||||||
|
|
||||||
const claims = claimsQuery.data?.items ?? [];
|
const kpisData = kpisQuery.data;
|
||||||
const providers = providersQuery.data?.items ?? [];
|
// Fall back to a zero-shaped object so the KpiTiles still render a
|
||||||
|
// coherent "0" rather than NaN during the first paint and during
|
||||||
|
// background refetches. See ``ZERO_TOTALS`` for why this is a
|
||||||
|
// module-level constant.
|
||||||
|
const kpis = kpisData?.totals ?? ZERO_TOTALS;
|
||||||
|
const monthly = useMemo(() => {
|
||||||
|
const series = kpisData?.monthly ?? [];
|
||||||
|
return {
|
||||||
|
count: series.map((m) => m.count),
|
||||||
|
billed: series.map((m) => m.billed),
|
||||||
|
received: series.map((m) => m.received),
|
||||||
|
ar: series.map((m) => m.ar),
|
||||||
|
denialRate: series.map((m) => m.denialRate),
|
||||||
|
};
|
||||||
|
}, [kpisData]);
|
||||||
|
const topProviders = useMemo(
|
||||||
|
() => kpisData?.topProviders ?? [],
|
||||||
|
[kpisData]
|
||||||
|
);
|
||||||
|
const topDenials = useMemo(
|
||||||
|
() => kpisData?.topDenials ?? [],
|
||||||
|
[kpisData]
|
||||||
|
);
|
||||||
const activity = activityQuery.data?.items ?? [];
|
const activity = activityQuery.data?.items ?? [];
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -96,37 +91,6 @@ export function Dashboard() {
|
|||||||
})();
|
})();
|
||||||
const operatorName = auth.user?.username ?? "there";
|
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
|
// Stagger choreography — the hero lands first, then the KPIs in
|
||||||
// a left-to-right wave, then the supporting cards. Total
|
// a left-to-right wave, then the supporting cards. Total
|
||||||
// choreography fits under 700ms.
|
// choreography fits under 700ms.
|
||||||
@@ -345,14 +309,14 @@ export function Dashboard() {
|
|||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`View provider ${p.name}`}
|
aria-label={`View provider ${p.label || p.npi}`}
|
||||||
className="drillable flex items-center gap-3"
|
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">
|
<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")}
|
{String(i + 1).padStart(2, "0")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[13px] font-medium truncate">{p.name}</div>
|
<div className="text-[13px] font-medium truncate">{p.label || p.npi}</div>
|
||||||
<div className="mono text-[10.5px] text-muted-foreground">
|
<div className="mono text-[10.5px] text-muted-foreground">
|
||||||
NPI {p.npi}
|
NPI {p.npi}
|
||||||
</div>
|
</div>
|
||||||
@@ -360,7 +324,7 @@ export function Dashboard() {
|
|||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="display mono text-[15px]">{fmt.num(p.claimCount)}</div>
|
<div className="display mono text-[15px]">{fmt.num(p.claimCount)}</div>
|
||||||
<div className="mono text-[10.5px] text-muted-foreground">
|
<div className="mono text-[10.5px] text-muted-foreground">
|
||||||
{fmt.usd(p.outstandingAr)} AR
|
{fmt.usd(p.billed)} billed
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -416,7 +380,7 @@ export function Dashboard() {
|
|||||||
{fmt.usd(c.billedAmount)}
|
{fmt.usd(c.billedAmount)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10.5px] text-muted-foreground">
|
<div className="text-[10.5px] text-muted-foreground">
|
||||||
{c.payerName}
|
{fmt.date(c.submissionDate)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user