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

27 KiB
Raw Permalink Blame History

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)
  • 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):

  • 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'
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 (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/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.
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 (backend-fed): name, claim count, billed, received, denial rate, "view all claims →" link.
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/.

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 DrillDrawer(claim, ?claim=ID) top drawer (navigates to /claims?claim=ID)
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 (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 endpoint:

Method Path Purpose
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.

Extended endpoint (existing, response shape widened):

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:

  • 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 AND Claims / Activity tabs (via the newly extended arrays).
  • 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.

Implementation notes:

  • 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.
  • 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

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:

.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 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

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
│   │   ├── ValidationRulePeekContent.tsx
│   │   ├── DrillStackProvider.test.tsx
│   │   ├── DrillableCell.test.tsx
│   │   ├── PeekModal.test.tsx
│   │   └── index.ts                          # barrel export
│   ├── ProviderDrawer/                       # NEW
│   │   ├── ProviderDrawer.tsx
│   │   ├── ProviderOverview.tsx
│   │   ├── ProviderRecentClaims.tsx          # top-10 mini-table (data from extended /providers/{npi})
│   │   ├── ProviderRecentActivity.tsx        # top-10 mini-feed (data from extended /providers/{npi})
│   │   └── index.ts
│   ├── RemitDrawer/                          # NEW
│   │   ├── 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 (zustand-backed ephemeral peek stack)
│   ├── usePayerSummary.ts                    # NEW
│   ├── useProviderDrawerUrlState.ts          # NEW (?provider=)
│   ├── useRemitDrawerUrlState.ts             # NEW (?remit=)
│   └── useAckDrawerUrlState.ts               # NEW (?ack=)
├── lib/
│   └── api.ts                                # +api.getPayerSummary(id); /api/providers/{npi} response widened in backend
└── pages/                                    # wire drills per the drill map; refactor existing useDrawerUrlState usages to follow the per-drawer convention
    ├── 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
  • 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

Backend (pytest):

  • test_payer_summary_endpoint — happy path, cache hit/miss, invalidation on claim_written
  • 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, ~3 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) → navigates to /claims?claim=ID with the claim drawer open.
  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. 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).

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 its slice of smoke steps):
    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 + 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 — 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 — 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 — 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.