feat(sp30): Dashboard Recent batches widget with billing outcome

- backend: GET /api/batches now returns acceptedCount/rejectedCount/
  pendingCount/billedTotal/topRejectionReason/hasProblem per item
  (one GROUP BY query + one ordered scan, no N+1)
- backend: 2 tests pin the 837p full-bucket case + the 835 zero case
- frontend: BatchSummary extended with 6 optional fields
  (backwards compat preserved)
- frontend: new RecentBatchesWidget renders one row per batch with
  status icon + billed total + accepted count + top rejection reason
- frontend: 837p rows show $ total + 'N/M accepted'; 835 rows show
  payment count (Remittance has no batch-level total_charge)
- frontend: full-width row between KPI tiles and Activity on the
  Dashboard; click navigates to /batches?batch=ID
- frontend: 3 component tests cover empty/clean/problem/835 branches
This commit is contained in:
Nora
2026-07-02 14:18:55 -06:00
parent bde3060e9e
commit 97512ec4a7
7 changed files with 661 additions and 0 deletions
+163
View File
@@ -1766,6 +1766,153 @@ def _batch_summary_claim_ids(rec: BatchRecord) -> list[str]:
] ]
# SP30: state buckets the Dashboard widget (and any future "how the
# last batch billed" surface) reads at a glance. Keep these in sync
# with ClaimState — adding a new state here is a deliberate decision
# the operator needs to see, not a coincidence.
_BATCH_SUMMARY_ACCEPTED_STATES: tuple[ClaimState, ...] = (
ClaimState.PAID,
ClaimState.RECEIVED,
ClaimState.RECONCILED,
ClaimState.PARTIAL,
)
_BATCH_SUMMARY_REJECTED_STATES: tuple[ClaimState, ...] = (
ClaimState.REJECTED,
ClaimState.DENIED,
ClaimState.REVERSED,
)
_BATCH_SUMMARY_PENDING_STATES: tuple[ClaimState, ...] = (
ClaimState.SUBMITTED,
)
# 277CA STC category A4/A6/A7 — payer-side rejections that may not
# yet have flipped Claim.state (the operator hasn't acknowledged).
# The Dashboard widget treats these as problems too, mirroring the
# Inbox `rejected + payer_rejected` aggregation.
_BATCH_SUMMARY_PAYER_REJECT_CODES: tuple[str, ...] = ("A4", "A6", "A7")
def _batch_summary_billing_outcomes(
records: list[BatchRecord],
) -> dict[str, dict]:
"""Compute per-batch billing outcome for the Dashboard widget.
Returns ``{batch_id: {accepted, rejected, pending, billed,
top_rejection_reason, has_problem}}`` for every batch in
``records``. Empty input → empty dict.
Two SQL queries, both bounded by the supplied batch ids:
1. One GROUP BY ``(batch_id, state)`` aggregate that produces
the accepted/rejected/pending counts and the sum of
``charge_amount`` (the billed total). Single pass — no N+1.
2. One ordered scan over the rejected + payer-rejected subset
to pick the most recent rejection reason (truncated to 60
chars). Skipped when the first query found no rejections
and no payer-rejects, so the happy path stays at one query.
835 batches have no Claim rows — the GROUP BY returns no
rows for them, so the dict entry for an 835 batch is
``{accepted:0, rejected:0, pending:0, billed:0.0,
top_rejection_reason:None, has_problem:False}`` (filled by
the caller's ``.get(id, defaults)`` pattern).
"""
if not records:
return {}
from sqlalchemy import func # local import to keep top-of-file light
batch_ids = [r.id for r in records]
outcome: dict[str, dict] = {
bid: {
"accepted": 0,
"rejected": 0,
"pending": 0,
"billed": 0.0,
"top_rejection_reason": None,
"has_problem": False,
}
for bid in batch_ids
}
with db.SessionLocal()() as s:
# ---- 1. GROUP BY (batch_id, state) for counts + billed total ----
rows = (
s.query(
Claim.batch_id,
Claim.state,
func.count(Claim.id),
func.coalesce(func.sum(Claim.charge_amount), 0),
)
.filter(Claim.batch_id.in_(batch_ids))
.group_by(Claim.batch_id, Claim.state)
.all()
)
any_rejection_or_payer = False
for batch_id, state, count, billed in rows:
slot = outcome.get(batch_id)
if slot is None:
continue # batch has no row in our pre-allocated dict
count = int(count or 0)
billed_f = float(billed or 0)
slot["billed"] += billed_f
if state in _BATCH_SUMMARY_ACCEPTED_STATES:
slot["accepted"] += count
elif state in _BATCH_SUMMARY_REJECTED_STATES:
slot["rejected"] += count
any_rejection_or_payer = True
elif state in _BATCH_SUMMARY_PENDING_STATES:
slot["pending"] += count
# everything else (DRAFT, etc.) is excluded from the widget.
# ---- 2. Most-recent rejection reason + payer-reject probe ----
# Only run when we know there IS at least one rejection OR a
# payer-reject claim somewhere in the batch set; otherwise
# the first query alone is enough.
if any_rejection_or_payer:
rej_rows = (
s.query(
Claim.batch_id,
Claim.rejection_reason,
Claim.payer_rejected_status_code,
)
.filter(
Claim.batch_id.in_(batch_ids),
Claim.state.in_(_BATCH_SUMMARY_REJECTED_STATES)
| Claim.payer_rejected_status_code.in_(
_BATCH_SUMMARY_PAYER_REJECT_CODES
),
)
.order_by(Claim.rejected_at.desc().nullslast())
.all()
)
seen_reason: set[str] = set()
for batch_id, reason, payer_code in rej_rows:
slot = outcome.get(batch_id)
if slot is None:
continue
if payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES:
slot["has_problem"] = True
# Capture the first non-null reason for this batch
# (rej_rows is ordered newest-first, so the first
# non-null wins). Truncate to 60 chars + ellipsis.
if (
slot["top_rejection_reason"] is None
and reason
and batch_id not in seen_reason
):
r = reason.strip()
if len(r) > 60:
r = r[:60] + ""
slot["top_rejection_reason"] = r
seen_reason.add(batch_id)
if (
slot["rejected"] > 0
or payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES
):
slot["has_problem"] = True
return outcome
@app.get("/api/batches", dependencies=[Depends(matrix_gate)]) @app.get("/api/batches", dependencies=[Depends(matrix_gate)])
def list_batches( def list_batches(
request: Request, request: Request,
@@ -1778,8 +1925,16 @@ def list_batches(
row without an extra round-trip to ``/api/batches/{id}``. The row without an extra round-trip to ``/api/batches/{id}``. The
list is still capped at ``limit`` claims; see the full result list is still capped at ``limit`` claims; see the full result
via the by-id endpoint when more is needed. via the by-id endpoint when more is needed.
SP30: also returns billing-outcome fields
(``acceptedCount`` / ``rejectedCount`` / ``pendingCount`` /
``billedTotal`` / ``topRejectionReason`` / ``hasProblem``) so
the Dashboard "Recent batches" widget can render one row per
batch without an N+1 fetch. See
:func:`_batch_summary_billing_outcomes`.
""" """
records = store.list(limit=limit) records = store.list(limit=limit)
outcomes = _batch_summary_billing_outcomes(records)
items = [ items = [
{ {
"id": r.id, "id": r.id,
@@ -1788,6 +1943,14 @@ def list_batches(
"parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"), "parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"),
"claimCount": _batch_summary_claim_count(r), "claimCount": _batch_summary_claim_count(r),
"claimIds": _batch_summary_claim_ids(r), "claimIds": _batch_summary_claim_ids(r),
"acceptedCount": outcomes.get(r.id, {}).get("accepted", 0),
"rejectedCount": outcomes.get(r.id, {}).get("rejected", 0),
"pendingCount": outcomes.get(r.id, {}).get("pending", 0),
"billedTotal": round(outcomes.get(r.id, {}).get("billed", 0.0), 2),
"topRejectionReason": outcomes.get(r.id, {}).get(
"top_rejection_reason"
),
"hasProblem": outcomes.get(r.id, {}).get("has_problem", False),
} }
for r in records for r in records
] ]
+116
View File
@@ -101,6 +101,122 @@ def test_batches_claim_ids_empty_for_835(client: TestClient, tmp_path):
assert item["claimIds"] == [] assert item["claimIds"] == []
# --------------------------------------------------------------------------- #
# SP30: /api/batches billing-outcome fields
# --------------------------------------------------------------------------- #
def _sp30_seed_billing_outcome_batch() -> None:
"""Insert one batch with 3 claims (paid / rejected / submitted)
via the ORM directly. Mirrors the helper pattern in
``test_dashboard_kpis.py`` — bypasses the parse pipeline so
the test is independent of parser fixture drift.
"""
from datetime import datetime, timezone
from decimal import Decimal
from cyclone.db import Batch, Claim, ClaimState
from cyclone import db
raw_claim = {
"subscriber": {"first_name": "Jane", "last_name": "Doe"},
"payer": {"name": "CO_TXIX"},
"billing_provider": {"npi": "1234567893"},
"service_lines": [],
}
# ParseResult requires a `summary` (BatchSummary). The list endpoint
# rehydrates Batch.raw_result_json through ParseResult.model_validate,
# so the stub has to be shape-valid even though we don't read it.
valid_raw_result = {
"claims": [],
"summary": {
"input_file": "out.edi",
"total_claims": 3,
"passed": 3,
"failed": 0,
},
}
with db.SessionLocal()() as s:
s.add(Batch(
id="b-sp30-out",
kind="837p",
input_filename="out.edi",
parsed_at=datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc),
totals_json={"total_claims": 3},
validation_json={"passed": True, "warnings": [], "errors": []},
raw_result_json=valid_raw_result,
))
s.add(Claim(
id="C-paid",
batch_id="b-sp30-out",
patient_control_number="PCN-paid",
charge_amount=Decimal("100.00"),
state=ClaimState.PAID,
raw_json=raw_claim,
))
s.add(Claim(
id="C-rej",
batch_id="b-sp30-out",
patient_control_number="PCN-rej",
charge_amount=Decimal("50.00"),
state=ClaimState.REJECTED,
rejection_reason="999 AK5 R",
raw_json=raw_claim,
))
s.add(Claim(
id="C-sub",
batch_id="b-sp30-out",
patient_control_number="PCN-sub",
charge_amount=Decimal("75.00"),
state=ClaimState.SUBMITTED,
raw_json=raw_claim,
))
s.commit()
def test_batches_includes_billing_outcome(client: TestClient):
"""SP30: the Dashboard widget reads accepted/rejected/pending counts,
billed total, top rejection reason, and a has-problem flag off the
list endpoint. One batch, three claims covering all three buckets."""
_sp30_seed_billing_outcome_batch()
resp = client.get("/api/batches", headers=JSON)
assert resp.status_code == 200
body = resp.json()
# We may have other batches from earlier tests (the `seeded_store`
# autouse isn't applied here); find the SP30 batch by id.
item = next(i for i in body["items"] if i["id"] == "b-sp30-out")
assert item["acceptedCount"] == 1
assert item["rejectedCount"] == 1
assert item["pendingCount"] == 1
assert item["billedTotal"] == 225.0
assert item["topRejectionReason"] == "999 AK5 R"
assert item["hasProblem"] is True
def test_batches_835_kind_returns_zero_billed_and_no_rejection(client: TestClient):
"""SP30: 835 (ERA) batches have no Claim rows — the new fields are
all zeroed out so the frontend widget doesn't render garbage (the
widget shows "N payments" instead of a $ figure for ERAs).
"""
src = Path(__file__).parent / "fixtures" / "minimal_835.txt"
files = {"file": ("minimal_835.txt", src.read_bytes(), "text/plain")}
r = client.post(
"/api/parse-835",
params={"payer": "co_medicaid_835"},
files=files,
headers=JSON,
)
assert r.status_code == 200, r.text
body = client.get("/api/batches", headers=JSON).json()
item = next(i for i in body["items"] if i["kind"] == "835")
assert item["acceptedCount"] == 0
assert item["rejectedCount"] == 0
assert item["pendingCount"] == 0
assert item["billedTotal"] == 0.0
assert item["topRejectionReason"] is None
assert item["hasProblem"] is False
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# /api/batches/{id} # /api/batches/{id}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+134
View File
@@ -0,0 +1,134 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render } from "@testing-library/react";
import { RecentBatchesWidget } from "./RecentBatchesWidget";
import type { BatchSummary } from "@/lib/api";
function makeBatch(over: Partial<BatchSummary> = {}): BatchSummary {
return {
id: "b-1",
kind: "837p",
inputFilename: "2026-07-02-morning.edi",
parsedAt: "2026-07-02T15:00:00Z",
claimCount: 5,
claimIds: ["C-1", "C-2"],
acceptedCount: 5,
rejectedCount: 0,
pendingCount: 0,
billedTotal: 425.5,
topRejectionReason: null,
hasProblem: false,
...over,
};
}
describe("RecentBatchesWidget", () => {
it("renders the empty-state copy when batches is empty", () => {
const { container } = render(
<RecentBatchesWidget batches={[]} onRowClick={vi.fn()} />,
);
expect(container.textContent).toContain("No batches yet.");
// No interactive rows when there's nothing to show.
expect(container.querySelectorAll('[role="button"]').length).toBe(0);
});
it("renders one row per batch with status icon, billed total, accepted/total count, top rejection reason", () => {
const batches: BatchSummary[] = [
makeBatch({
id: "b-clean",
inputFilename: "clean.edi",
acceptedCount: 4,
rejectedCount: 0,
pendingCount: 1,
billedTotal: 425.5,
hasProblem: false,
topRejectionReason: null,
}),
makeBatch({
id: "b-bad",
inputFilename: "rejected-batch.edi",
acceptedCount: 1,
rejectedCount: 3,
pendingCount: 1,
billedTotal: 890.25,
hasProblem: true,
topRejectionReason: "999 AK5 R: envelope reject",
}),
];
const onRowClick = vi.fn();
const { container } = render(
<RecentBatchesWidget batches={batches} onRowClick={onRowClick} />,
);
// Both filenames present.
expect(container.textContent).toContain("clean.edi");
expect(container.textContent).toContain("rejected-batch.edi");
// Billed total rendered for the 837p rows via fmt.usd (whole
// dollars per the formatter's maximumFractionDigits: 0 setting
// — the Dashboard's KPI tiles use the same convention).
expect(container.textContent).toContain("$426");
expect(container.textContent).toContain("$890");
// accepted/total counts (4/5 accepted on the clean row, 1/5 on the bad).
expect(container.textContent).toContain("4/5 accepted");
expect(container.textContent).toContain("1/5 accepted");
// Top rejection reason appears on the bad batch's row.
const rejSpans = container.querySelectorAll(
'[data-testid="recent-batch-rejection"]',
);
expect(rejSpans.length).toBe(1);
expect(rejSpans[0].textContent).toBe("999 AK5 R: envelope reject");
// The rejection span carries the destructive-color tint.
const rejColor = (rejSpans[0] as HTMLElement).style.color;
expect(rejColor).toMatch(/destructive|229|72|77/i);
// Click handler fires with the batch id.
const cleanRow = container.querySelector(
'[data-testid="recent-batch-row-b-clean"]',
) as HTMLElement;
expect(cleanRow).toBeTruthy();
fireEvent.click(cleanRow);
expect(onRowClick).toHaveBeenCalledWith("b-clean");
// Enter key on the same row also fires onRowClick (a11y contract).
onRowClick.mockClear();
fireEvent.keyDown(cleanRow, { key: "Enter" });
expect(onRowClick).toHaveBeenCalledWith("b-clean");
});
it("renders 835 rows with payments count instead of a dollar figure", () => {
// ERAs (835) don't carry a billed total — the Remittance table
// has total_paid but no batch-level total_charge aggregate. The
// widget shows the payment count and the "payments" label, not $.
const batches: BatchSummary[] = [
makeBatch({
id: "b-era",
kind: "835",
inputFilename: "remit.835",
claimCount: 12,
acceptedCount: 0,
rejectedCount: 0,
pendingCount: 0,
billedTotal: 0,
hasProblem: false,
topRejectionReason: null,
}),
];
const { container } = render(
<RecentBatchesWidget batches={batches} onRowClick={vi.fn()} />,
);
// Payments count visible.
expect(container.textContent).toContain("12");
// Label "payments" present.
expect(container.textContent).toContain("payments");
// No dollar figure for the ERA row — no "$" anywhere on the row.
const row = container.querySelector(
'[data-testid="recent-batch-row-b-era"]',
) as HTMLElement;
expect(row.textContent).not.toContain("$");
// And the billed span is absent for ERA rows.
expect(container.querySelectorAll('[data-testid="recent-batch-billed"]').length).toBe(0);
});
});
+184
View File
@@ -0,0 +1,184 @@
// ---------------------------------------------------------------------------
// SP30: "Recent batches" Dashboard widget.
//
// One row per batch the operator parsed recently. Each row tells the
// operator two things at a glance:
// 1. Was this batch clean or did it have a problem? (icon tint +
// `topRejectionReason` line under the filename when present.)
// 2. What was the billed-out outcome? ($ for 837P; payment count for
// 835 ERA batches — the Remittance table has no batch-level
// `total_charge` aggregate.)
//
// Click → onRowClick(id) so the page can navigate to /batches?batch=ID
// (the BatchDrawer deep-link pattern from pages/Batches.tsx).
//
// Reuses the Dashboard "Recent activity" card chrome verbatim (Card +
// CardHeader pb-3 + CardTitle text-[14px] + CardContent pt-0) so the
// widget reads as part of the same family.
// ---------------------------------------------------------------------------
import { Layers, CheckCircle2, AlertTriangle } from "lucide-react";
import type { KeyboardEvent } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { fmt } from "@/lib/format";
import type { BatchSummary } from "@/lib/api";
import { cn } from "@/lib/utils";
type Props = {
batches: BatchSummary[];
onRowClick: (batchId: string) => void;
/** Cap shown in the header subtitle. Default 5. */
limit?: number;
};
export function RecentBatchesWidget({ batches, onRowClick, limit = 5 }: Props) {
return (
<Card data-testid="recent-batches-widget">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div>
<CardTitle className="flex items-center gap-2 text-[14px]">
<Layers className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.75} />
Recent batches
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
How the last {limit} batches billed out sorted newest first.
</p>
</div>
<span className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground">
{batches.length} latest
</span>
</CardHeader>
<CardContent className="pt-0">
{batches.length === 0 ? (
<div
className="text-[13px] text-muted-foreground px-2 py-6 text-center"
data-testid="recent-batches-empty"
>
No batches yet.
</div>
) : (
<ul className="divide-y divide-border/40">
{batches.map((b) => {
const rejected = b.rejectedCount ?? 0;
const clean = !b.hasProblem && rejected === 0;
const tint = clean
? "hsl(var(--success))"
: "hsl(var(--destructive))";
const Icon = clean ? CheckCircle2 : AlertTriangle;
const ariaLabel = clean
? `Clean batch ${b.inputFilename || b.id}`
: `Batch with rejections ${b.inputFilename || b.id}`;
return (
<BatchRow
key={b.id}
batch={b}
tint={tint}
Icon={Icon}
ariaLabel={ariaLabel}
onActivate={() => onRowClick(b.id)}
/>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// BatchRow — one row in the widget. Kept as a separate component so the
// parent can stay readable; matches the clickable-row + keyboard
// accessibility pattern from Dashboard.tsx:300-307 and the divide-y
// row markup from ActivityFeed.tsx:124-140.
// ---------------------------------------------------------------------------
type BatchRowProps = {
batch: BatchSummary;
tint: string;
Icon: typeof CheckCircle2;
ariaLabel: string;
onActivate: () => void;
};
function BatchRow({ batch, tint, Icon, ariaLabel, onActivate }: BatchRowProps) {
function onKeyDown(e: KeyboardEvent<HTMLLIElement>) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onActivate();
}
}
const isErn = batch.kind === "835";
const accepted = batch.acceptedCount ?? 0;
const billed = batch.billedTotal ?? 0;
return (
<li
role="button"
tabIndex={0}
aria-label={ariaLabel}
data-testid={`recent-batch-row-${batch.id}`}
onClick={onActivate}
onKeyDown={onKeyDown}
className={cn(
"drillable flex items-center gap-3 py-3 cursor-pointer",
"hover:bg-muted/30 transition-colors",
)}
>
<div
aria-hidden
className="mt-0.5 h-7 w-7 shrink-0 rounded-md ring-1 ring-inset ring-border/40 flex items-center justify-center"
style={{ color: tint }}
>
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-foreground/95 truncate">
{batch.inputFilename || batch.id}
</div>
<div className="mono text-[10.5px] text-muted-foreground mt-0.5 flex items-center gap-2 min-w-0">
<span className="shrink-0">{batch.kind.toUpperCase()}</span>
<span aria-hidden className="shrink-0">·</span>
<span className="shrink-0">{fmt.relative(batch.parsedAt)}</span>
{batch.topRejectionReason && (
<>
<span aria-hidden className="shrink-0">·</span>
<span
className="truncate"
style={{ color: "hsl(var(--destructive))" }}
data-testid="recent-batch-rejection"
>
{batch.topRejectionReason}
</span>
</>
)}
</div>
</div>
<div className="text-right shrink-0">
{isErn ? (
<>
<div className="display mono text-[15px] tabular-nums text-foreground">
{fmt.num(batch.claimCount)}
</div>
<div className="mono text-[10.5px] text-muted-foreground">payments</div>
</>
) : (
<>
<div
className="display mono text-[15px] tabular-nums text-foreground"
data-testid="recent-batch-billed"
>
{fmt.usd(billed)}
</div>
<div className="mono text-[10.5px] text-muted-foreground">
{fmt.num(accepted)}/{fmt.num(batch.claimCount)} accepted
</div>
</>
)}
</div>
</li>
);
}
+19
View File
@@ -256,6 +256,25 @@ export interface BatchSummary {
* instead of an extra round-trip to `/api/batches/{id}`. * instead of an extra round-trip to `/api/batches/{id}`.
*/ */
claimIds: string[]; claimIds: string[];
/**
* SP30: billing-outcome rollup computed server-side via one
* batched SQL aggregate (no N+1) so the Dashboard "Recent batches"
* widget can render one row per batch without a follow-up
* `/api/batches/{id}` call. All optional — pre-SP30 fixtures and
* tests that construct BatchSummary locally continue to work.
*/
/** # claims whose state is paid / received / partial / reconciled. */
acceptedCount?: number;
/** # claims whose state is rejected / denied / reversed. */
rejectedCount?: number;
/** # claims whose state is submitted (or draft). */
pendingCount?: number;
/** SUM(charge_amount) across all claims (837P only; 0 for 835). */
billedTotal?: number;
/** Most-recent rejection_reason on the batch, truncated to 60 chars. */
topRejectionReason?: string | null;
/** True when rejectedCount > 0 OR any 277CA A4/A6/A7 claim. */
hasProblem?: boolean;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+15
View File
@@ -193,3 +193,18 @@ describe("Dashboard · Recent activity event routing (SP21 Task 2.5)", () => {
expect(probe.getAttribute("data-search")).toBe(""); expect(probe.getAttribute("data-search")).toBe("");
}); });
}); });
// ---------------------------------------------------------------------------
// SP30: Dashboard integration is exercised by:
// - The 3 component tests in src/components/RecentBatchesWidget.test.tsx
// (which pin the click → onRowClick behavior + the empty/clean/problem
// rendering branches + the 837p vs 835 row split).
// - The Playwright smoke test (login → / → click row → URL changes
// to /batches?batch=ID).
//
// A Dashboard-level test would need to flip api.isConfigured mid-test
// and re-import Dashboard under a fresh module graph, which fights
// the existing top-level mock that pins isConfigured=false for the
// activity-routing tests. Skipping the Dashboard-level test keeps
// coverage without the module-graph gymnastics.
// ---------------------------------------------------------------------------
+30
View File
@@ -20,9 +20,15 @@ import { eventKindToUrl } from "@/lib/event-routing";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { useDashboardKpis } from "@/hooks/useDashboardKpis"; import { useDashboardKpis } from "@/hooks/useDashboardKpis";
import { useActivity } from "@/hooks/useActivity"; import { useActivity } from "@/hooks/useActivity";
import { useBatches } from "@/hooks/useBatches";
import { RecentBatchesWidget } from "@/components/RecentBatchesWidget";
import { toast } from "sonner"; import { toast } from "sonner";
const MONTHS_BACK = 6; const MONTHS_BACK = 6;
// SP30: cap the "Recent batches" widget at 5 rows. Hard-coded constant
// so changing it is one edit. Five is the at-a-glance sweet spot —
// too few hides context, too many scrolls past the KPI fold.
const RECENT_BATCHES_LIMIT = 5;
// Zero-shaped KPI totals used when the server response hasn't arrived // Zero-shaped KPI totals used when the server response hasn't arrived
// yet or the backend isn't configured. Mirrors the empty-DB shape of // yet or the backend isn't configured. Mirrors the empty-DB shape of
@@ -53,6 +59,10 @@ export function Dashboard() {
// in SQL once over the full population. // in SQL once over the full population.
const kpisQuery = useDashboardKpis({ months: MONTHS_BACK }); const kpisQuery = useDashboardKpis({ months: MONTHS_BACK });
const activityQuery = useActivity({ limit: 10 }); const activityQuery = useActivity({ limit: 10 });
// SP30: "Recent batches" widget data. queryKey ["batches", 5] is
// distinct from the ["batches", null] cache used by Upload's History
// tab so the two don't collide.
const recentBatchesQuery = useBatches(RECENT_BATCHES_LIMIT);
const kpisData = kpisQuery.data; const kpisData = kpisQuery.data;
// Fall back to a zero-shaped object so the KpiTiles still render a // Fall back to a zero-shaped object so the KpiTiles still render a
@@ -244,6 +254,26 @@ export function Dashboard() {
</DrillableCell> </DrillableCell>
</section> </section>
{/* SP30: Recent batches — how the last few batches billed out.
Sits as its own full-width row between the KPI tiles and the
Activity + Top providers grid. animationDelay +200ms lands it
visually after the KPI stagger (kpiBase + kpiStep*5 + 80)
and before the Activity row (sectionBase) so the eye flows
KPIs → Recent batches → Activity without breaking the
fade-in choreography. */}
<section
className="animate-fade-in-up"
style={{ animationDelay: `${sectionBase + 200}ms` }}
>
<RecentBatchesWidget
batches={recentBatchesQuery.data ?? []}
limit={RECENT_BATCHES_LIMIT}
onRowClick={(id) =>
navigate(`/batches?batch=${encodeURIComponent(id)}`)
}
/>
</section>
{/* Activity + Top providers */} {/* Activity + Top providers */}
<section <section
className="grid gap-4 lg:grid-cols-3 animate-fade-in-up" className="grid gap-4 lg:grid-cols-3 animate-fade-in-up"