spec(SP21): universal drilldown design — drawers + peek modals everywhere (self-review fixes)

This commit is contained in:
Tyler
2026-06-21 11:21:58 -06:00
parent 02b06bf4d9
commit 2d7d73a9db
@@ -18,7 +18,7 @@ The goal is **"dial down to anything"**: from the dashboard, from any list row,
- New `DrillStackProvider` context to manage a max-2-level stack (one drawer + one peek) - 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 - Hover-reveal affordance (pointer + accent tint + trailing chevron) on every clickable cell
- Per-surface drill map (every click target → its destination) - 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 - 1 new backend read endpoint (`/api/payers/{payer_id}/summary`) plus an extended response shape on the existing `/api/providers/{npi}` (adds `recent_claims[]` and `recent_activity[]` arrays). The rest of the work is frontend wiring.
**Out of scope (deferred):** **Out of scope (deferred):**
@@ -41,12 +41,12 @@ The goal is **"dial down to anything"**: from the dashboard, from any list row,
| `PeekModalHeader.tsx` | Eyebrow + title + close button. | | `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. | | `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. | | `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. | | `ProviderDrawer.tsx` | New full-entity drawer for `/api/providers/{npi}`. Tabs: Overview (NPI + address + counts) / Claims (top 10 recent claims + "View all →" link) / Activity (top 10 recent activity + "View all →" link). |
| `AckDrawer.tsx` | New full-entity drawer for `/api/activity/{event_id}`? No — for `/api/acks/{id}` (reuses existing `GET /api/acks/{id}`). | | `AckDrawer.tsx` | New full-entity drawer for `/api/acks/{id}` (reuses the existing `GET /api/acks/{id}` endpoint). |
| `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. | | `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". | | `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. | | `PayerPeekContent.tsx` | Payer peek body (backend-fed): 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). | | `ValidationRulePeekContent.tsx` | Validation-rule peek body (static lookup, no backend call): rule code, severity, full description, X12 spec reference if any. Opened from ClaimDrawer validation issue rows. |
All live under `src/components/drill/` (new shared folder) plus `src/components/ProviderDrawer/`, `src/components/AckDrawer/`, `src/components/RemitDrawer/`. All live under `src/components/drill/` (new shared folder) plus `src/components/ProviderDrawer/`, `src/components/AckDrawer/`, `src/components/RemitDrawer/`.
@@ -84,8 +84,8 @@ The complete click → destination table. Each row is one click target on one su
| Inbox · Unmatched remit row | row body | `RemitDrawer(?remit=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 | | 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 button | button | (unchanged — select for match) | — |
| Reconciliation · claim card body | body | `PeekModal(claim)` | top peek | | Reconciliation · claim card body | body | `DrillDrawer(claim, ?claim=ID)` | top drawer (navigates to `/claims?claim=ID`) |
| Reconciliation · remit card body | body | `PeekModal(remit)` | top peek | | Reconciliation · remit card body | body | `RemitDrawer(?remit=ID)` | top drawer (navigates to `/remittances?remit=ID`) |
| Batch diff · claim id (added) | id | `DrillDrawer(claim, ?claim=ID)` | top drawer | | 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 (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 | | Batch diff · claim id (changed) | id | `DrillDrawer(claim, ?claim=ID)` | top drawer |
@@ -104,29 +104,33 @@ The complete click → destination table. Each row is one click target on one su
### 2.3 Backend endpoints ### 2.3 Backend endpoints
**New:** **New endpoint:**
| Method | Path | Purpose | | 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/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; invalidated on `claim_written` / `remittance_written` pubsub events. |
| `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. | **Extended endpoint (existing, response shape widened):**
| `GET` | `/api/activity/{event_id}` | One activity event with its full payload. Used by the ActivityPeekContent. |
| Method | Path | Change |
|---|---|---|
| `GET` | `/api/providers/{npi}` | Response gains `recent_claims: ClaimSummary[]` (top 10 by `submission_date DESC`) and `recent_activity: ActivityEvent[]` (top 10 by `ts DESC`). Backwards-compatible: existing clients ignore the new arrays. |
**Reused as-is:** **Reused as-is:**
- `GET /api/claims/{id}` — already exists (SP4). Used by `DrillDrawer(claim)`. - `GET /api/claims/{id}` — already exists (SP4). Used by `DrillDrawer(claim)`.
- `GET /api/remittances/{id}` — already exists (SP3). Used by `RemitDrawer`. - `GET /api/remittances/{id}` — already exists (SP3). Used by `RemitDrawer`.
- `GET /api/batches/{id}` — already exists. Used by `BatchDetail`. - `GET /api/batches/{id}` — already exists. Used by `BatchDetail`.
- `GET /api/providers/{npi}` — already exists (SP9). Used by `ProviderDrawer` Overview tab. - `GET /api/providers/{npi}` — already exists (SP9). Used by `ProviderDrawer` Overview tab AND Claims / Activity tabs (via the newly extended arrays).
- `GET /api/config/payers/{payer_id}` — already exists (SP9). Used by the payer peek header. - `GET /api/config/payers/{payer_id}` — already exists (SP9). Used by the payer peek header (the payer name).
- `GET /api/acks/{id}` — already exists (SP3). Used by `AckDrawer`. - `GET /api/acks/{id}` — already exists (SP3). Used by `AckDrawer`.
**Implementation notes:** **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). - Add the one new handler in `backend/src/cyclone/api_routers/payers.py`.
- Payer summary cache lives in a module-level `lru_cache` with a TTL guard (60s); invalidated on a `claim_written` / `remittance_written` pubsub event. - 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. - The new endpoint inherits the existing security middleware stack (SP19): `127.0.0.1` bind, body limit, rate limit, security headers.
- The extended `/api/providers/{npi}` response is bounded — only 10 + 10 entries per category — so the existing payload cap (per SP19 body limit, default 50 MB) is comfortably respected.
### 2.4 Visual design tokens ### 2.4 Visual design tokens
@@ -221,7 +225,7 @@ The trailing chevron is injected via `::after` so `DrillableCell` doesn't need t
- `/acks?ack=42` — list with ack drawer. - `/acks?ack=42` — list with ack drawer.
- `/activity?event=evt-uuid` — list with activity event 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. Each entity-drawer type has its own URL state hook following the existing per-page pattern (`useDrawerUrlState` for Claims, `useBatchDrawerUrlState` for Batches, plus a new `useProviderDrawerUrlState` for Providers, `useRemitDrawerUrlState` for Remittances, `useAckDrawerUrlState` for Acks). All hooks read & write the same `?{entity}={id}` convention. **Each drawer is mounted by the page that owns it** clicking a cross-page entity from inside another page navigates to that entity's owning page with `?{entity}={id}` set (soft fade, no full reload). The opening source's filter state (e.g. `/remittances?status=posted`) is preserved when navigating back via browser-back. Peeks never trigger navigation.
### 2.7 Frontend file layout ### 2.7 Frontend file layout
@@ -237,7 +241,7 @@ src/
│ │ ├── DrillStackMount.tsx # the actual portal mount + z-index mgmt │ │ ├── DrillStackMount.tsx # the actual portal mount + z-index mgmt
│ │ ├── DrillableCell.tsx │ │ ├── DrillableCell.tsx
│ │ ├── PayerPeekContent.tsx │ │ ├── PayerPeekContent.tsx
│ │ ├── ActivityPeekContent.tsx │ │ ├── ValidationRulePeekContent.tsx
│ │ ├── DrillStackProvider.test.tsx │ │ ├── DrillStackProvider.test.tsx
│ │ ├── DrillableCell.test.tsx │ │ ├── DrillableCell.test.tsx
│ │ ├── PeekModal.test.tsx │ │ ├── PeekModal.test.tsx
@@ -245,10 +249,10 @@ src/
│ ├── ProviderDrawer/ # NEW │ ├── ProviderDrawer/ # NEW
│ │ ├── ProviderDrawer.tsx │ │ ├── ProviderDrawer.tsx
│ │ ├── ProviderOverview.tsx │ │ ├── ProviderOverview.tsx
│ │ ├── ProviderClaims.tsx # paginated mini-table │ │ ├── ProviderRecentClaims.tsx # top-10 mini-table (data from extended /providers/{npi})
│ │ ├── ProviderActivity.tsx # paginated mini-feed │ │ ├── ProviderRecentActivity.tsx # top-10 mini-feed (data from extended /providers/{npi})
│ │ └── index.ts │ │ └── index.ts
│ ├── RemitDrawer/ # NEW (some files exist per tree) │ ├── RemitDrawer/ # NEW
│ │ ├── RemitDrawer.tsx │ │ ├── RemitDrawer.tsx
│ │ ├── RemitHeader.tsx │ │ ├── RemitHeader.tsx
│ │ ├── ClaimPaymentsTable.tsx │ │ ├── ClaimPaymentsTable.tsx
@@ -262,15 +266,14 @@ src/
│ │ └── index.ts │ │ └── index.ts
│ └── ClaimDrawer/ # existing — refactor header to use DrillDrawerHeader │ └── ClaimDrawer/ # existing — refactor header to use DrillDrawerHeader
├── hooks/ ├── hooks/
│ ├── useDrillStack.ts # NEW │ ├── useDrillStack.ts # NEW (zustand-backed ephemeral peek stack)
│ ├── useProviderClaims.ts # NEW
│ ├── useProviderActivity.ts # NEW
│ ├── usePayerSummary.ts # NEW │ ├── usePayerSummary.ts # NEW
│ ├── useActivityEvent.ts # NEW │ ├── useProviderDrawerUrlState.ts # NEW (?provider=)
── useProviderDetail.ts # NEW (wraps existing api.getProvider) ── useRemitDrawerUrlState.ts # NEW (?remit=)
│ └── useAckDrawerUrlState.ts # NEW (?ack=)
├── lib/ ├── lib/
│ └── api.ts # +api.getProviderClaims, getProviderActivity, getPayerSummary, getActivityEvent │ └── api.ts # +api.getPayerSummary(id); /api/providers/{npi} response widened in backend
└── pages/ # wire drills per the drill map; replace ?claim= etc. state hooks with the shared useDrillStack hooks └── pages/ # wire drills per the drill map; refactor existing useDrawerUrlState usages to follow the per-drawer convention
├── Claims.tsx ├── Claims.tsx
├── Remittances.tsx ├── Remittances.tsx
├── Dashboard.tsx ├── Dashboard.tsx
@@ -295,17 +298,16 @@ src/
- `RemitDrawer.test.tsx` — header summary, claim payments table, CAS panel, payer cell opens payer peek - `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) - `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 - `PayerPeekContent.test.tsx` — loading skeleton, populated peek, "view all claims" link href
- `ActivityPeekContent.test.tsx`empty payload, claim_* payload, remit_received payload - `ValidationRulePeekContent.test.tsx`known rule (R050_diagnosis_present) renders full description + X12 reference; unknown rule shows fallback copy
- Page tests (per-page drill map coverage): one test per drill target verifying the click routes correctly - Page tests (per-page drill map coverage): one test per drill target verifying the click routes correctly
**Backend (pytest):** **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_payer_summary_endpoint` — happy path, cache hit/miss, invalidation on `claim_written`
- `test_activity_event_endpoint` — happy path, 404 on unknown event id - `test_provider_detail_extended_response` — existing fields unchanged; new `recent_claims` + `recent_activity` arrays populated (10 each, sorted); empty arrays when the provider has no claims/activity
- `test_provider_detail_extended_response_backwards_compat` — clients that ignore the new arrays don't break; old fields identical to SP9 response shape
**Target counts:** ~25 new frontend tests, ~6 new backend tests. All 249 existing frontend tests + 409 existing backend tests must stay green. **Target counts:** ~25 new frontend tests, ~3 new backend tests. All 249 existing frontend tests + 409 existing backend tests must stay green.
### 2.9 Smoke test (end-of-SP21) ### 2.9 Smoke test (end-of-SP21)
@@ -328,7 +330,7 @@ src/
17. **Activity log:** click a `remit_received` event → remit drawer opens. 17. **Activity log:** click a `remit_received` event → remit drawer opens.
18. **Inbox:** click a rejected row → claim drawer opens (was: no-op). 18. **Inbox:** click a rejected row → claim drawer opens (was: no-op).
19. **Inbox:** click a candidates row → remit 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. 20. **Reconciliation:** click the body of an unmatched claim (not the select button) → navigates to `/claims?claim=ID` with the claim drawer open.
21. **Batch diff:** click an added claim id → claim drawer 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). 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. 23. Browser back from any drawer state → drawer closes.
@@ -342,7 +344,7 @@ src/
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. 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`. 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. 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. 7. **Patient name in URL.** Navigating to `/claims?patient=John%20Doe` puts the patient name in the URL bar. Cyclone is local-only (127.0.0.1) per project context, so this is acceptable for v1. Flagged for SP22 if multi-operator / networked expansion happens. The existing PII scrubber (SP18) only scrubs log lines, not URL bars.
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). 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 ## 4. Migration / rollout
@@ -351,10 +353,10 @@ src/
- Worktree at `.worktrees/universal-drilldown/`, following SP1-3 pattern. - Worktree at `.worktrees/universal-drilldown/`, following SP1-3 pattern.
- DB migrations: **none** (uses existing tables: `claims`, `remittances`, `batches`, `activity_events`, `providers`, `payers`, `acks`). - DB migrations: **none** (uses existing tables: `claims`, `remittances`, `batches`, `activity_events`, `providers`, `payers`, `acks`).
- No new dependencies (Radix Dialog + zustand already in `package.json`). - No new dependencies (Radix Dialog + zustand already in `package.json`).
- Phasing within the branch (each PR is shippable independently, each closes one smoke step): - Phasing within the branch (each PR is shippable independently, each closes its slice of smoke steps):
1. **PR1:** `DrillStackProvider` + `DrillableCell` + `PeekModal` primitives. Wire Dashboard KPI navigation + Dashboard Top providers drill. Backend: `/api/payers/{payer_id}/summary`. (Smoke steps 4-6.) 1. **PR1 — Foundation primitives + first 3 surfaces:** `DrillStackProvider` + `DrillableCell` + `PeekModal` primitives, the App-level `<DrillStackMount>` portal, the hover affordance CSS, the extended `/api/providers/{npi}` response. Wire Dashboard KPI navigation + Dashboard Top providers drill + Dashboard Recent denials drill. Backend: `/api/payers/{payer_id}/summary` (the actual provider-overview data lives in the extended `/api/providers/{npi}` response — no separate provider endpoints needed). (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.) 2. **PR2`ProviderDrawer` + activity event routing:** Build the ProviderDrawer (Overview tab only in this PR; Claims/Activity tabs come in PR3 with the recent_claims/recent_activity payload rendering). Wire Dashboard Recent activity for `claim_*` events (the activity → entity mapping lives in a small `eventKindToEntity()` helper introduced here). Wire Providers directory page + Claims provider cell. (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.) 3. **PR3 — `ProviderDrawer` tabs + remaining event routing:** Add the Claims and Activity tabs to ProviderDrawer, using the extended `/api/providers/{npi}` payload. Wire Dashboard Recent activity for `remit_received` and `provider_added` event kinds (still routed via the PR2 `eventKindToEntity()` helper; `provider_added` routes to ProviderDrawer, `remit_received` will route to the PR4 RemitDrawer — until PR4 lands it shows a graceful "Remit drawer coming in PR4" toast). (Smoke step 7 finishes — all 4 event kinds route correctly by PR4 merge.)
4. **PR4:** `AckDrawer`. Wire Acks row, ClaimDrawer payer peek. Backend: `/api/activity/{event_id}` (also used by step 5). (Smoke steps 12, 16.) 4. **PR4 — `RemitDrawer` + 4 surfaces:** Build the RemitDrawer. Wire Remittances row click (replaces inline CAS expand), Inbox candidates + unmatched remit rows, Activity log `remit_received` event click, Remittances claim-id cell, ClaimDrawer matched-remit link. Now `eventKindToEntity()`'s `remit_received` branch resolves to a real drawer. (Smoke steps 13, 14, 17, 19.)
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.) 5. **PR5 — `AckDrawer` + final 8 surfaces:** Build the AckDrawer. Wire Acks row, Inbox rejected + payer_rejected + done_today rows, Reconciliation body click, Batch diff claim id, Upload "See claim in detail" link, ClaimDrawer payer peek (`PayerPeekContent`), ClaimDrawer validation-rule peek (`ValidationRulePeekContent`), refactor existing `ClaimDrawer` to use the `DrillDrawer` shell + `DrillDrawerHeader`. (Smoke steps 8, 9, 11, 12, 16, 18, 20, 21, 22, 23.)
- Merge to main after final PR + smoke pass; cleanup worktree + branch. - Merge to main after final PR + smoke pass; cleanup worktree + branch.