12 KiB
SP30 — Dashboard "Recent batches" widget implementation plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a single Dashboard card that lists the 5 most recently parsed batches with one-line outcome per batch (status icon, filename, billed total, accepted/rejected/pending counts, top rejection reason). The operator can spot a bad batch — full envelope reject, sudden rejection spike — the moment they log in, without clicking through to /batches.
Architecture: Extend the existing GET /api/batches list response with billing-outcome fields (one batched SQL aggregate per request, no N+1). Extend the frontend BatchSummary TS type with the new fields (all optional — backwards compatible). Build a new RecentBatchesWidget component that mirrors the existing Dashboard "Recent activity" Card chrome. Wire it into the Dashboard between the KPI tile row and the Activity row. Use the existing useBatches(limit) hook (queryKey ["batches", 5]). Click → navigate to /batches?batch=ID opening the existing BatchDrawer.
Tech Stack: Python 3.11+, FastAPI, SQLAlchemy 2.x, SQLite (encrypted via SQLCipher), React 18 + TypeScript + Vite, TanStack Query, lucide-react.
File map
| File | Change |
|---|---|
backend/src/cyclone/api.py |
Add _batch_summary_billing_outcomes helper. Extend list_batches handler to attach the new fields per item. |
backend/tests/test_api_gets.py |
+2 tests (test_batches_includes_billing_outcome, test_batches_835_kind_returns_zero_billed_and_no_rejection). |
src/lib/api.ts |
+6 optional fields on BatchSummary. |
src/components/RecentBatchesWidget.tsx |
NEW component. |
src/pages/Dashboard.tsx |
Insert widget between KPI tiles and Activity row. |
src/components/RecentBatchesWidget.test.tsx |
NEW, 3 tests. |
src/pages/Dashboard.test.tsx |
+1 test (widget renders + click navigates). |
Step 1: Backend — extend list_batches with billing-outcome fields
- Open
backend/src/cyclone/api.pyand readlist_batchesat lines 1769-1808 plus the existing helpers at 1740-1764. Note the pattern. - Add a new helper
_batch_summary_billing_outcomes(records)near the existing helpers (after line 1764). It:- Opens a single
db.SessionLocal()() as ssession. - Collects batch ids from
recordsvia[r.id for r in records]. Returns{}if empty. - Runs one GROUP BY query:
from sqlalchemy import func from cyclone.db import Claim, ClaimState rows = ( s.query(Claim.batch_id, Claim.state, func.count(Claim.id), func.sum(Claim.charge_amount)) .filter(Claim.batch_id.in_(batch_ids)) .group_by(Claim.batch_id, Claim.state) .all() ) - Buckets into a
{batch_id: {"accepted": int, "rejected": int, "pending": int, "billed": float}}dict. Define the state buckets:accepted=PAID, RECEIVED, RECONCILED, PARTIALrejected=REJECTED, DENIED, REVERSEDpending=SUBMITTED, DRAFT(everything else is "other" — not surfaced on the widget)
- Runs the rejection-reason probe (only when there's at least one rejected claim across all batches — skip the query otherwise):
Bucket into
rejected_states = (ClaimState.REJECTED, ClaimState.DENIED, ClaimState.REVERSED) rej = ( s.query(Claim.batch_id, Claim.rejection_reason, Claim.payer_rejected_status_code) .filter(Claim.batch_id.in_(batch_ids), ((Claim.state.in_(rejected_states)) | (Claim.payer_rejected_status_code.in_(("A4","A6","A7"))))) .order_by(Claim.rejected_at.desc().nullslast()) .all() ){batch_id: {"top_rejection_reason": str|null, "has_payer_reject": bool}}. - Truncate
top_rejection_reasonto 60 chars with…suffix. - Returns
{batch_id: {accepted, rejected, pending, billed, top_rejection_reason, has_problem}}wherehas_problem = rejected > 0 or has_payer_reject.
- Opens a single
- Update
list_batcheshandler (line 1783-1793) to:- Call
_batch_summary_billing_outcomes(records)afterrecords = store.list(limit=limit). - For each item in the constructed
itemslist, look upoutcomes = outcomes_by_id.get(r.id, {})and add:acceptedCount,rejectedCount,pendingCount,billedTotal,topRejectionReason,hasProblem(defaulting sensibly when missing — e.g.acceptedCount=0,hasProblem=False).
- Call
- Confirm the existing
test_batches_*tests intest_api_gets.pystill pass (the new fields are added, none removed — existing assertions onkind,inputFilename,claimIdsare untouched).
Step 2: Backend tests
- Open
backend/tests/test_api_gets.py. Add the 2 tests after the existing 4test_batches_*tests (after line 101). test_batches_includes_billing_outcome— seed one batch via direct ORM insert (use the_add_batch/_add_claimhelper pattern fromtest_dashboard_kpis.py:36-91— these helpers don't exist intest_api_gets.pyso inline them):from datetime import datetime, timezone from decimal import Decimal from cyclone.db import Batch, Claim, ClaimState, db as db_module with db_module.SessionLocal()() as s: s.add(Batch(id="b-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={"_": "stub"})) s.add(Claim(id="C-paid", batch_id="b-out", patient_control_number="PCN-paid", charge_amount=Decimal("100.00"), state=ClaimState.PAID, raw_json={"subscriber": {"first_name":"Jane","last_name":"Doe"}, "payer": {"name":"CO_TXIX"}, "billing_provider": {"npi":"1234567893"}, "service_lines": []})) s.add(Claim(id="C-rej", batch_id="b-out", patient_control_number="PCN-rej", charge_amount=Decimal("50.00"), state=ClaimState.REJECTED, rejection_reason="999 AK5 R", raw_json={"subscriber": {"first_name":"Jane","last_name":"Doe"}, "payer": {"name":"CO_TXIX"}, "billing_provider": {"npi":"1234567893"}, "service_lines": []})) s.add(Claim(id="C-sub", batch_id="b-out", patient_control_number="PCN-sub", charge_amount=Decimal("75.00"), state=ClaimState.SUBMITTED, raw_json={"subscriber": {"first_name":"Jane","last_name":"Doe"}, "payer": {"name":"CO_TXIX"}, "billing_provider": {"npi":"1234567893"}, "service_lines": []})) s.commit() resp = client.get("/api/batches") item = resp.json()["items"][0] 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 Truetest_batches_835_kind_returns_zero_billed_and_no_rejection— seed one 835 via/api/parse-835(uses existing fixture pattern intest_batches_claim_ids_empty_for_835at lines 85-101), assert the new fields are zeroed out:acceptedCount=0, rejectedCount=0, pendingCount=0, billedTotal=0, topRejectionReason=None, hasProblem=False.- Run:
cd backend && .venv/bin/pytest tests/test_api_gets.py -v. Both new tests pass; existing 4 still pass.
Step 3: Frontend types — extend BatchSummary
- Open
src/lib/api.tsline 245-259. Add 6 optional fields to theBatchSummaryinterface:/** Number of claims on the batch whose state is paid / received / partial / reconciled. */ acceptedCount?: number; /** Number of claims whose state is rejected / denied / reversed. */ rejectedCount?: number; /** Number of claims whose state is submitted / draft. */ pendingCount?: number; /** Sum of charge_amount across all claims on the batch (837P only; 0 for 835). */ billedTotal?: number; /** Most recent rejection_reason on the batch (truncated to 60 chars). Null when no rejections. */ topRejectionReason?: string | null; /** True when the batch has rejections OR any 277CA A4/A6/A7 payer-reject claim. */ hasProblem?: boolean;
Step 4: Frontend component — RecentBatchesWidget
- Create
src/components/RecentBatchesWidget.tsx. Build per the spec's row markup (Card chrome mirroring Dashboard.tsx:252-287). - Add
data-testid="recent-batches-widget"on the outer<Card>for Playwright + the Dashboard integration test. - Import
Layers,CheckCircle2,AlertTrianglefromlucide-react. - Empty state: "No batches yet." centered with
text-[13px] text-muted-foreground px-2 py-6. - Row markup per spec —
role="button" tabIndex={0}+onClick+onKeyDownfor Enter key.
Step 5: Frontend wire-up — Dashboard
- Open
src/pages/Dashboard.tsx. Add imports:import { useBatches } from "@/hooks/useBatches"; import { RecentBatchesWidget } from "@/components/RecentBatchesWidget"; - Inside the
Dashboardcomponent (afterconst activity = activityQuery.data?.items ?? [];on line 81), add:const RECENT_BATCHES_LIMIT = 5; const recentBatchesQuery = useBatches(RECENT_BATCHES_LIMIT); - Insert a new
<section>BETWEEN line 245 (</section>for KPI tiles) and line 247 ({/* Activity + Top providers */}):<section className="animate-fade-in-up" style={{ animationDelay: `${sectionBase + 200}ms` }} > <RecentBatchesWidget batches={recentBatchesQuery.data ?? []} onRowClick={(id) => navigate(`/batches?batch=${encodeURIComponent(id)}`)} /> </section>
Step 6: Frontend tests — component + Dashboard
- Create
src/components/RecentBatchesWidget.test.tsxwith 3 tests (empty state, 2-batch render, 835 row). Mirror the patterns fromsrc/components/ActivityFeed.test.tsx(if present) orsrc/pages/Batches.test.tsx. - Open
src/pages/Dashboard.test.tsx. Read its existing setup. AddSP30: Recent batches widget renders + click navigates to /batches?batch=ID:- Mock
api.listBatchesto resolve with 2 batches. - Render
<Dashboard />insideMemoryRouter + QueryClientProvider. - Wait for the widget's testid.
- Click the first row's
<li role="button">. - Assert URL is
/batches?batch=ID.
- Mock
- Run:
npx vitest run src/components/RecentBatchesWidget.test.tsx src/pages/Dashboard.test.tsx— all green.
Step 7: Per-tree verification
cd backend && .venv/bin/pytest— full backend suite passes (no regressions).npx vitest run— full frontend suite passes (no regressions).npm run typecheck— 0 errors in SP30 files (existing baseline errors inUpload.tsxare acceptable).npm run lint— 0 errors in SP30 files.
Step 8: Atomic merge + deploy
- Commit:
feat(sp30): Dashboard Recent batches widget with billing outcome. git checkout main && git merge --no-ff sp30-recent-batches-widget -m "merge: SP30 Dashboard Recent batches widget into main".docker compose build backend frontend— rebuild both images (backend has the new endpoint fields; frontend has the new widget).docker compose up -d backend frontend— restart.- Playwright smoke test:
- Login
- Navigate to
/ - Wait for
[data-testid="recent-batches-widget"] - Screenshot
/tmp/dashboard-sp30.png - Click top row → assert URL changes
- Screenshot
/tmp/dashboard-sp30-clicked.png
- Show user the screenshots + the curl response from
/api/batchesconfirming the new fields.