363 lines
27 KiB
Markdown
363 lines
27 KiB
Markdown
# 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' | '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 (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:
|
||
|
||
```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.
|