Files
cyclone/docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md
T

361 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sub-project 21 — Universal Drill-Down: Design Spec
**Date:** 2026-06-21
**Status:** Draft (awaiting user review)
**Branch:** `universal-drilldown`
**Aesthetic direction:** Reuse the existing dashboard / drawer aesthetic (Radix Dialog + dim overlay, accent-tinted hover affordance). No new visual tokens.
## 1. Scope
Every interactive surface in the Cyclone UI becomes drillable. Clicking an entity reference (claim id, patient, provider, payer, batch, ack, activity event) opens a contextual view — either a full-record drawer (slides in from the right) or a slim peek modal (centered), depending on whether the entity is a first-class drillable record or a cross-reference.
The goal is **"dial down to anything"**: from the dashboard, from any list row, from inside any drawer, the operator can reach the underlying record with one click without losing their place.
**In scope:**
- New `PeekModal` primitive (centered modal) for cross-reference drills
- Reuse / refactor of the existing right-side `DrillDrawer` pattern for full-entity records
- New `DrillStackProvider` context to manage a max-2-level stack (one drawer + one peek)
- Hover-reveal affordance (pointer + accent tint + trailing chevron) on every clickable cell
- Per-surface drill map (every click target → its destination)
- 4 new backend read endpoints (`/api/providers/{npi}/claims`, `/api/providers/{npi}/activity`, `/api/payers/{payer_id}/summary`, `/api/activity/{event_id}`); the rest of the work is frontend wiring
**Out of scope (deferred):**
- **Patient peek modal** — patient names are PHI; a peek would surface sensitive fields. Patient-name clicks instead navigate to `/claims?patient=…` filtered list (no new endpoint needed; uses existing `/api/claims?patient_name=…`).
- **Write actions from peeks** — peeks are read-only. Edit / resubmit / acknowledge actions remain on the full drawer / inbox lane.
- **Mobile bottom-sheet variant** — desktop-only for v1. Mobile fallback: peeks become centered modals at full width with the same max-height cap.
- **Customizing peek fields** — peek content is fixed per surface. The existing drawer already covers "I want everything."
- **KPI breakdown peeks** — dashboard KPI tiles navigate to filtered `/claims` (the user's picked data-strategy was "fetch fresh on click"; navigation keeps the dashboard fast).
- **Auto-suggest next drill** — no "you might also want to see…" hints.
## 2. Locked decisions
### 2.1 Component decomposition
| Component | Role |
|---|---|
| `DrillDrawer.tsx` | Right-side slide-over for full-record drills (claim, remit, batch, provider, ack). Refactor of today's `ClaimDrawer` pattern into a shared shell that all entity drawers mount inside. |
| `DrillDrawerHeader.tsx` | Drawer header: eyebrow, title, close button, optional primary action (e.g. "Download 837" on the ClaimDrawer). |
| `PeekModal.tsx` | Centered Radix Dialog for cross-reference drills (payer, activity event, validation issue, single-row tables like Inbox rows, Batch diff rows). Smaller (≤ 480px), no keyboard j/k nav (single record), closes on Esc + click-outside. |
| `PeekModalHeader.tsx` | Eyebrow + title + close button. |
| `DrillStackProvider.tsx` | React context + zustand-backed store. Owns the stack of `[{ kind: 'drawer' | 'peek', key: string, payload?: unknown }]`. Exposes `open()`, `closeTop()`, `closeAll()`. Enforces max 2 levels. |
| `DrillableCell.tsx` | Tiny wrapper that adds the hover-reveal affordance (pointer + accent tint + trailing chevron) and the click handler. Used inside tables, KPI tiles, activity feeds. |
| `ProviderDrawer.tsx` | New full-entity drawer for `/api/providers/{npi}`. Tabs: Overview / Claims / Activity. |
| `AckDrawer.tsx` | New full-entity drawer for `/api/activity/{event_id}`? No — for `/api/acks/{id}` (reuses existing `GET /api/acks/{id}`). |
| `RemitDrawer.tsx` | New full-entity drawer for `/api/remittances/{id}`. Already documented as "deferred to a follow-up" in the SP4 claim-drawer spec; SP21 ships it. |
| `useDrillStack.ts` | Hook wrapper around `DrillStackProvider`'s store, with selector helpers for "is this drawer open?", "open this drawer", "the active peek payload". |
| `PayerPeekContent.tsx` | Payer peek body: name, claim count, billed, received, denial rate, "view all claims →" link. |
| `ActivityPeekContent.tsx` | Activity event peek body: kind, timestamp, entity id link, optional payload preview (e.g. claim ids touched). |
All live under `src/components/drill/` (new shared folder) plus `src/components/ProviderDrawer/`, `src/components/AckDrawer/`, `src/components/RemitDrawer/`.
### 2.2 Drill map (per surface)
The complete click → destination table. Each row is one click target on one surface. "Stack position" describes where the destination lives in the drill stack at the moment of the click.
| Surface | Click target | Destination | Stack position |
|---|---|---|---|
| Dashboard · KPI: Claims | tile | navigate → `/claims` | — |
| Dashboard · KPI: Billed | tile | navigate → `/claims?sort=-billedAmount` | — |
| Dashboard · KPI: Received | tile | navigate → `/claims?sort=-receivedAmount` | — |
| Dashboard · KPI: Pending AR | tile | navigate → `/claims?status=submitted,pending` | — |
| Dashboard · KPI: Denial rate | tile | navigate → `/claims?status=denied` | — |
| Dashboard · Top providers | any row | `ProviderDrawer(?provider=NPI)` | top drawer |
| Dashboard · Recent denials | any row | `DrillDrawer(claim, ?claim=ID)` | top drawer |
| Dashboard · Recent activity | any event | routed by event kind: `claim_*` → claim drawer, `remit_received` → remit drawer, `provider_added` → provider drawer | top drawer |
| Claims · table row | row body | `DrillDrawer(claim, ?claim=ID)` | top drawer |
| Claims · claim id cell | id | `DrillDrawer(claim, ?claim=ID)` | top drawer |
| Claims · patient cell | name | navigate → `/claims?patient=NAME` | — |
| Claims · provider cell | provider name + NPI | `ProviderDrawer(?provider=NPI)` | top drawer |
| Claims · payer cell | payer name | `PeekModal(payer)` | top peek |
| Remittances · table row | row body | `RemitDrawer(?remit=ID)` (replaces the inline CAS expand) | top drawer |
| Remittances · claim id cell | id | `DrillDrawer(claim, ?claim=ID)` | top drawer |
| Remittances · payer cell | payer name | `PeekModal(payer)` | top peek |
| Batches · table row | row body | `BatchDetail` drawer (?batch=ID) | top drawer |
| Providers · directory card | card body | `ProviderDrawer(?provider=NPI)` | top drawer |
| Activity log · event row | row body | routed by event kind (same as Dashboard · Recent activity) | top drawer |
| 999 ACKs · table row | row body | `AckDrawer(?ack=ID)` | top drawer |
| 999 ACKs · Download 999 button | button | (unchanged — file download) | — |
| Inbox · Rejected row | row body | `DrillDrawer(claim, ?claim=ID)` | top drawer |
| Inbox · Payer-Rejected row | row body | `DrillDrawer(claim, ?claim=ID)` | top drawer |
| Inbox · Candidates row | row body | `RemitDrawer(?remit=ID)` | top drawer |
| Inbox · Unmatched claim row | row body | `DrillDrawer(claim, ?claim=ID)` | top drawer |
| Inbox · Unmatched remit row | row body | `RemitDrawer(?remit=ID)` | top drawer |
| Inbox · Done today row | row body | routed by row kind (claim vs remit) | top drawer |
| Reconciliation · claim button | button | (unchanged — select for match) | — |
| Reconciliation · claim card body | body | `PeekModal(claim)` | top peek |
| Reconciliation · remit card body | body | `PeekModal(remit)` | top peek |
| Batch diff · claim id (added) | id | `DrillDrawer(claim, ?claim=ID)` | top drawer |
| Batch diff · claim id (removed) | id | `DrillDrawer(claim, ?claim=ID)` (will 404 if removed — show distinct not-found state) | top drawer |
| Batch diff · claim id (changed) | id | `DrillDrawer(claim, ?claim=ID)` | top drawer |
| Upload · streamed claim card | header / chevron | (unchanged — inline expand) | — |
| Upload · streamed claim card | "See claim in detail →" link (new) | `DrillDrawer(claim, ?claim=ID)` (only enabled if the claim id is in the persisted batch list, else disabled with tooltip) | top drawer |
| ClaimDrawer · payer name (inside drawer) | payer name | `PeekModal(payer)` | peek on top of drawer |
| ClaimDrawer · provider name (inside drawer) | provider name | `ProviderDrawer(?provider=NPI)` (replaces drawer) | top drawer (replaces) |
| ClaimDrawer · matched-remit id | id | `RemitDrawer(?remit=ID)` (replaces drawer) | top drawer (replaces) |
| ClaimDrawer · validation issue row | rule code | `PeekModal(validation issue)` | peek on top of drawer |
**Nesting rules** (enforced by `DrillStackProvider`):
1. Max 2 levels: one drawer at the bottom + at most one peek on top. Opening a third drill replaces the top peek (same kind) or pops the peek (different kind).
2. Clicking inside a peek to open a drawer: closes the peek first, opens the drawer (no drawer-over-peek).
3. Esc closes the top entry (peek or drawer). Browser back closes the top drawer (URL pops). Browser back while only peeks are open: no-op (peek stack isn't URL-persisted).
### 2.3 Backend endpoints
**New:**
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/providers/{npi}/claims` | Paginated claims for a provider. Mirrors `/api/claims` filter shape but pre-scoped to the provider. Limit/offset/sort/order all honored. |
| `GET` | `/api/providers/{npi}/activity` | Paginated activity for a provider. Mirrors `/api/activity` filter shape pre-scoped to the provider. |
| `GET` | `/api/payers/{payer_id}/summary` | Aggregate snapshot for a payer: `{ payer_id, name, claim_count, billed_total, received_total, denial_rate, top_providers: [{npi, count}] }`. Computed on read from the existing `claims` + `remittances` tables. Cached for 60s. |
| `GET` | `/api/activity/{event_id}` | One activity event with its full payload. Used by the ActivityPeekContent. |
**Reused as-is:**
- `GET /api/claims/{id}` — already exists (SP4). Used by `DrillDrawer(claim)`.
- `GET /api/remittances/{id}` — already exists (SP3). Used by `RemitDrawer`.
- `GET /api/batches/{id}` — already exists. Used by `BatchDetail`.
- `GET /api/providers/{npi}` — already exists (SP9). Used by `ProviderDrawer` Overview tab.
- `GET /api/config/payers/{payer_id}` — already exists (SP9). Used by the payer peek header.
- `GET /api/acks/{id}` — already exists (SP3). Used by `AckDrawer`.
**Implementation notes:**
- Add the four new handlers in `backend/src/cyclone/api_routers/` (split: providers gets two new methods, payers gets one, activity gets one).
- Payer summary cache lives in a module-level `lru_cache` with a TTL guard (60s); invalidated on a `claim_written` / `remittance_written` pubsub event.
- All four new endpoints inherit the existing security middleware stack (SP19): `127.0.0.1` bind, body limit, rate limit, security headers.
### 2.4 Visual design tokens
No new tokens. Reuse:
- `--surface` / `--surface-dim` for drawer + dim overlay
- `--accent` for hover tint (the same `--accent` used by today's selected-row indicator + nav-active)
- `border-border/60` for the peek modal border
- Existing `display mono` / `eyebrow` typography for peek headers
- `animate-fade-in` / `animate-fade-in-up` for peek/drawer enter
Peek modal sizing:
```
max-width: 480px (or 90vw on narrow viewports)
padding: 20px
border: 1px solid var(--border)
shadow: shadow-2xl
border-radius: rounded-xl (matches today's Radix Dialog)
```
Hover affordance CSS:
```css
.drillable {
cursor: pointer;
transition: background-color 120ms ease;
}
.drillable:hover {
background-color: hsl(var(--accent) / 0.08);
}
.drillable:hover::after {
content: "";
margin-left: 6px;
color: hsl(var(--accent));
font-weight: 600;
}
```
The trailing chevron is injected via `::after` so `DrillableCell` doesn't need to compose a child element. Disabled cells (e.g. the "See claim in detail" link in Upload when the claim isn't persisted) drop the affordance and the click handler.
### 2.5 Interaction patterns
**Open (drawer):**
- Click target → URL updates to `?{entity}={id}` via `history.pushState` (or `replaceState` if same entity type, to avoid history pollution).
- Drawer slides in from right, 240ms ease-out (same as today's ClaimDrawer).
- Background page dims to 60% opacity.
**Open (peek):**
- Click target → zustand stack updated; peek fades in centered, 180ms.
- Background dims to 75% (more aggressive than the drawer's 60%, to differentiate).
- No URL change (peek stack is ephemeral).
**Open (peek on top of drawer):**
- Click target inside drawer body → zustand stack gains one entry.
- Peek renders above drawer; drawer stays in place, dims to 75%.
- Drawer's existing keyboard nav (j/k, ?) is suspended while a peek is on top.
**Close:**
- Esc → pops the top stack entry. If peek was top, peek closes (drawer underneath is interactive again). If only a drawer, URL strips `?{entity}=`.
- Click on dim overlay → same as Esc.
- Browser back → pops URL drawer only (no peek history).
**Loading:**
- Drawer: existing `Skeleton` + `ErrorState` patterns reused.
- Peek: a 6-line shimmer skeleton, max 280px height. Fetch errors collapse the peek and show an inline error toast (peek is small enough that an inline state would look broken).
**Errors:**
- 404 on a drawer → existing `ErrorState` "not found" branch (per drawer component).
- 404 on a peek → toast: "Couldn't find that record. It may have been removed." and peek auto-closes after 1.5s.
- Network error → toast with Retry button.
**Keyboard:**
- Drawer nav: j/k, ?, esc (unchanged from today).
- Peek nav: esc, Tab cycles focusable elements inside the peek body. No j/k (single record, no list).
- Global `?` cheatsheet: extended to list peek keyboard hints (currently lists only drawer hints).
### 2.6 URL state contract
- `/claims` — list, no drawer.
- `/claims?claim=CLM-114` — list with drawer for CLM-114.
- `/remittances?remit=R-9876` — list with remit drawer.
- `/providers?provider=1881068062` — directory with provider drawer.
- `/batches?batch=BATCH-…` — list with batch drawer.
- `/acks?ack=42` — list with ack drawer.
- `/activity?event=evt-uuid` — list with activity event drawer.
Each page's `useDrawerUrlState` hook handles its own `?{entity}=` param. Opening a different entity-type drawer from inside a page navigates to the page that owns that entity-type drawer (e.g. clicking a payer name on `/claims` opens a payer peek — no nav; but clicking a claim id on `/remittances` opens the claim drawer, which is the `/claims` page's drawer — this case keeps the operator on `/remittances` and uses a global drawer pattern). To keep things simple in v1: **all entity drawers mount from the page that owns them**, and clicking a cross-page entity from inside another page **navigates to that entity's owning page with `?{entity}=` set**. The page transition is a soft fade (no full reload). Peeks never trigger navigation.
### 2.7 Frontend file layout
```
src/
├── components/
│ ├── drill/ # NEW shared folder
│ │ ├── DrillDrawer.tsx
│ │ ├── DrillDrawerHeader.tsx
│ │ ├── PeekModal.tsx
│ │ ├── PeekModalHeader.tsx
│ │ ├── DrillStackProvider.tsx
│ │ ├── DrillStackMount.tsx # the actual portal mount + z-index mgmt
│ │ ├── DrillableCell.tsx
│ │ ├── PayerPeekContent.tsx
│ │ ├── ActivityPeekContent.tsx
│ │ ├── DrillStackProvider.test.tsx
│ │ ├── DrillableCell.test.tsx
│ │ ├── PeekModal.test.tsx
│ │ └── index.ts # barrel export
│ ├── ProviderDrawer/ # NEW
│ │ ├── ProviderDrawer.tsx
│ │ ├── ProviderOverview.tsx
│ │ ├── ProviderClaims.tsx # paginated mini-table
│ │ ├── ProviderActivity.tsx # paginated mini-feed
│ │ └── index.ts
│ ├── RemitDrawer/ # NEW (some files exist per tree)
│ │ ├── RemitDrawer.tsx
│ │ ├── RemitHeader.tsx
│ │ ├── ClaimPaymentsTable.tsx
│ │ ├── CasAdjustmentsPanel.tsx
│ │ ├── RemitPartiesGrid.tsx
│ │ └── index.ts
│ ├── AckDrawer/ # NEW
│ │ ├── AckDrawer.tsx
│ │ ├── AckHeader.tsx
│ │ ├── SegmentStatusList.tsx
│ │ └── index.ts
│ └── ClaimDrawer/ # existing — refactor header to use DrillDrawerHeader
├── hooks/
│ ├── useDrillStack.ts # NEW
│ ├── useProviderClaims.ts # NEW
│ ├── useProviderActivity.ts # NEW
│ ├── usePayerSummary.ts # NEW
│ ├── useActivityEvent.ts # NEW
│ └── useProviderDetail.ts # NEW (wraps existing api.getProvider)
├── lib/
│ └── api.ts # +api.getProviderClaims, getProviderActivity, getPayerSummary, getActivityEvent
└── pages/ # wire drills per the drill map; replace ?claim= etc. state hooks with the shared useDrillStack hooks
├── Claims.tsx
├── Remittances.tsx
├── Dashboard.tsx
├── Batches.tsx
├── Providers.tsx
├── Acks.tsx
├── ActivityLog.tsx
├── Reconciliation.tsx
├── BatchDiff.tsx
├── Upload.tsx
└── Inbox.tsx
```
### 2.8 Testing targets
**Frontend (Vitest + React Testing Library):**
- `DrillStackProvider.test.tsx` — max-2-level rule, peek-over-drawer, drawer-over-peek prevention, esc closes top, closeAll resets stack
- `DrillableCell.test.tsx` — renders chevron on hover, fires onClick, disabled state hides chevron + blocks click
- `PeekModal.test.tsx` — opens via context, closes on esc, closes on click-outside, 404 → toast + auto-close, network error → retry toast
- `ProviderDrawer.test.tsx` — Overview tab shows NPI + address + counts; Claims tab paginates; Activity tab paginates; j/k nav (if claim ids in scope)
- `RemitDrawer.test.tsx` — header summary, claim payments table, CAS panel, payer cell opens payer peek
- `AckDrawer.test.tsx` — header (source batch, ack code), per-segment status list, download button (unchanged)
- `PayerPeekContent.test.tsx` — loading skeleton, populated peek, "view all claims" link href
- `ActivityPeekContent.test.tsx` — empty payload, claim_* payload, remit_received payload
- Page tests (per-page drill map coverage): one test per drill target verifying the click routes correctly
**Backend (pytest):**
- `test_provider_claims_endpoint` — filters correctly, paginates, 404 on unknown NPI
- `test_provider_activity_endpoint` — filters by kind, paginates, 404 on unknown NPI
- `test_payer_summary_endpoint` — happy path, cache hit/miss, invalidation on `claim_written`
- `test_activity_event_endpoint` — happy path, 404 on unknown event id
**Target counts:** ~25 new frontend tests, ~6 new backend tests. All 249 existing frontend tests + 409 existing backend tests must stay green.
### 2.9 Smoke test (end-of-SP21)
1. Start backend (`backend/.venv/bin/python -m cyclone serve`).
2. Start frontend (`npm run dev`).
3. Open `http://localhost:5173/`.
4. **Dashboard:** click a KPI tile → page navigates to `/claims` with the right filter.
5. **Dashboard:** click a top-providers row → provider drawer opens from the right.
6. **Dashboard:** click a recent-denials row → claim drawer opens.
7. **Dashboard:** click a recent-activity event of kind `claim_paid` → claim drawer opens for the underlying claim.
8. **Claims:** click a claim row → claim drawer opens (already worked, regression check).
9. **Claims:** hover the patient name cell → cursor + tint + chevron appear. Click → navigates to `/claims?patient=NAME`.
10. **Claims:** hover the provider cell → affordance. Click → provider drawer opens.
11. **Claims:** hover the payer cell → affordance. Click → payer peek modal opens (centered).
12. **Claims:** with the claim drawer open, click the payer name inside the drawer → peek opens on top of the drawer. Esc → peek closes; drawer still there. Esc again → drawer closes.
13. **Remittances:** click a remit row → remit drawer opens (was: inline expand only).
14. **Remittances:** click the claim id cell → claim drawer opens.
15. **Providers:** click a card → provider drawer opens.
16. **Acks:** click a row → ack drawer opens (was: only the Download button worked).
17. **Activity log:** click a `remit_received` event → remit drawer opens.
18. **Inbox:** click a rejected row → claim drawer opens (was: no-op).
19. **Inbox:** click a candidates row → remit drawer opens (was: no-op).
20. **Reconciliation:** click the body of an unmatched claim (not the select button) → claim peek modal opens.
21. **Batch diff:** click an added claim id → claim drawer opens.
22. **Upload:** click a streamed claim's "See claim in detail" link → claim drawer opens (if persisted).
23. Browser back from any drawer state → drawer closes.
24. Commit empty `smoke: end-to-end SP21 (universal drilldown) flow passes`.
## 3. Risk areas
1. **Stack explosion.** Without an enforcement point, it's easy to write code that opens a drawer from inside a peek from inside a drawer. The `DrillStackProvider` API only exposes `open` (which respects the max-2 rule) and `closeTop`. There's no `openOnTopOf` — the stack is implicit from context. Risk: future contributors might bypass the provider and mount `<PeekModal>` ad-hoc. Mitigation: the `<DrillStackMount>` portal at the root of `App.tsx` is the only place peeks render; mounting `<PeekModal>` outside it renders nothing.
2. **URL vs ephemeral state confusion.** Drawers persist in URL; peeks don't. If a user opens a peek, copies the URL, and pastes it later, the peek is gone. Acceptable trade-off — peeks are exploratory; drawers are shareable.
3. **Cross-page drawer navigation.** Clicking a claim id on `/remittances` navigates to `/claims?claim=…`. The user lands on the claims page, not on the remittances page with the claim drawer overlaid. This is intentional — the claim drawer lives on `/claims`. Side effect: the user's remittances filter context is lost on nav. Mitigation: the nav is a soft fade + the URL preserves `?status=` etc. on `/claims` so the user can navigate back with browser back.
4. **Drawer nav state across page navigation.** When the user clicks a claim id on `/remittances`, lands on `/claims?claim=…`, and presses browser back, they go back to `/remittances` (the remits filter is restored). The claim drawer closes. Tested in smoke step 23.
5. **Inbox row click vs checkbox click.** Inbox rows have multi-select checkboxes. Row body click opens the drawer; checkbox click toggles selection. The `DrillableCell` wrapper goes around the row body, NOT the checkbox. Tested in `Inbox.test.tsx`.
6. **Payer summary cache staleness.** The 60s cache + pubsub invalidation is best-effort. Worst case: a payer summary is up to 60s stale after a `claim_written`. Acceptable for v1.
7. **Patient name in URL.** Navigating to `/claims?patient=John%20Doe` puts the patient name in the URL bar. The existing ClaimDrawer spec already redacts patient names from logs but not from URL. Mitigation: when navigating from a patient cell, hash the name (sha256 first 8 chars) before putting it in the URL; the Claims page filter accepts the hash and looks up by hash. Alternative: just use the full name in URL — Cyclone is local-only (127.0.0.1) per project context. Going with the full name in URL for v1; flag for SP22 if multi-operator expansion happens.
8. **Drawer dim overlay z-index.** With a peek over a drawer, three z-index layers: page (z=0), drawer overlay (z=40), peek overlay (z=50). The `<DrillStackMount>` portal renders peeks in a dedicated DOM node at `z-50`; the existing drawer overlays at `z-40` continue to work. Tested in `PeekModal.test.tsx` (asserts peek is rendered above drawer overlay).
## 4. Migration / rollout
- Single feature branch `universal-drilldown` cut from `main`.
- Worktree at `.worktrees/universal-drilldown/`, following SP1-3 pattern.
- DB migrations: **none** (uses existing tables: `claims`, `remittances`, `batches`, `activity_events`, `providers`, `payers`, `acks`).
- No new dependencies (Radix Dialog + zustand already in `package.json`).
- Phasing within the branch (each PR is shippable independently, each closes one smoke step):
1. **PR1:** `DrillStackProvider` + `DrillableCell` + `PeekModal` primitives. Wire Dashboard KPI navigation + Dashboard Top providers drill. Backend: `/api/payers/{payer_id}/summary`. (Smoke steps 4-6.)
2. **PR2:** `ProviderDrawer` (full drawer, Overview tab only). Wire Dashboard Recent denials, Dashboard Recent activity, Providers page, Claims provider cell. Backend: `/api/providers/{npi}/claims`, `/api/providers/{npi}/activity`. (Smoke steps 7, 10, 15.)
3. **PR3:** `RemitDrawer`. Wire Remittances row click, Inbox candidates + unmatched remit rows, Activity `remit_received` events, Remittances claim-id cell, ClaimDrawer matched-remit link. (Smoke steps 13, 14, 17, 19.)
4. **PR4:** `AckDrawer`. Wire Acks row, ClaimDrawer payer peek. Backend: `/api/activity/{event_id}` (also used by step 5). (Smoke steps 12, 16.)
5. **PR5:** Wire the remaining surfaces: Activity log event click (PR4's backend), Inbox rejected + payer_rejected + done_today rows, Reconciliation body click, Batch diff claim id, Upload "See claim in detail" link. Refactor existing `ClaimDrawer` to use `DrillDrawer` shell. (Smoke steps 8, 9, 11, 18, 20, 21, 22, 23.)
- Merge to main after final PR + smoke pass; cleanup worktree + branch.