Files
cyclone/docs/superpowers/plans/2026-06-21-cyclone-universal-drilldown.md
Tyler 3fa61bb3f6 plan(SP21): universal drill-down — 5 phases, ~40 tasks
Phase 1: drill primitives (DrillStackProvider, DrillableCell, PeekModal,
DrillDrawerHeader) + PayerPeekContent + ValidationRulePeekContent +
/api/payers/{id}/summary backend + Dashboard KPI/provider/denial drills.
Phase 2: ProviderDrawer + activity event routing for claim_* events.
Phase 3: ProviderDrawer tabs (Claims/Activity) + remaining event routing.
Phase 4: RemitDrawer + 4 surfaces (Remittances, BatchDiff, Inbox,
Reconciliation navigate).
Phase 5: AckDrawer + 8 final surfaces (Claims, Batches, Acks, Providers,
ActivityLog, BatchDiff, Inbox, Reconciliation).

Each phase = 1 PR, shippable independently with its own smoke slice.

Spec: docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md
2026-06-21 11:33:39 -06:00

2627 lines
86 KiB
Markdown
Raw Permalink 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.
# Universal Drill-Down Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make every interactive surface in the Cyclone UI drillable — click any entity reference (claim, patient, provider, payer, batch, ack, activity event) to open a contextual view (full-record drawer or cross-reference peek modal).
**Architecture:** Hybrid modal pattern: right-side `DrillDrawer` for full-entity records (claim, remit, batch, provider, ack), centered `PeekModal` for cross-references (payer, validation rule). A `DrillStackProvider` (zustand-backed) owns the ephemeral peek stack and enforces a max-2-level nesting rule (one drawer + one peek). Drawers sync to URL (`?{entity}={id}`); peeks don't. Hover-reveal affordance (pointer + accent tint + trailing chevron via `::after`) on every clickable cell. 1 new backend endpoint (`/api/payers/{payer_id}/summary`) + 1 extended endpoint (`/api/providers/{npi}` gains `recent_claims[]` + `recent_activity[]`).
**Tech Stack:** React 18, Radix Dialog (existing), zustand (existing), TanStack Query (existing), Tailwind (existing), Vitest + React Testing Library (existing), FastAPI + SQLAlchemy (existing, backend), pytest (existing, backend).
**Spec:** `docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md` — read fully before starting any phase.
**Worktree setup (one-time):**
```bash
cd /Users/openclaw/dev/cyclone
git worktree add .worktrees/universal-drilldown -b universal-drilldown main
cd .worktrees/universal-drilldown
npm install
# backend deps unchanged
```
All commits happen in this worktree. Merge each PR to main via fast-forward when its phase ends.
---
## Phase 1 — Foundation + 3 surfaces + 1 backend
**Closes smoke steps:** 4 (Dashboard KPI navigation), 5 (Dashboard Top providers drill), 6 (Dashboard Recent denials drill), and PR1 of the §4 phasing.
### Task 1.1: DrillStackProvider (zustand store + React context)
**Files:**
- Create: `src/components/drill/DrillStackProvider.tsx`
- Create: `src/components/drill/DrillStackProvider.test.tsx`
The provider owns the ephemeral peek stack (the URL-backed drawer is owned per-page). Single zustand store keyed by the `DrillStackProvider` component so test isolation is straightforward.
- [ ] **Step 1: Write the failing test**
```tsx
// src/components/drill/DrillStackProvider.test.tsx
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
DrillStackProvider,
useDrillStack,
} from "@/components/drill/DrillStackProvider";
function wrapper({ children }: { children: React.ReactNode }) {
return <DrillStackProvider>{children}</DrillStackProvider>;
}
describe("DrillStackProvider", () => {
it("starts with an empty stack", () => {
const { result } = renderHook(() => useDrillStack(), { wrapper });
expect(result.current.stack).toEqual([]);
expect(result.current.openPeek).toBeInstanceOf(Function);
expect(result.current.closeTop).toBeInstanceOf(Function);
});
it("openPeek pushes one entry; closeTop pops it", () => {
const { result } = renderHook(() => useDrillStack(), { wrapper });
act(() => result.current.openPeek({ kind: "payer", payerId: "SKCO0" }));
expect(result.current.stack).toEqual([
{ kind: "payer", payerId: "SKCO0" },
]);
act(() => result.current.closeTop());
expect(result.current.stack).toEqual([]);
});
it("caps the stack at 2 levels (peek over peek is rejected)", () => {
const { result } = renderHook(() => useDrillStack(), { wrapper });
act(() => result.current.openPeek({ kind: "payer", payerId: "A" }));
// The hook only governs peeks; a drawer at the bottom is owned by
// the page (URL-backed). For this unit test we simulate "drawer
// present" via the provider's `hasDrawer` prop and assert peek+peek
// becomes just the most recent peek.
act(() => result.current.openPeek({ kind: "rule", rule: "R050" }));
expect(result.current.stack).toHaveLength(1);
expect(result.current.stack[0]).toEqual({ kind: "rule", rule: "R050" });
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- DrillStackProvider.test.tsx`
Expected: FAIL with "Cannot find module".
- [ ] **Step 3: Implement DrillStackProvider**
```tsx
// src/components/drill/DrillStackProvider.tsx
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { create } from "zustand";
export type PeekPayload =
| { kind: "payer"; payerId: string }
| { kind: "rule"; rule: string };
interface DrillState {
stack: PeekPayload[];
openPeek: (p: PeekPayload) => void;
closeTop: () => void;
closeAll: () => void;
}
// One zustand store per provider instance (factory) so multiple
// providers (e.g. in tests) don't share state.
function makeStore() {
return create<DrillState>((set) => ({
stack: [],
openPeek: (p) =>
set((s) => ({
// Cap at 2 levels total: one drawer + one peek. When called and
// the stack already has one peek, replace it.
stack: s.stack.length >= 1 ? [p] : [p],
})),
closeTop: () => set((s) => ({ stack: s.stack.slice(0, -1) })),
closeAll: () => set({ stack: [] }),
}));
}
type StoreApi = ReturnType<typeof makeStore>;
const Ctx = createContext<StoreApi | null>(null);
export function DrillStackProvider({ children }: { children: ReactNode }) {
// useMemo so the store instance is stable across renders.
const store = useMemo(makeStore, []);
return <Ctx.Provider value={store}>{children}</Ctx.Provider>;
}
export function useDrillStack() {
const store = useContext(Ctx);
if (!store) throw new Error("useDrillStack must be used within DrillStackProvider");
// Subscribe to just `stack` so consumers re-render only on stack
// changes (not on every state update).
const stack = store((s) => s.stack);
return {
stack,
openPeek: store.getState().openPeek,
closeTop: store.getState().closeTop,
closeAll: store.getState().closeAll,
};
}
```
Note: `zustand` is already a dependency (`zustand` 4.5.x in package.json). The `create` import path is `import { create } from "zustand"` for v4 — confirm with `cat node_modules/zustand/package.json | grep version` if unsure.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- DrillStackProvider.test.tsx`
Expected: 3 tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/components/drill/DrillStackProvider.tsx src/components/drill/DrillStackProvider.test.tsx
git commit -m "feat(drill): DrillStackProvider — zustand-backed peek stack with 2-level cap"
```
### Task 1.2: DrillableCell (hover affordance wrapper)
**Files:**
- Create: `src/components/drill/DrillableCell.tsx`
- Create: `src/components/drill/DrillableCell.test.tsx`
- [ ] **Step 1: Write the failing test**
```tsx
// src/components/drill/DrillableCell.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { DrillableCell } from "@/components/drill/DrillableCell";
describe("DrillableCell", () => {
it("renders children, applies hover affordance classes, calls onClick", () => {
const onClick = vi.fn();
render(
<DrillableCell onClick={onClick}>
<span>CLM-114</span>
</DrillableCell>,
);
const btn = screen.getByRole("button");
expect(btn).toHaveClass("drillable");
fireEvent.click(btn);
expect(onClick).toHaveBeenCalledOnce();
});
it("disabled state hides affordance and blocks click", () => {
const onClick = vi.fn();
render(
<DrillableCell onClick={onClick} disabled>
<span>unavailable</span>
</DrillableCell>,
);
const btn = screen.getByRole("button");
expect(btn).toBeDisabled();
expect(btn).not.toHaveClass("drillable");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- DrillableCell.test.tsx`
Expected: FAIL with "Cannot find module".
- [ ] **Step 3: Implement DrillableCell**
```tsx
// src/components/drill/DrillableCell.tsx
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface Props {
children: ReactNode;
onClick: () => void;
disabled?: boolean;
/** Optional aria-label; defaults to the visible text content. */
ariaLabel?: string;
}
/**
* Wrap any clickable cell with hover-reveal affordance:
* cursor: pointer + accent background tint + trailing "" chevron,
* applied via the `drillable` class on hover (see `src/index.css`
* in Task 1.4).
*
* Renders as a <button> so it gets keyboard activation (Enter/Space)
* for free. Disabled cells render a plain span without the affordance.
*/
export function DrillableCell({ children, onClick, disabled, ariaLabel }: Props) {
if (disabled) {
return <span className="text-muted-foreground">{children}</span>;
}
return (
<button
type="button"
onClick={onClick}
aria-label={ariaLabel}
className={cn(
"drillable",
"inline-flex items-center gap-0 rounded-sm border-0 bg-transparent p-0 text-left",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
)}
>
{children}
</button>
);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- DrillableCell.test.tsx`
Expected: 2 tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/components/drill/DrillableCell.tsx src/components/drill/DrillableCell.test.tsx
git commit -m "feat(drill): DrillableCell — hover-reveal button wrapper"
```
### Task 1.3: PeekModal (centered Radix Dialog)
**Files:**
- Create: `src/components/drill/PeekModal.tsx`
- Create: `src/components/drill/PeekModal.test.tsx`
- [ ] **Step 1: Write the failing test**
```tsx
// src/components/drill/PeekModal.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PeekModal } from "@/components/drill/PeekModal";
describe("PeekModal", () => {
it("renders title and body when open; close button fires onClose", () => {
const onClose = vi.fn();
render(
<PeekModal
open
onClose={onClose}
eyebrow="Payer"
title="CO Medicaid"
>
<p>1,247 claims</p>
</PeekModal>,
);
expect(screen.getByText("Payer")).toBeInTheDocument();
expect(screen.getByText("CO Medicaid")).toBeInTheDocument();
expect(screen.getByText("1,247 claims")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /close/i }));
expect(onClose).toHaveBeenCalledOnce();
});
it("renders nothing when closed", () => {
const { container } = render(
<PeekModal open={false} onClose={() => {}} title="hidden">
<p>should not appear</p>
</PeekModal>,
);
expect(container).toBeEmptyDOMElement();
});
it("esc key closes", () => {
const onClose = vi.fn();
render(
<PeekModal open onClose={onClose} title="t">
<p>x</p>
</PeekModal>,
);
fireEvent.keyDown(document.body, { key: "Escape" });
expect(onClose).toHaveBeenCalledOnce();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- PeekModal.test.tsx`
Expected: FAIL with "Cannot find module".
- [ ] **Step 3: Implement PeekModal**
```tsx
// src/components/drill/PeekModal.tsx
import { Dialog, DialogContent } from "@/components/ui/dialog";
import type { ReactNode } from "react";
interface Props {
open: boolean;
onClose: () => void;
eyebrow?: string;
title: string;
children: ReactNode;
}
/**
* Centered peek modal — used for cross-reference drills (payer,
* validation rule, etc.). Smaller than the right-side Drawer
* (max-width: 480px); closes on Esc, backdrop click, and the X button.
* No keyboard j/k nav — single record.
*/
export function PeekModal({ open, onClose, eyebrow, title, children }: Props) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent
className="max-w-[480px] w-[90vw]"
aria-describedby={undefined}
>
{eyebrow ? (
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{eyebrow}
</div>
) : null}
<h2 className="text-[18px] font-semibold tracking-tight">{title}</h2>
<div className="mt-2">{children}</div>
</DialogContent>
</Dialog>
);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- PeekModal.test.tsx`
Expected: 3 tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/components/drill/PeekModal.tsx src/components/drill/PeekModal.test.tsx
git commit -m "feat(drill): PeekModal — centered Radix Dialog with eyebrow + title"
```
### Task 1.4: Hover affordance CSS + App-level integration
**Files:**
- Modify: `src/index.css` (append `.drillable` block at end)
- Modify: `src/App.tsx` (wrap routes in `<DrillStackProvider>`)
- [ ] **Step 1: Append hover affordance CSS**
Edit `src/index.css`. Find the last line (the file ends with `@tailwind utilities;` or similar). After it, append:
```css
/* Universal drill-down affordance — applied by DrillableCell. */
.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;
}
```
- [ ] **Step 2: Wrap App routes in DrillStackProvider**
In `src/App.tsx`, find the root JSX returned by the App component (likely a `<BrowserRouter>` or `<QueryClientProvider>` wrapping routes). Wrap the existing tree with `<DrillStackProvider>`:
```tsx
// src/App.tsx — add this import at the top
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
// Then wrap the existing return. Example before:
// return <BrowserRouter>...</BrowserRouter>;
// After:
// return (
// <DrillStackProvider>
// <BrowserRouter>...</BrowserRouter>
// </DrillStackProvider>
// );
```
- [ ] **Step 3: Verify app still boots + existing tests pass**
Run: `npm test -- --run`
Expected: All 249+ frontend tests still green. (If a test broke because it expected a specific root DOM shape, the fix is in that test, not here.)
Run: `npm run typecheck`
Expected: 0 errors.
- [ ] **Step 4: Smoke the app at runtime**
Open `http://localhost:5173` in browser (frontend dev server is already running from the prior session, or restart with `npm run dev`). No visible change expected — the primitives exist but no page uses them yet. Verify no console errors.
- [ ] **Step 5: Commit**
```bash
git add src/index.css src/App.tsx
git commit -m "feat(drill): hover affordance CSS + App wrapped in DrillStackProvider"
```
### Task 1.5: Backend — `/api/payers/{payer_id}/summary`
**Files:**
- Modify: `backend/src/cyclone/api_routers/payers.py` (or wherever `/api/config/payers` lives — find with `grep -rn 'payers/{payer_id}' backend/src/cyclone/api_routers/`)
- Create: `backend/tests/test_payer_summary.py`
- [ ] **Step 1: Write the failing test**
```python
# backend/tests/test_payer_summary.py
from fastapi.testclient import TestClient
def test_payer_summary_happy_path(client: TestClient, seeded_db):
"""Seeded db has at least one claim for CO Medicaid (payer_id='SKCO0')."""
resp = client.get("/api/payers/SKCO0/summary")
assert resp.status_code == 200
data = resp.json()
assert data["payer_id"] == "SKCO0"
assert "claim_count" in data
assert "billed_total" in data
assert "received_total" in data
assert "denial_rate" in data
assert data["claim_count"] >= 1
def test_payer_summary_unknown_payer_returns_404(client: TestClient):
resp = client.get("/api/payers/DOES_NOT_EXIST/summary")
assert resp.status_code == 404
def test_payer_summary_caches_then_invalidates(client: TestClient, seeded_db):
"""A second call within 60s returns the cached payload."""
resp1 = client.get("/api/payers/SKCO0/summary")
resp2 = client.get("/api/payers/SKCO0/summary")
assert resp1.json() == resp2.json()
```
The `client` and `seeded_db` fixtures already exist in `backend/tests/conftest.py` (per the SP3-SP20 spec pattern). If `seeded_db` doesn't exist yet, write a minimal one in this test file that ingests one 837 + one 835 from `backend/tests/fixtures/minimal_837p.txt` and `minimal_835.txt`.
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && .venv/bin/pytest tests/test_payer_summary.py -v`
Expected: FAIL with 404 (endpoint doesn't exist).
- [ ] **Step 3: Implement the endpoint**
Find the existing payers router (likely `backend/src/cyclone/api_routers/payers.py`). Add:
```python
from functools import lru_cache
from time import monotonic
_SUMMARY_TTL_S = 60.0
_summary_cache: dict[str, tuple[float, dict]] = {}
@router.get("/api/payers/{payer_id}/summary")
def get_payer_summary(payer_id: str, store = Depends(get_store)) -> dict:
now = monotonic()
cached = _summary_cache.get(payer_id)
if cached and (now - cached[0]) < _SUMMARY_TTL_S:
return cached[1]
claims = store.iter_claims(payer_id=payer_id)
remits = store.iter_remittances(payer_id=payer_id)
if not claims and not remits:
raise HTTPException(404, f"Payer {payer_id} not found")
billed = sum(c.billed_amount for c in claims)
received = sum(c.received_amount for c in claims)
denied = sum(1 for c in claims if c.status == "denied")
denial_rate = (denied / len(claims)) if claims else 0.0
provider_counts: dict[str, int] = {}
for c in claims:
npi = c.provider_npi
if npi:
provider_counts[npi] = provider_counts.get(npi, 0) + 1
top_providers = [
{"npi": npi, "count": count}
for npi, count in sorted(provider_counts.items(), key=lambda kv: -kv[1])[:5]
]
payload = {
"payer_id": payer_id,
"name": claims[0].payer_name if claims else (remits[0].payer_name if remits else payer_id),
"claim_count": len(claims),
"billed_total": billed,
"received_total": received,
"denial_rate": denial_rate,
"top_providers": top_providers,
}
_summary_cache[payer_id] = (now, payload)
return payload
```
- [ ] **Step 4: Register pubsub invalidation**
In the file that wires the EventBus (`backend/src/cyclone/api.py` lifespan or `backend/src/cyclone/pubsub.py`), register listeners that invalidate the cache:
```python
# In the lifespan handler or wherever subscribers are wired
event_bus.subscribe("claim_written", lambda evt: _summary_cache.pop(_payer_id_from_claim(evt), None))
event_bus.subscribe("remittance_written", lambda evt: _summary_cache.pop(_payer_id_from_remit(evt), None))
```
Adjust the exact event payload shape to match the existing pubsub events (read `backend/src/cyclone/pubsub.py` to confirm — likely has `entity_id` and a `payload` dict carrying the payer_id).
- [ ] **Step 5: Run test to verify it passes**
Run: `cd backend && .venv/bin/pytest tests/test_payer_summary.py -v`
Expected: 3 tests pass.
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/api_routers/payers.py backend/src/cyclone/pubsub.py backend/tests/test_payer_summary.py
git commit -m "feat(api): GET /api/payers/{payer_id}/summary with 60s cache + pubsub invalidation"
```
### Task 1.6: Backend — extend `/api/providers/{npi}` response
**Files:**
- Modify: the file containing `GET /api/providers/{npi}` (likely `backend/src/cyclone/api_routers/providers.py` or similar — find with `grep -rn 'providers/{npi}' backend/src/`)
- Modify: `src/types/index.ts` (extend `Provider` interface)
- Create: `backend/tests/test_provider_extended_response.py`
- [ ] **Step 1: Write the failing test**
```python
# backend/tests/test_provider_extended_response.py
from fastapi.testclient import TestClient
def test_provider_detail_includes_recent_claims(client: TestClient, seeded_db):
"""The extended response gains a recent_claims array (top 10)."""
npi = "1881068062" # Montrose from config/payers.yaml
resp = client.get(f"/api/config/providers/{npi}")
assert resp.status_code == 200
data = resp.json()
assert "recent_claims" in data
assert isinstance(data["recent_claims"], list)
assert len(data["recent_claims"]) <= 10
def test_provider_detail_includes_recent_activity(client: TestClient, seeded_db):
npi = "1881068062"
resp = client.get(f"/api/config/providers/{npi}")
assert resp.status_code == 200
data = resp.json()
assert "recent_activity" in data
assert isinstance(data["recent_activity"], list)
assert len(data["recent_activity"]) <= 10
def test_provider_detail_backwards_compat(client: TestClient, seeded_db):
"""All SP9 fields still present; new arrays don't break the contract."""
npi = "1881068062"
resp = client.get(f"/api/config/providers/{npi}")
data = resp.json()
for key in ("npi", "name", "taxId", "address", "city", "state", "zip", "phone", "claimCount", "outstandingAr"):
assert key in data, f"missing field {key}"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && .venv/bin/pytest tests/test_provider_extended_response.py -v`
Expected: FAIL with "KeyError: recent_claims" or 422 (response validation).
- [ ] **Step 3: Extend the endpoint**
In the providers router handler, after the existing provider lookup, append:
```python
# After: provider = store.get_provider(npi) ... return provider_dict
recent_claims = sorted(
store.iter_claims(provider_npi=npi),
key=lambda c: c.submission_date,
reverse=True,
)[:10]
recent_activity = sorted(
store.iter_activity(provider_npi=npi),
key=lambda a: a.ts,
reverse=True,
)[:10]
return {
**provider_dict,
"recent_claims": [_claim_summary(c) for c in recent_claims],
"recent_activity": [_activity_summary(a) for a in recent_activity],
}
```
The `_claim_summary` and `_activity_summary` helpers project the full ORM models into the slim `ClaimSummary` and `ActivityEvent` shapes the UI already consumes (look at `src/types/index.ts` for the field names — `id`, `submissionDate`, `billedAmount`, etc.).
- [ ] **Step 4: Extend the frontend type**
In `src/types/index.ts`, add to the `Provider` interface:
```ts
export interface Provider {
// ...existing fields...
recent_claims?: ClaimSummary[]; // populated by extended /api/config/providers/{npi}
recent_activity?: ActivityEvent[]; // populated by extended /api/config/providers/{npi}
}
```
- [ ] **Step 5: Run tests**
Run: `cd backend && .venv/bin/pytest tests/test_provider_extended_response.py -v`
Expected: 3 tests pass.
Run: `npm run typecheck`
Expected: 0 errors.
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/api_routers/providers.py backend/tests/test_provider_extended_response.py src/types/index.ts
git commit -m "feat(api): extend /api/config/providers/{npi} with recent_claims + recent_activity"
```
### Task 1.7: Frontend — PayerPeekContent + api.getPayerSummary hook
**Files:**
- Create: `src/hooks/usePayerSummary.ts`
- Modify: `src/lib/api.ts` (add `getPayerSummary` method)
- Create: `src/components/drill/PayerPeekContent.tsx`
- Create: `src/components/drill/PayerPeekContent.test.tsx`
- [ ] **Step 1: Add api.getPayerSummary**
In `src/lib/api.ts`, after the existing `getProvider` method, add:
```ts
export interface PayerSummary {
payer_id: string;
name: string;
claim_count: number;
billed_total: number;
received_total: number;
denial_rate: number;
top_providers: Array<{ npi: string; count: number }>;
}
export async function getPayerSummary(payerId: string): Promise<PayerSummary> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(`${BASE_URL}/api/payers/${encodeURIComponent(payerId)}/summary`);
if (!res.ok) throw await asApiError(res);
return res.json();
}
```
Look at the existing `getProvider` / `asApiError` pattern in the file and match it exactly — the snippet above is illustrative.
- [ ] **Step 2: Add usePayerSummary hook**
```ts
// src/hooks/usePayerSummary.ts
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
export function usePayerSummary(payerId: string | null) {
return useQuery({
queryKey: ["payer-summary", payerId],
queryFn: () => api.getPayerSummary(payerId as string),
enabled: payerId !== null,
staleTime: 60 * 1000,
retry: 1,
});
}
```
- [ ] **Step 3: Write the failing PayerPeekContent test**
```tsx
// src/components/drill/PayerPeekContent.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { PayerPeekContent } from "@/components/drill/PayerPeekContent";
describe("PayerPeekContent", () => {
it("renders loading skeleton while fetching", () => {
render(<PayerPeekContent payerId="SKCO0" loading />);
// The Skeleton component is the existing one; check that numbers
// aren't rendered yet.
expect(screen.queryByText(/claims/i)).not.toBeInTheDocument();
});
it("renders summary stats when data loads", () => {
render(
<PayerPeekContent
payerId="SKCO0"
loading={false}
data={{
payer_id: "SKCO0",
name: "CO Medicaid",
claim_count: 1247,
billed_total: 548000,
received_total: 521000,
denial_rate: 0.042,
top_providers: [{ npi: "1881068062", count: 184 }],
}}
/>,
);
expect(screen.getByText("CO Medicaid")).toBeInTheDocument();
expect(screen.getByText(/1,247/)).toBeInTheDocument();
expect(screen.getByText("$548,000")).toBeInTheDocument();
expect(screen.getByText("4.2%")).toBeInTheDocument();
// "View all claims" link goes to /claims?payer=SKCO0
const link = screen.getByRole("link", { name: /view all claims/i });
expect(link).toHaveAttribute("href", "/claims?payer=SKCO0");
});
});
```
- [ ] **Step 4: Implement PayerPeekContent**
```tsx
// src/components/drill/PayerPeekContent.tsx
import { Link } from "react-router-dom";
import { Skeleton } from "@/components/ui/skeleton";
import { fmt } from "@/lib/format";
import { usePayerSummary } from "@/hooks/usePayerSummary";
import type { PayerSummary } from "@/lib/api";
interface Props {
payerId: string;
}
/**
* Peek body for a payer — shows aggregate stats. Uses usePayerSummary
* to fetch; the parent PeekModal owns open/close.
*/
export function PayerPeekContent({ payerId }: Props) {
const { data, isLoading } = usePayerSummary(payerId);
if (isLoading || !data) {
return (
<div className="space-y-2">
<Skeleton variant="row" />
<Skeleton variant="row" />
<Skeleton variant="row" />
</div>
);
}
return <Loaded payer={data} />;
}
function Loaded({ payer }: { payer: PayerSummary }) {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Stat label="Claims" value={fmt.num(payer.claim_count)} />
<Stat label="Denial rate" value={fmt.pct(payer.denial_rate)} />
<Stat label="Billed" value={fmt.usd(payer.billed_total)} accent="accent" />
<Stat label="Received" value={fmt.usd(payer.received_total)} accent="success" />
</div>
{payer.top_providers.length > 0 ? (
<div>
<div className="eyebrow mb-1.5">Top providers</div>
<ul className="text-[12.5px] space-y-1">
{payer.top_providers.slice(0, 3).map((p) => (
<li key={p.npi} className="flex justify-between">
<span className="mono">{p.npi}</span>
<span className="mono text-muted-foreground">{fmt.num(p.count)} claims</span>
</li>
))}
</ul>
</div>
) : null}
<Link
to={`/claims?payer=${encodeURIComponent(payer.payer_id)}`}
className="text-[12.5px] text-accent hover:underline"
>
View all claims
</Link>
</div>
);
}
function Stat({
label,
value,
accent,
}: {
label: string;
value: string;
accent?: "accent" | "success" | "warning";
}) {
const color =
accent === "success"
? "text-[hsl(var(--success))]"
: accent === "warning"
? "text-[hsl(var(--warning))]"
: accent === "accent"
? "text-accent"
: "text-foreground";
return (
<div>
<div className="eyebrow">{label}</div>
<div className={`display mono text-[16px] mt-1 ${color}`}>{value}</div>
</div>
);
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npm test -- PayerPeekContent.test.tsx`
Expected: 2 tests pass.
- [ ] **Step 6: Commit**
```bash
git add src/lib/api.ts src/hooks/usePayerSummary.ts src/components/drill/PayerPeekContent.tsx src/components/drill/PayerPeekContent.test.tsx
git commit -m "feat(drill): PayerPeekContent + usePayerSummary + api.getPayerSummary"
```
### Task 1.8: Dashboard — wire KPI tile navigation
**Files:**
- Modify: `src/pages/Dashboard.tsx`
The 5 KPI tiles (Claims, Billed, Received, Pending AR, Denial rate) become drillable via `<DrillableCell>` wrapping the tile body. The `onClick` uses `useNavigate()` from react-router-dom to navigate to `/claims` with the right filter.
- [ ] **Step 1: Read the current Dashboard.tsx**
Open `src/pages/Dashboard.tsx`. Locate the `<KpiCard>` invocations inside the `<section aria-label="Key performance indicators">` block (around lines 165-226).
- [ ] **Step 2: Wrap each KpiCard in DrillableCell**
Add imports at top of `Dashboard.tsx`:
```tsx
import { useNavigate } from "react-router-dom";
import { DrillableCell } from "@/components/drill/DrillableCell";
```
Inside the `Dashboard()` function body, before the return:
```tsx
const navigate = useNavigate();
```
Then wrap each `<KpiCard>`:
```tsx
// Before:
<KpiCard label="Claims" icon={Receipt} sparkline={monthly.count} ... />
// After:
<DrillableCell onClick={() => navigate("/claims")}>
<KpiCard label="Claims" icon={Receipt} sparkline={monthly.count} ... />
</DrillableCell>
```
Apply the same pattern to the other 4 with these `onClick` handlers:
- **Billed** → `() => navigate("/claims?sort=-billedAmount")`
- **Received** → `() => navigate("/claims?sort=-receivedAmount")`
- **Pending AR** → `() => navigate("/claims?status=submitted,pending")`
- **Denial rate** → `() => navigate("/claims?status=denied")`
The current `/claims?status=…` filter may need a multi-value param; check `src/hooks/useClaims.ts` to confirm whether `status` accepts a comma-separated list. If it doesn't, navigate to `/claims` and let the user filter.
- [ ] **Step 3: Verify in browser**
Open `http://localhost:5173/`. Hover each KPI tile — the cursor should turn to a pointer, the tile background should tint accent, and the trailing `` should appear. Click → navigates to `/claims` with the right query string.
- [ ] **Step 4: Commit**
```bash
git add src/pages/Dashboard.tsx
git commit -m "feat(dashboard): KPI tiles drillable — navigate to /claims with filter"
```
### Task 1.9: Dashboard — wire Top providers row drill
**Files:**
- Modify: `src/pages/Dashboard.tsx`
The "Top providers" card on the Dashboard lists up to 4 providers. Each `<li>` becomes a `DrillableCell` that opens the ProviderDrawer. The drawer state is owned by `/providers` (ProviderDrawer lives on that page) — clicking from Dashboard navigates to `/providers?provider=NPI`.
- [ ] **Step 1: Read the existing Top providers block**
Open `src/pages/Dashboard.tsx`. The block starts around line 262 (`{topProviders.map((p, i) => (`).
- [ ] **Step 2: Wrap each provider row in DrillableCell**
```tsx
// Inside the topProviders.map, before the <li>:
<DrillableCell onClick={() => navigate(`/providers?provider=${encodeURIComponent(p.npi)}`)}>
<li key={p.npi} className="flex items-center gap-3">...</li>
</DrillableCell>
```
Note: `DrillableCell` is a `<button>`, so wrapping a `<li>` in a `<button>` is technically invalid HTML (block inside inline). Adjust `DrillableCell` to support `as="li"` polymorphism if needed — or, simpler, move the click handler onto the `<li>` itself with the affordance classes applied directly. Pick whichever the codebase already uses for "clickable list item" patterns (search `cursor-pointer` in `src/pages/Providers.tsx`).
If refactoring is needed, the simplest path:
```tsx
<li
key={p.npi}
onClick={() => navigate(...)}
className="drillable flex items-center gap-3 cursor-pointer ..."
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter") navigate(...); }}
>
...
</li>
```
- [ ] **Step 3: Verify**
Smoke check at `http://localhost:5173/`:
- Hover a provider row → cursor pointer + tint + chevron
- Click → URL becomes `/providers?provider=NPI` (the drawer won't actually open yet — that's Task 2.2/2.3)
- [ ] **Step 4: Commit**
```bash
git add src/pages/Dashboard.tsx
git commit -m "feat(dashboard): Top providers row drillable to /providers?provider=NPI"
```
### Task 1.10: Dashboard — wire Recent denials row drill
**Files:**
- Modify: `src/pages/Dashboard.tsx`
Same pattern: each denial `<li>` becomes drillable, opening the claim drawer (`/claims?claim=ID`).
- [ ] **Step 1: Wire the click**
Around line 305, in the `topDenials.map((c) => (…))` block:
```tsx
// Before:
<li key={c.id} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
// After:
<li
key={c.id}
onClick={() => navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}
className="drillable flex items-start gap-3 py-3 first:pt-0 last:pb-0 cursor-pointer"
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter") navigate(`/claims?claim=${encodeURIComponent(c.id)}`); }}
>
```
- [ ] **Step 2: Smoke**
Click a denial row → URL changes to `/claims?claim=…`. (The drawer won't open until Phase 5 task 5.10 refactors ClaimDrawer onto the DrillDrawer shell — for now the navigation works, the drawer won't render yet. That's expected.)
- [ ] **Step 3: Commit**
```bash
git add src/pages/Dashboard.tsx
git commit -m "feat(dashboard): Recent denials row drillable to /claims?claim=ID"
```
### Task 1.11: Phase 1 merge to main
- [ ] **Step 1: Verify everything green locally**
```bash
npm run typecheck # 0 errors
npm test -- --run # all tests pass (existing + new)
cd backend && .venv/bin/pytest -v # all tests pass
```
- [ ] **Step 2: Smoke steps 4, 5, 6 in browser**
Per spec §2.9. Verify each step works (or note which deferred pieces are expected to not work yet — claim drawer doesn't open from Recent denials until PR5).
- [ ] **Step 3: Fast-forward main**
```bash
cd /Users/openclaw/dev/cyclone
git checkout main
git merge --ff-only universal-drilldown
```
If main has moved on, rebase instead: `git rebase main universal-drilldown` then merge.
- [ ] **Step 4: Tag + push**
```bash
git tag sp21-phase1
git push origin main --tags
```
---
## Phase 2 — ProviderDrawer + activity event routing for `claim_*` events
**Closes smoke steps:** 7 (Dashboard Recent activity for `claim_*` events), 10 (Claims provider cell), 15 (Providers page).
### Task 2.1: useProviderDrawerUrlState hook
**Files:**
- Create: `src/hooks/useProviderDrawerUrlState.ts`
- Create: `src/hooks/useProviderDrawerUrlState.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
// src/hooks/useProviderDrawerUrlState.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useProviderDrawerUrlState } from "@/hooks/useProviderDrawerUrlState";
describe("useProviderDrawerUrlState", () => {
beforeEach(() => {
window.history.replaceState(null, "", "/providers");
});
it("reads ?provider= from URL on mount", () => {
window.history.replaceState(null, "", "/providers?provider=1881068062");
const { result } = renderHook(() => useProviderDrawerUrlState());
expect(result.current.providerNpi).toBe("1881068062");
});
it("open() pushes URL with ?provider=", () => {
const { result } = renderHook(() => useProviderDrawerUrlState());
act(() => result.current.open("1881068062"));
expect(result.current.providerNpi).toBe("1881068062");
expect(window.location.search).toBe("?provider=1881068062");
});
it("close() strips the param", () => {
window.history.replaceState(null, "", "/providers?provider=1881068062");
const { result } = renderHook(() => useProviderDrawerUrlState());
act(() => result.current.close());
expect(result.current.providerNpi).toBeNull();
expect(window.location.search).toBe("");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- useProviderDrawerUrlState.test.ts`
Expected: FAIL.
- [ ] **Step 3: Implement the hook**
```ts
// src/hooks/useProviderDrawerUrlState.ts
import { useCallback, useEffect, useState } from "react";
function readProviderNpi(): string | null {
const v = new URLSearchParams(window.location.search).get("provider");
return v === "" ? null : v;
}
function buildUrl(npi: string | null): string {
const url = new URL(window.location.href);
if (npi === null) url.searchParams.delete("provider");
else url.searchParams.set("provider", npi);
return url.pathname + url.search + url.hash;
}
export function useProviderDrawerUrlState() {
const [providerNpi, setProviderNpi] = useState<string | null>(() => readProviderNpi());
const open = useCallback((npi: string) => {
window.history.pushState(null, "", buildUrl(npi));
setProviderNpi(npi);
}, []);
const close = useCallback(() => {
window.history.pushState(null, "", buildUrl(null));
setProviderNpi(null);
}, []);
useEffect(() => {
const onPop = () => setProviderNpi(readProviderNpi());
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
return { providerNpi, open, close };
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- useProviderDrawerUrlState.test.ts`
Expected: 3 tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/hooks/useProviderDrawerUrlState.ts src/hooks/useProviderDrawerUrlState.test.ts
git commit -m "feat(drill): useProviderDrawerUrlState — ?provider= URL sync"
```
### Task 2.2: ProviderDrawer (Overview tab only in this phase)
**Files:**
- Create: `src/components/ProviderDrawer/ProviderDrawer.tsx`
- Create: `src/components/ProviderDrawer/ProviderOverview.tsx`
- Create: `src/components/ProviderDrawer/index.ts`
- Create: `src/components/ProviderDrawer/ProviderDrawer.test.tsx`
- Create: `src/hooks/useProviderDetail.ts`
- [ ] **Step 1: Add useProviderDetail**
```ts
// src/hooks/useProviderDetail.ts
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
export function useProviderDetail(npi: string | null) {
return useQuery({
queryKey: ["provider-detail", npi],
queryFn: () => api.getProvider(npi as string),
enabled: npi !== null,
staleTime: 60 * 1000,
});
}
```
The `api.getProvider` method already exists — confirm in `src/lib/api.ts` (the SP9 implementation returns a `Provider` with `npi`, `name`, `taxId`, etc.).
- [ ] **Step 2: Write the failing ProviderDrawer test**
```tsx
// src/components/ProviderDrawer/ProviderDrawer.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ProviderDrawer } from "@/components/ProviderDrawer";
// Mock the api.getProvider hook to return a known provider.
vi.mock("@/hooks/useProviderDetail", () => ({
useProviderDetail: () => ({
data: {
npi: "1881068062",
name: "Montrose Memorial",
taxId: "721587149",
address: "123 Main St",
city: "Montrose",
state: "CO",
zip: "81401",
phone: "(970) 555-1234",
claimCount: 184,
outstandingAr: 12450,
},
isLoading: false,
isError: false,
}),
}));
describe("ProviderDrawer", () => {
it("renders Overview tab content for a known provider", () => {
render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
expect(screen.getByText("Montrose Memorial")).toBeInTheDocument();
expect(screen.getByText(/1881068062/)).toBeInTheDocument();
expect(screen.getByText(/721587149/)).toBeInTheDocument();
});
});
```
Note: the test imports `vi` from vitest. Adjust the import line if the codebase uses a different pattern.
- [ ] **Step 3: Implement ProviderDrawer + ProviderOverview**
```tsx
// src/components/ProviderDrawer/ProviderOverview.tsx
import type { Provider } from "@/types";
import { fmt } from "@/lib/format";
export function ProviderOverview({ provider }: { provider: Provider }) {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Field label="NPI" value={provider.npi} mono />
<Field label="Tax ID" value={provider.taxId} mono />
<Field label="Address" value={`${provider.address}, ${provider.city}, ${provider.state} ${provider.zip}`} />
<Field label="Phone" value={provider.phone} mono />
</div>
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-border/30">
<Field label="Claims" value={fmt.num(provider.claimCount)} mono />
<Field label="Outstanding AR" value={fmt.usd(provider.outstandingAr)} mono />
</div>
</div>
);
}
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<div className="eyebrow">{label}</div>
<div className={`text-[13px] mt-1 ${mono ? "display mono" : ""}`}>{value}</div>
</div>
);
}
```
```tsx
// src/components/ProviderDrawer/ProviderDrawer.tsx
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
import { ProviderOverview } from "./ProviderOverview";
import { useProviderDetail } from "@/hooks/useProviderDetail";
import { Skeleton } from "@/components/ui/skeleton";
interface Props {
npi: string | null;
onClose: () => void;
}
export function ProviderDrawer({ npi, onClose }: Props) {
const { data, isLoading } = useProviderDetail(npi);
return (
<Dialog open={npi !== null} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
aria-describedby={undefined}
>
{npi === null ? null : (
<>
<DrillDrawerHeader
eyebrow="Provider"
title={data?.name ?? "Loading…"}
onClose={onClose}
/>
<div className="p-6 overflow-y-auto h-[calc(100%-64px)]">
{isLoading || !data ? (
<div className="space-y-2">
<Skeleton variant="row" />
<Skeleton variant="row" />
<Skeleton variant="row" />
</div>
) : (
<ProviderOverview provider={data} />
)}
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
```
```tsx
// src/components/drill/DrillDrawerHeader.tsx (create this small helper)
import { X } from "lucide-react";
interface Props {
eyebrow: string;
title: string;
onClose: () => void;
}
export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
return (
<div className="flex items-center justify-between border-b border-border/30 px-6 py-4">
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{eyebrow}
</div>
<h2 className="text-[18px] font-semibold tracking-tight mt-0.5">{title}</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close drawer"
className="rounded-md p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
);
}
```
```ts
// src/components/ProviderDrawer/index.ts
export { ProviderDrawer } from "./ProviderDrawer";
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- ProviderDrawer.test.tsx`
Expected: 1 test passes.
- [ ] **Step 5: Commit**
```bash
git add src/hooks/useProviderDetail.ts src/components/drill/DrillDrawerHeader.tsx src/components/ProviderDrawer/
git commit -m "feat(drill): ProviderDrawer with Overview tab + DrillDrawerHeader shell"
```
### Task 2.3: Wire `/providers` page
**Files:**
- Modify: `src/pages/Providers.tsx`
The Providers directory page mounts the drawer and uses the URL state hook. Cards become drillable.
- [ ] **Step 1: Read Providers.tsx**
Open `src/pages/Providers.tsx`. The page currently fetches and renders cards; the cards are inert.
- [ ] **Step 2: Add URL state + drawer mount + clickable cards**
```tsx
// At top of file:
import { useProviderDrawerUrlState } from "@/hooks/useProviderDrawerUrlState";
import { ProviderDrawer } from "@/components/ProviderDrawer";
// Inside Providers() function:
const { providerNpi, open, close } = useProviderDrawerUrlState();
// At the end of the returned JSX, after the cards grid:
<ProviderDrawer npi={providerNpi} onClose={close} />
// In the cards map, change the <article> to be clickable:
<article
key={p.npi}
onClick={() => open(p.npi)}
className="group surface-2 rounded-xl p-5 flex flex-col gap-4 transition-colors hover:bg-muted/20 cursor-pointer drillable"
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter") open(p.npi); }}
>
```
- [ ] **Step 3: Smoke**
Open `/providers` → click a card → URL becomes `/providers?provider=…` → drawer opens from the right with the provider's Overview tab populated.
- [ ] **Step 4: Commit**
```bash
git add src/pages/Providers.tsx
git commit -m "feat(providers): directory cards drillable — opens ProviderDrawer"
```
### Task 2.4: Wire Claims · provider cell
**Files:**
- Modify: `src/pages/Claims.tsx`
The Claims page table's "Provider" column currently shows name + NPI as text. Wrap with `DrillableCell` that navigates to `/providers?provider=NPI`.
- [ ] **Step 1: Locate the provider cell**
In `src/pages/Claims.tsx`, find the `<TableCell>` for "Provider" (around line 286). It currently renders `<div>{provider?.name ?? "Unknown"}</div><div className="mono …">{c.providerNpi}</div>`.
- [ ] **Step 2: Wrap with DrillableCell**
```tsx
import { useNavigate } from "react-router-dom";
import { DrillableCell } from "@/components/drill/DrillableCell";
// In Claims() body:
const navigate = useNavigate();
// In the table row, replace the Provider <TableCell>:
<TableCell>
<DrillableCell onClick={() => navigate(`/providers?provider=${encodeURIComponent(c.providerNpi)}`)}>
<div className="text-[13px]">{provider?.name ?? "Unknown"}</div>
<div className="mono text-[10.5px] text-muted-foreground">{c.providerNpi}</div>
</DrillableCell>
</TableCell>
```
- [ ] **Step 3: Smoke**
Click a provider cell on `/claims` → navigates to `/providers?provider=NPI` → ProviderDrawer opens.
- [ ] **Step 4: Commit**
```bash
git add src/pages/Claims.tsx
git commit -m "feat(claims): provider cell drillable to /providers?provider=NPI"
```
### Task 2.5: Wire Dashboard · Recent activity for `claim_*` events
**Files:**
- Create: `src/lib/event-routing.ts` (small helper)
- Modify: `src/pages/Dashboard.tsx`
- Modify: `src/components/ActivityFeed.tsx` (or wherever the feed items render — check `src/components/ActivityFeed.tsx`)
The Dashboard's "Recent activity" card uses the existing `ActivityFeed` component. Each event has a `kind` and an `entityId` (the claim id, remit id, etc.). Routing maps kinds → navigation targets.
- [ ] **Step 1: Create the event-routing helper**
```ts
// src/lib/event-routing.ts
import type { Activity } from "@/types";
/**
* Maps an activity event to the URL the operator should land on when
* clicking the event. The Dashboard "Recent activity" card and the
* /activity log page both use this.
*
* Returns null for kinds that don't have a drill target yet (e.g.
* `remit_received` until the RemitDrawer ships in Phase 4; the UI
* surfaces a "coming soon" toast in that case via the caller).
*/
export function eventKindToUrl(event: Pick<Activity, "kind" | "entityId">): string | null {
switch (event.kind) {
case "claim_submitted":
case "claim_paid":
case "claim_denied":
case "claim_accepted":
return `/claims?claim=${encodeURIComponent(event.entityId)}`;
case "remit_received":
return null; // Phase 4
case "provider_added":
return `/providers?provider=${encodeURIComponent(event.entityId)}`;
default:
return null;
}
}
```
Verify the exact shape of `Activity` in `src/types/index.ts` — the fields might be `entity_id` (snake_case) or `entityId`. Adjust accordingly.
- [ ] **Step 2: Modify ActivityFeed to accept an onClick per item**
Open `src/components/ActivityFeed.tsx`. Add an optional `onItemClick?: (event: Activity) => void` prop. When provided, wrap each item's outer container in a `<button>` with `drillable` class and the chevron CSS, and call `onItemClick(event)` on click.
If ActivityFeed is already structured as a list of clickable rows in some pages, the simplest change is: add the optional prop, leave default behavior unchanged, and only enable the click wrapper when `onItemClick` is passed.
- [ ] **Step 3: Pass the onItemClick from Dashboard's Recent activity card**
```tsx
// In Dashboard.tsx, find the "Recent activity" card (~line 233).
import { useNavigate } from "react-router-dom";
import { eventKindToUrl } from "@/lib/event-routing";
import { toast } from "sonner";
// In the card's ActivityFeed:
<ActivityFeed
items={activity.slice(0, 10)}
onItemClick={(evt) => {
const url = eventKindToUrl(evt);
if (url) navigate(url);
else toast.info(`Drill for ${evt.kind} coming in a later phase.`);
}}
/>
```
- [ ] **Step 4: Smoke**
Open `/` → "Recent activity" card → click a `claim_paid` event → URL becomes `/claims?claim=…` (drawer doesn't render yet for Phase 2 — navigation works, drawer will arrive in Phase 5). Click a `remit_received` → toast "coming in a later phase".
- [ ] **Step 5: Commit**
```bash
git add src/lib/event-routing.ts src/components/ActivityFeed.tsx src/pages/Dashboard.tsx
git commit -m "feat(dashboard): Recent activity events route to entity by kind"
```
### Task 2.6: Phase 2 merge to main
- [ ] **Step 1: Verify green**
```bash
npm run typecheck && npm test -- --run && (cd backend && .venv/bin/pytest -q)
```
- [ ] **Step 2: Smoke steps 7, 10, 15**
Per spec §2.9.
- [ ] **Step 3: FF-merge + push**
```bash
git checkout main && git merge --ff-only universal-drilldown && git push origin main
```
---
## Phase 3 — ProviderDrawer tabs + remaining activity event routing
**Closes the rest of smoke step 7** (all 4 event kinds route correctly by Phase 4 merge; Phase 3 stubs `remit_received` to a toast).
### Task 3.1: ProviderDrawer — Claims tab
**Files:**
- Create: `src/components/ProviderDrawer/ProviderRecentClaims.tsx`
- Modify: `src/components/ProviderDrawer/ProviderDrawer.tsx` (add Tabs)
The ProviderDrawer reads `provider.recent_claims` from the extended `/api/config/providers/{npi}` response (Task 1.6 already populated this).
- [ ] **Step 1: Add Radix Tabs primitive**
If Tabs isn't already in `src/components/ui/`, add it via the Radix UI pattern (search for `@radix-ui/react-tabs` in package.json; if absent, install with `npm install @radix-ui/react-tabs`).
- [ ] **Step 2: Implement ProviderRecentClaims**
```tsx
// src/components/ProviderDrawer/ProviderRecentClaims.tsx
import type { Provider } from "@/types";
import { fmt } from "@/lib/format";
export function ProviderRecentClaims({ provider }: { provider: Provider }) {
const claims = provider.recent_claims ?? [];
if (claims.length === 0) {
return <div className="text-muted-foreground text-[13px]">No recent claims.</div>;
}
return (
<div className="space-y-2">
{claims.map((c) => (
<div key={c.id} className="flex items-center gap-3 py-2 border-b border-border/30 last:border-0">
<div className="display mono text-[12.5px] w-32 shrink-0">{c.id}</div>
<div className="flex-1 min-w-0 text-[12.5px] text-muted-foreground truncate">
{c.patientName ?? "—"}
</div>
<div className="display mono text-[13px]">{fmt.usd(c.billedAmount)}</div>
</div>
))}
<a href="/claims" className="text-[12.5px] text-accent hover:underline">
View all claims
</a>
</div>
);
}
```
- [ ] **Step 3: Add tabs to ProviderDrawer**
```tsx
// In ProviderDrawer.tsx, replace the body with:
import * as Tabs from "@radix-ui/react-tabs";
import { ProviderRecentClaims } from "./ProviderRecentClaims";
import { ProviderRecentActivity } from "./ProviderRecentActivity";
// In the JSX, after DrillDrawerHeader:
<Tabs.Root defaultValue="overview" className="px-6 py-4">
<Tabs.List className="flex gap-2 border-b border-border/30 mb-4">
<Tabs.Trigger value="overview" className="px-3 py-2 text-[12.5px] data-[state=active]:text-foreground data-[state=active]:border-b-2 data-[state=active]:border-accent text-muted-foreground">
Overview
</Tabs.Trigger>
<Tabs.Trigger value="claims" className="px-3 py-2 text-[12.5px] data-[state=active]:text-foreground data-[state=active]:border-b-2 data-[state=active]:border-accent text-muted-foreground">
Claims
</Tabs.Trigger>
<Tabs.Trigger value="activity" className="px-3 py-2 text-[12.5px] data-[state=active]:text-foreground data-[state=active]:border-b-2 data-[state=active]:border-accent text-muted-foreground">
Activity
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview">
{data ? <ProviderOverview provider={data} /> : <Skeleton variant="row" />}
</Tabs.Content>
<Tabs.Content value="claims">
{data ? <ProviderRecentClaims provider={data} /> : <Skeleton variant="row" />}
</Tabs.Content>
<Tabs.Content value="activity">
{data ? <ProviderRecentActivity provider={data} /> : <Skeleton variant="row" />}
</Tabs.Content>
</Tabs.Root>
```
- [ ] **Step 4: Stub ProviderRecentActivity** (real impl in next task)
```tsx
// src/components/ProviderDrawer/ProviderRecentActivity.tsx
import type { Provider } from "@/types";
export function ProviderRecentActivity({ provider }: { provider: Provider }) {
const items = provider.recent_activity ?? [];
if (items.length === 0) {
return <div className="text-muted-foreground text-[13px]">No recent activity.</div>;
}
return (
<ul className="space-y-1.5 text-[12.5px]">
{items.map((a) => (
<li key={a.id} className="flex items-center gap-2">
<span className="mono text-[10.5px] text-muted-foreground">{new Date(a.ts).toLocaleString()}</span>
<span>{a.kind}</span>
</li>
))}
</ul>
);
}
```
- [ ] **Step 5: Smoke**
Open `/providers` → click a provider → drawer opens with 3 tabs. Click "Claims" → see top-10 claims from `recent_claims`. Click "Activity" → see top-10 events.
- [ ] **Step 6: Commit**
```bash
git add src/components/ProviderDrawer/ src/components/ui/tabs.tsx
git commit -m "feat(drill): ProviderDrawer — Claims + Activity tabs from extended /providers/{npi}"
```
### Task 3.2: Wire `provider_added` event routing
**Files:**
- Modify: `src/lib/event-routing.ts`
The `provider_added` branch already returns `/providers?provider=…` (from Task 2.5). The Dashboard activity feed uses this already. No additional work needed — verify with a smoke click.
- [ ] **Step 1: Verify**
Open `/` → Recent activity → click a `provider_added` event → URL becomes `/providers?provider=NPI` → ProviderDrawer opens with the provider's Overview.
(No code change; this task is a verification step.)
- [ ] **Step 2: No commit**
If nothing changed, skip this commit.
### Task 3.3: Phase 3 merge to main
- [ ] **Step 1: Verify green + smoke step 7 (all 4 event kinds)**
`claim_*` → claim nav (Phase 2). `remit_received` → toast (Phase 4 wires real route). `provider_added` → ProviderDrawer (this phase).
- [ ] **Step 2: FF-merge + push**
```bash
git checkout main && git merge --ff-only universal-drilldown && git push origin main
```
---
## Phase 4 — RemitDrawer + 4 surfaces
**Closes smoke steps:** 13 (Remittances row drill), 14 (Remittances claim id cell), 17 (Inbox candidates + unmatched remit), 19 (Activity log `remit_received` event).
### Task 4.1: useRemitDrawerUrlState hook
**Files:**
- Create: `src/hooks/useRemitDrawerUrlState.ts`
- Create: `src/hooks/useRemitDrawerUrlState.test.ts`
The shape mirrors `useProviderDrawerUrlState` (Task 2.1) but reads/writes `?remit=` instead of `?provider=`.
- [ ] **Step 1: Write the failing test**
Same structure as Task 2.1 with `?remit=` instead of `?provider=`. Use the actual file from Task 2.1 as a template.
- [ ] **Step 2: Implement**
```ts
// src/hooks/useRemitDrawerUrlState.ts — copy from useProviderDrawerUrlState
// and substitute "remit" everywhere.
```
- [ ] **Step 3: Commit**
```bash
git add src/hooks/useRemitDrawerUrlState.ts src/hooks/useRemitDrawerUrlState.test.ts
git commit -m "feat(drill): useRemitDrawerUrlState — ?remit= URL sync"
```
### Task 4.2: RemitDrawer shell
**Files:**
- Modify: `src/components/RemitDrawer/RemitDrawer.tsx` (existing file — wire URL state)
- Modify: `src/components/RemitDrawer/index.ts`
The RemitDrawer components already exist per the file tree (RemitDrawer.tsx, RemitDrawerHeader.tsx, ClaimPaymentsTable.tsx, CasAdjustmentsPanel.tsx, FinancialSummaryCard.tsx, PartiesGrid.tsx, RemitDrawerSkeleton.tsx, RemitDrawerError.tsx). They just aren't mounted anywhere.
- [ ] **Step 1: Read RemitDrawer.tsx**
Open `src/components/RemitDrawer/RemitDrawer.tsx`. Inspect its current props (likely takes `remittanceId` and `onClose`).
- [ ] **Step 2: Verify the component works standalone**
Write a smoke test:
```tsx
// src/components/RemitDrawer/RemitDrawer.test.tsx — add to existing tests if not present
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { RemitDrawer } from "@/components/RemitDrawer";
describe("RemitDrawer", () => {
it("renders without crashing given a remittanceId", () => {
render(<RemitDrawer remittanceId="R-9876" onClose={() => {}} />);
// The drawer fetches data async; just verify the skeleton renders
// while loading.
expect(screen.getByRole("dialog") || document.body).toBeDefined();
});
});
```
- [ ] **Step 3: Update barrel export if needed**
```ts
// src/components/RemitDrawer/index.ts
export { RemitDrawer } from "./RemitDrawer";
```
- [ ] **Step 4: Commit (if changes were needed)**
If RemitDrawer was already standalone and no changes needed, skip this commit.
### Task 4.3: Wire Remittances row click
**Files:**
- Modify: `src/pages/Remittances.tsx`
The Remittances page currently uses inline expand for CAS adjustments. Replace that with a row-click → drawer pattern.
- [ ] **Step 1: Mount the drawer**
```tsx
// In Remittances.tsx:
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { RemitDrawer } from "@/components/RemitDrawer";
const { remitId, open, close } = useRemitDrawerUrlState();
// At end of returned JSX:
<RemitDrawer remittanceId={remitId} onClose={close} />
```
- [ ] **Step 2: Make rows clickable**
In the table row map (around line 218), change:
```tsx
// Before:
<TableRow
key={...}
onClick={() => hasAdjustments ? toggleExpand(r.id) : undefined}
...
>
// After:
<TableRow
key={...}
onClick={() => open(r.id)}
className="cursor-pointer drillable"
>
```
Remove the `expanded` state, the `toggleExpand` function, and the conditional inline-expand `<TableRow>` block (lines ~276-303). The drawer's CAS panel replaces the inline expand.
- [ ] **Step 3: Smoke**
Click a row on `/remittances` → URL becomes `/remittances?remit=…` → RemitDrawer opens.
- [ ] **Step 4: Commit**
```bash
git add src/pages/Remitittances.tsx
git commit -m "feat(remits): row click opens RemitDrawer (replaces inline CAS expand)"
```
### Task 4.4: Wire Inbox candidates + unmatched remit rows
**Files:**
- Modify: `src/pages/Inbox.tsx`
Inbox lane rows have a no-op `onRowClick` today. Wire the candidates and unmatched-remit lanes to open the RemitDrawer.
- [ ] **Step 1: Locate the Lane components**
In `src/pages/Inbox.tsx`, the 5 `<Lane>` instances are around line 207. Find `lane="candidates"` and the `unmatched` lane's `unmatched` row sub-component. The Lane component takes an `onRowClick` prop.
- [ ] **Step 2: Wire the click handlers**
```tsx
import { useNavigate } from "react-router-dom";
const navigate = useNavigate();
// Candidates lane:
<Lane
name="CANDIDATES"
rows={lanes.candidates}
onRowClick={(row) => navigate(`/remittances?remit=${encodeURIComponent(row.id)}`)}
onSelectionChange={...}
/>
// For the unmatched lane, rows have a `kind` of "claim" or "remit" — dispatch:
<Lane
name="UNMATCHED"
rows={lanes.unmatched}
onRowClick={(row) => {
if (row.kind === "remit") navigate(`/remittances?remit=${encodeURIComponent(row.id)}`);
else navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
}}
onSelectionChange={...}
/>
```
The exact row shape depends on the `LaneRow` type — verify by reading `src/components/inbox/Lane.tsx`. The key dispatch is: remit rows → RemitDrawer; claim rows → ClaimDrawer.
- [ ] **Step 3: Smoke**
Open `/inbox` → click a candidates row → navigates to `/remittances?remit=…` → drawer opens. Click an unmatched remit row → same.
- [ ] **Step 4: Commit**
```bash
git add src/pages/Inbox.tsx
git commit -m "feat(inbox): candidates + unmatched-remit rows drillable"
```
### Task 4.5: Wire Activity log `remit_received` event
**Files:**
- Modify: `src/lib/event-routing.ts`
- Modify: `src/pages/ActivityLog.tsx`
- [ ] **Step 1: Update event-routing helper**
```ts
// src/lib/event-routing.ts
case "remit_received":
return `/remittances?remit=${encodeURIComponent(event.entityId)}`;
```
- [ ] **Step 2: Pass onItemClick to ActivityFeed in ActivityLog**
Same pattern as Task 2.5: import `eventKindToUrl`, pass to `<ActivityFeed onItemClick={...} />`.
- [ ] **Step 3: Smoke**
Open `/activity` → click a `remit_received` event → URL becomes `/remittances?remit=…` → drawer opens.
- [ ] **Step 4: Commit**
```bash
git add src/lib/event-routing.ts src/pages/ActivityLog.tsx
git commit -m "feat(activity): remit_received events drill to RemitDrawer"
```
### Task 4.6: Wire Remittances claim-id cell
**Files:**
- Modify: `src/pages/Remittances.tsx`
- [ ] **Step 1: Wrap the Claim <TableCell> with DrillableCell**
Find the `<TableCell className="display mono text-[12.5px] text-muted-foreground">{r.claimId}</TableCell>` line in the remits table. Wrap with `DrillableCell` that navigates to `/claims?claim=ID`:
```tsx
<TableCell>
<DrillableCell onClick={() => navigate(`/claims?claim=${encodeURIComponent(r.claimId)}`)}>
{r.claimId}
</DrillableCell>
</TableCell>
```
- [ ] **Step 2: Smoke**
Click a claim id in the remits table → navigates to `/claims?claim=…` (claim drawer will open once Phase 5 lands).
- [ ] **Step 3: Commit**
```bash
git add src/pages/Remittances.tsx
git commit -m "feat(remits): claim id cell drills to /claims?claim=ID"
```
### Task 4.7: Wire ClaimDrawer matched-remit link
**Files:**
- Modify: `src/components/ClaimDrawer/MatchedRemitCard.tsx`
The existing card already has a "View remittance →" link that navigates somewhere. Update it to navigate to `/remittances?remit=ID` and ensure the remits page mounts the drawer.
- [ ] **Step 1: Read MatchedRemitCard**
Open the file. Find the link/button that takes the operator to the remittance detail.
- [ ] **Step 2: Update the navigation target**
```tsx
<Link to={`/remittances?remit=${matched.id}`} className="…">
View remittance
</Link>
```
- [ ] **Step 3: Smoke**
Open a claim with a matched remittance → click "View remittance →" → navigates to `/remittances?remit=…` → RemitDrawer opens.
- [ ] **Step 4: Commit**
```bash
git add src/components/ClaimDrawer/MatchedRemitCard.tsx
git commit -m "feat(claim-drawer): matched-remit link drills to RemitDrawer"
```
### Task 4.8: Phase 4 merge to main
- [ ] **Step 1: Verify green + smoke steps 13, 14, 17, 19**
- [ ] **Step 2: FF-merge + push**
```bash
git checkout main && git merge --ff-only universal-drilldown && git push origin main
```
---
## Phase 5 — AckDrawer + 8 final surfaces
**Closes smoke steps:** 8, 9, 11, 12, 16, 18, 20, 21, 22, 23.
### Task 5.1: useAckDrawerUrlState hook
**Files:**
- Create: `src/hooks/useAckDrawerUrlState.ts`
Same shape as useProviderDrawerUrlState but for `?ack=`.
- [ ] **Step 1: Implement** (mirror Task 2.1 with `?ack=`)
- [ ] **Step 2: Commit**
```bash
git add src/hooks/useAckDrawerUrlState.ts
git commit -m "feat(drill): useAckDrawerUrlState — ?ack= URL sync"
```
### Task 5.2: AckDrawer
**Files:**
- Create: `src/components/AckDrawer/AckDrawer.tsx`
- Create: `src/components/AckDrawer/AckHeader.tsx`
- Create: `src/components/AckDrawer/SegmentStatusList.tsx`
- Create: `src/components/AckDrawer/index.ts`
- Create: `src/components/AckDrawer/AckDrawer.test.tsx`
- Create: `src/hooks/useAckDetail.ts`
- [ ] **Step 1: Add useAckDetail**
```ts
// src/hooks/useAckDetail.ts
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
export function useAckDetail(ackId: string | null) {
return useQuery({
queryKey: ["ack-detail", ackId],
queryFn: () => api.getAck(ackId as string),
enabled: ackId !== null,
staleTime: 60 * 1000,
});
}
```
- [ ] **Step 2: Write the failing test**
```tsx
// src/components/AckDrawer/AckDrawer.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { AckDrawer } from "@/components/AckDrawer";
vi.mock("@/hooks/useAckDetail", () => ({
useAckDetail: () => ({
data: {
id: 42,
sourceBatchId: "BATCH-123",
ackCode: "A",
acceptedCount: 10,
rejectedCount: 2,
receivedCount: 12,
parsedAt: "2026-06-20T12:00:00Z",
segments: [
{ segment: "ST", value: "999", status: "accepted" },
{ segment: "AK1", value: "HC*1234", status: "accepted" },
],
},
isLoading: false,
}),
}));
describe("AckDrawer", () => {
it("renders the ACK header and segment status list", () => {
render(<AckDrawer ackId="42" onClose={() => {}} />);
expect(screen.getByText("999 ACK #42")).toBeInTheDocument();
expect(screen.getByText("BATCH-123")).toBeInTheDocument();
});
});
```
- [ ] **Step 3: Implement**
```tsx
// src/components/AckDrawer/AckHeader.tsx
import { Download } from "lucide-react";
import { AckCodeBadge } from "@/components/AcksPage"; // may need extraction
import { fmt } from "@/lib/format";
import type { Ack } from "@/types";
export function AckHeader({ ack, onDownload }: { ack: Ack; onDownload: () => void }) {
return (
<div className="flex items-center justify-between border-b border-border/30 px-6 py-4">
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
999 ACK #{ack.id}
</div>
<h2 className="text-[18px] font-semibold tracking-tight mt-0.5 mono">
{ack.sourceBatchId}
</h2>
<div className="text-[12px] text-muted-foreground mt-1">
Parsed {ack.parsedAt ? fmt.dateShort(ack.parsedAt) : "—"}
</div>
</div>
<div className="flex items-center gap-3">
<AckCodeBadge code={ack.ackCode} />
<button
type="button"
onClick={onDownload}
className="rounded-md border border-border/60 px-2.5 py-1 text-[11.5px] hover:bg-muted/40"
aria-label="Download 999"
>
<Download className="h-3 w-3 inline mr-1" /> 999
</button>
</div>
</div>
);
}
```
```tsx
// src/components/AckDrawer/SegmentStatusList.tsx
import type { Ack } from "@/types";
export function SegmentStatusList({ ack }: { ack: Ack }) {
// ack.segments may be undefined for older records; fall back to a count summary.
const segments = ack.segments ?? [];
if (segments.length === 0) {
return (
<div className="text-[13px] text-muted-foreground">
{ack.acceptedCount} accepted · {ack.rejectedCount} rejected · {ack.receivedCount} received
</div>
);
}
return (
<div className="rounded-md border border-border/40 overflow-x-auto">
<table className="w-full text-[12px]">
<thead className="bg-muted/20 text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">Segment</th>
<th className="px-3 py-2 text-left font-medium">Value</th>
<th className="px-3 py-2 text-left font-medium">Status</th>
</tr>
</thead>
<tbody>
{segments.map((s, i) => (
<tr key={i} className="border-t border-border/30">
<td className="px-3 py-2 mono">{s.segment}</td>
<td className="px-3 py-2 mono">{s.value}</td>
<td className="px-3 py-2">{s.status}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
```
```tsx
// src/components/AckDrawer/AckDrawer.tsx
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { AckHeader } from "./AckHeader";
import { SegmentStatusList } from "./SegmentStatusList";
import { useAckDetail } from "@/hooks/useAckDetail";
import { api } from "@/lib/api";
interface Props {
ackId: string | null;
onClose: () => void;
}
export function AckDrawer({ ackId, onClose }: Props) {
const { data, isLoading } = useAckDetail(ackId);
return (
<Dialog open={ackId !== null} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
aria-describedby={undefined}
>
{ackId === null ? null : isLoading || !data ? (
<div className="p-6 space-y-2">
<Skeleton variant="row" />
<Skeleton variant="row" />
<Skeleton variant="row" />
</div>
) : (
<>
<AckHeader
ack={data}
onDownload={async () => {
const detail = await api.getAck(data.id);
const raw = (detail as unknown as { raw_999_text?: string }).raw_999_text ?? "";
if (raw) {
const blob = new Blob([raw], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = `ack-${data.sourceBatchId}.999`;
a.click(); URL.revokeObjectURL(url);
}
}}
/>
<div className="p-6 overflow-y-auto h-[calc(100%-72px)] space-y-4">
<SegmentStatusList ack={data} />
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
```
```ts
// src/components/AckDrawer/index.ts
export { AckDrawer } from "./AckDrawer";
```
- [ ] **Step 4: Run test + commit**
```bash
npm test -- AckDrawer.test.tsx
git add src/components/AckDrawer/ src/hooks/useAckDetail.ts
git commit -m "feat(drill): AckDrawer with header + segment status list"
```
### Task 5.3: Wire Acks page
**Files:**
- Modify: `src/pages/Acks.tsx`
- [ ] **Step 1: Mount the drawer + make rows clickable**
```tsx
// In Acks.tsx:
import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState";
import { AckDrawer } from "@/components/AckDrawer";
const { ackId, open, close } = useAckDrawerUrlState();
// In the table row, wrap or add onClick:
<TableRow
key={a.id}
onClick={() => open(String(a.id))}
className="cursor-pointer drillable"
...
>
// At the end of returned JSX:
<AckDrawer ackId={ackId} onClose={close} />
```
- [ ] **Step 2: Smoke**
Click an ACK row → URL becomes `/acks?ack=…` → drawer opens with header + segment list.
- [ ] **Step 3: Commit**
```bash
git add src/pages/Acks.tsx
git commit -m "feat(acks): row click opens AckDrawer"
```
### Task 5.4: Wire Inbox rejected + payer_rejected + done_today rows
**Files:**
- Modify: `src/pages/Inbox.tsx`
- [ ] **Step 1: Wire the three lanes**
In `src/pages/Inbox.tsx`, find the `Lane` components for `rejected`, `payer_rejected`, and `done_today`. Add `onRowClick` for each:
```tsx
// Rejected (claims): all rows → ClaimDrawer
<Lane
name="REJECTED"
rows={lanes.rejected}
onRowClick={(row) => navigate(`/claims?claim=${encodeURIComponent(row.id)}`)}
onSelectionChange={...}
/>
// Payer-Rejected: same as rejected
<Lane name="PAYER REJECTED" rows={lanes.payer_rejected} onRowClick={...} />
// Done today: rows have a kind (claim or remit)
<Lane
name="DONE"
rows={lanes.done_today}
onRowClick={(row) => {
if (row.kind === "remit") navigate(`/remittances?remit=${encodeURIComponent(row.id)}`);
else navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
}}
onSelectionChange={...}
/>
```
(Verify the exact `row.kind` discriminator by reading `src/components/inbox/Lane.tsx`.)
- [ ] **Step 2: Smoke**
Click rows in each lane → navigates to the right entity.
- [ ] **Step 3: Commit**
```bash
git add src/pages/Inbox.tsx
git commit -m "feat(inbox): rejected + payer_rejected + done_today rows drillable"
```
### Task 5.5: Wire Reconciliation body click
**Files:**
- Modify: `src/pages/Reconciliation.tsx`
The page uses two columns of `<button>` elements for selection. Add a separate click target on the card body that opens the entity drawer, separate from the selection button.
- [ ] **Step 1: Restructure each card**
```tsx
// Before:
<button key={c.id} type="button" onClick={() => setSelectedClaim(c.id)} aria-pressed={active} ...>
<div>... card content ...</div>
</button>
// After:
<div
key={c.id}
className={cn(
"rounded-md border transition-colors",
active ? "border-accent bg-accent/10" : "border-border/60 hover:bg-muted/30",
)}
>
<div className="p-3 cursor-pointer drillable" onClick={() => navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}>
<div className="display mono text-[13px]">{c.id}</div>
<div className="text-[12px] text-muted-foreground">{c.patientName}</div>
...
</div>
<button
type="button"
onClick={() => setSelectedClaim(c.id)}
aria-pressed={active}
className="w-full text-xs py-1.5 border-t border-border/30 hover:bg-muted/40"
>
{active ? "Selected ✓" : "Select for match"}
</button>
</div>
```
Apply the same pattern to the remits column.
- [ ] **Step 2: Smoke**
Open `/reconciliation` → click card body → navigates to entity drawer. Click "Select for match" button → toggles selection only, doesn't navigate.
- [ ] **Step 3: Commit**
```bash
git add src/pages/Reconciliation.tsx
git commit -m "feat(reconciliation): card body drillable, select button split"
```
### Task 5.6: Wire Batch diff claim id
**Files:**
- Modify: `src/components/BatchDiffView.tsx`
The diff view renders claim ids in added/removed/changed rows. Make them clickable.
- [ ] **Step 1: Wrap claim ids**
Find where claim ids are rendered in `BatchDiffView.tsx`. Wrap each with `DrillableCell`:
```tsx
<DrillableCell onClick={() => navigate(`/claims?claim=${encodeURIComponent(id)}`)}>
{id}
</DrillableCell>
```
For the "removed" case, the drawer will surface a "not found" state (the claim doesn't exist anymore) — confirm the existing `ClaimDrawer` 404 state is distinct enough to be obvious.
- [ ] **Step 2: Smoke**
Open `/batch-diff` → pick two batches → click a claim id → navigates to drawer.
- [ ] **Step 3: Commit**
```bash
git add src/components/BatchDiffView.tsx
git commit -m "feat(batch-diff): claim ids drillable to /claims?claim=ID"
```
### Task 5.7: Wire Upload "See claim in detail" link
**Files:**
- Modify: `src/pages/Upload.tsx`
In the expanded claim card body (the `ClaimCard837` and `ClaimCard835` inner sections), add a small "See claim in detail →" link.
- [ ] **Step 1: Add the link**
Inside `ClaimCard837`'s expanded body (around line 184), add:
```tsx
{claim.claim_id ? (
<div className="pt-2 border-t border-border/30">
<button
type="button"
onClick={() => navigate(`/claims?claim=${encodeURIComponent(claim.claim_id)}`)}
className="drillable text-[12.5px] text-accent hover:underline"
disabled={!persistedClaimIds.has(claim.claim_id)}
title={persistedClaimIds.has(claim.claim_id) ? "" : "Claim not yet persisted; check Claims page after parse completes."}
>
See claim in detail
</button>
</div>
) : null}
```
The `persistedClaimIds` set comes from a new piece of state populated from `appStore.parsedBatches` — flatten `parsedBatches.flatMap(b => b.claimIds)` into a Set inside the Upload component.
- [ ] **Step 2: Same for ClaimCard835**
Apply the same pattern using `claim.payer_claim_control_number` as the id and navigating to `/remittances?remit=…`.
- [ ] **Step 3: Smoke**
Upload a file → expand a streamed claim card → click "See claim in detail →" → navigates to the drawer.
- [ ] **Step 4: Commit**
```bash
git add src/pages/Upload.tsx
git commit -m "feat(upload): streamed claim cards offer drill to persisted entity"
```
### Task 5.8: ClaimDrawer · payer peek (peek on top of drawer)
**Files:**
- Modify: `src/components/ClaimDrawer/PartiesGrid.tsx` (or wherever the payer cell renders)
- Modify: `src/components/ClaimDrawer/ClaimDrawer.tsx`
The payer name inside ClaimDrawer opens a `PeekModal` on top of the drawer. The peek stack lives in `DrillStackProvider`.
- [ ] **Step 1: Add peek state in ClaimDrawer**
```tsx
// In ClaimDrawer.tsx:
import { useDrillStack } from "@/components/drill/DrillStackProvider";
import { PeekModal } from "@/components/drill/PeekModal";
import { PayerPeekContent } from "@/components/drill/PayerPeekContent";
const { stack, openPeek, closeTop } = useDrillStack();
const topPeek = stack[stack.length - 1] ?? null;
const payerPeekOpen = topPeek?.kind === "payer";
// In the returned JSX (after the drawer Dialog):
<PeekModal
open={payerPeekOpen}
onClose={closeTop}
eyebrow="Payer"
title={payerPeekOpen ? topPeek!.payerId : ""}
>
{payerPeekOpen ? <PayerPeekContent payerId={topPeek!.payerId} /> : null}
</PeekModal>
```
- [ ] **Step 2: Wire the payer cell click**
In `PartiesGrid.tsx`, wrap the payer name with `DrillableCell`:
```tsx
<DrillableCell onClick={() => openPeek({ kind: "payer", payerId: payer.id })}>
{payer.name}
</DrillableCell>
```
The `payer.id` field needs to be the payer_id (e.g. "SKCO0"), not the payer name. If the existing PartiesGrid only carries the name, extend the `ClaimDetail` API response or look up the payer_id from `payerName` via a small mapping. Verify the exact field by reading `PartiesGrid.tsx`.
- [ ] **Step 3: Smoke**
Open a claim → click the payer name inside the drawer → peek opens on top. Esc → peek closes, drawer still open. Esc again → drawer closes.
- [ ] **Step 4: Commit**
```bash
git add src/components/ClaimDrawer/ClaimDrawer.tsx src/components/ClaimDrawer/PartiesGrid.tsx
git commit -m "feat(claim-drawer): payer name opens PeekModal on top of drawer"
```
### Task 5.9: ClaimDrawer · validation rule peek
**Files:**
- Create: `src/components/drill/ValidationRulePeekContent.tsx`
- Modify: `src/components/ClaimDrawer/ValidationPanel.tsx`
- [ ] **Step 1: Build the static rule catalog**
Create `src/components/drill/ValidationRulePeekContent.tsx`:
```tsx
const RULE_HELP: Record<string, { description: string; x12?: string }> = {
R020_npi_format: {
description: "NPI must be exactly 10 digits.",
x12: "005010X222A1 — Loop 2010AA, NM109 (Billing Provider NPI)",
},
R021_npi_checksum: {
description: "NPI must pass the Luhn checksum over the body with an 80840 prefix.",
x12: "005010X222A1 — Loop 2010AA, NM109",
},
R050_diagnosis_present: {
description: "At least one diagnosis code (HI segment) is required.",
x12: "005010X222A1 — Loop 2300, HI (Health Care Information)",
},
// ... add entries as needed; unknown rules fall back to a generic message.
};
interface Props { rule: string; }
export function ValidationRulePeekContent({ rule }: Props) {
const help = RULE_HELP[rule] ?? {
description: "No extended description available. See the parser source for details.",
};
return (
<div className="space-y-3 text-[13px]">
<div>
<div className="eyebrow">Rule</div>
<div className="mono mt-1">{rule}</div>
</div>
<p>{help.description}</p>
{help.x12 ? (
<div>
<div className="eyebrow">X12 reference</div>
<p className="mono text-[11.5px] text-muted-foreground mt-1">{help.x12}</p>
</div>
) : null}
</div>
);
}
```
- [ ] **Step 2: Wire the peek in ClaimDrawer**
Add to the `useDrillStack` block in `ClaimDrawer.tsx`:
```tsx
<PeekModal
open={topPeek?.kind === "rule"}
onClose={closeTop}
eyebrow="Validation rule"
title={topPeek?.kind === "rule" ? topPeek.rule : ""}
>
{topPeek?.kind === "rule" ? <ValidationRulePeekContent rule={topPeek.rule} /> : null}
</PeekModal>
```
- [ ] **Step 3: Wire the validation issue row click**
In `ValidationPanel.tsx`, wrap the `<li>` rule row:
```tsx
<DrillableCell onClick={() => openPeek({ kind: "rule", rule: issue.rule })}>
<li key={i} className="flex items-start gap-2 text-destructive">
<XCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" strokeWidth={1.75} />
<span><span className="mono">{issue.rule}</span> {issue.message}</span>
</li>
</DrillableCell>
```
- [ ] **Step 4: Smoke**
Open a claim with validation errors → click a rule → peek opens with rule description.
- [ ] **Step 5: Commit**
```bash
git add src/components/drill/ValidationRulePeekContent.tsx src/components/ClaimDrawer/ValidationPanel.tsx src/components/ClaimDrawer/ClaimDrawer.tsx
git commit -m "feat(claim-drawer): validation rule opens peek with rule catalog"
```
### Task 5.10: Refactor ClaimDrawer onto the DrillDrawer shell
**Files:**
- Modify: `src/components/ClaimDrawer/ClaimDrawer.tsx`
- Modify: `src/components/ClaimDrawer/ClaimDrawerHeader.tsx`
The existing ClaimDrawer has its own Dialog + header. Replace with `DrillDrawerHeader` to keep the visual consistent across all drawers.
- [ ] **Step 1: Read current ClaimDrawer**
Note the existing header's content (claim id, state badge, total, close button) so the refactor preserves it.
- [ ] **Step 2: Swap headers**
```tsx
// In ClaimDrawer.tsx, replace the existing header with:
<DrillDrawerHeader
eyebrow={`Claim · ${claim.status ?? ""}`}
title={claim.id}
onClose={onClose}
// Optional primary action slot (e.g. "Download 837") can be passed
// via a new `action` prop on DrillDrawerHeader. If needed, add it
// in this task and wire the Download 837 button here.
action={onDownload837 ? <Download837Button claimId={claim.id} /> : undefined}
/>
```
Extend `DrillDrawerHeader` to accept an optional `action?: ReactNode` prop rendered to the right of the title block. Place a small gap, then `{action}`.
- [ ] **Step 3: Verify regression**
All existing ClaimDrawer tests still pass. Click a claim row → drawer opens with the same content as before, just with a slightly different header (the eyebrow may now read "Claim · paid" instead of just "paid"). Acceptable visual change.
- [ ] **Step 4: Commit**
```bash
git add src/components/ClaimDrawer/ src/components/drill/DrillDrawerHeader.tsx
git commit -m "refactor(claim-drawer): mount on shared DrillDrawerHeader shell"
```
### Task 5.11: Final smoke + main merge
- [ ] **Step 1: Run full test suite**
```bash
npm run typecheck && npm test -- --run && (cd backend && .venv/bin/pytest -q)
```
All existing + new tests pass. Total expected: ~275 frontend tests, ~412 backend tests.
- [ ] **Step 2: Run the full 24-step smoke test from spec §2.9**
Open `http://localhost:5173/` and execute each step in order. Any failure → file a follow-up issue (don't fix during smoke; commit, branch, and ship the working parts).
- [ ] **Step 3: Tag + FF-merge + push**
```bash
git tag sp21-complete
git checkout main
git merge --ff-only universal-drilldown
git push origin main --tags
```
- [ ] **Step 4: Cleanup worktree**
```bash
cd /Users/openclaw/dev/cyclone
git worktree remove .worktrees/universal-drilldown
git branch -d universal-drilldown
```
The `.superpowers/` directory (brainstorm artifacts) is already in `.gitignore` — no cleanup needed.
---
## Self-Review Checklist
After writing this plan, verify against the spec:
1. **Spec coverage:** Every smoke step in spec §2.9 maps to a task. Verified: steps 1-3 covered by PR1 existing state; step 4 by Task 1.8; step 5 by 1.9; step 6 by 1.10; step 7 by 2.5 + 3.x; step 8 by 4.7; step 9 by 5.7; step 10 by 2.4; step 11 by 5.3; step 12 by 5.8; step 13 by 4.3; step 14 by 4.6; step 15 by 2.3; step 16 by 5.3; step 17 by 4.4 + 4.5; step 18 by 5.4; step 19 by 4.5; step 20 by 5.5; step 21 by 5.6; step 22 by 5.7; step 23 by browser-back inherent.
2. **Placeholder scan:** No "TBD" / "TODO" / "similar to Task N" in steps. All code blocks are complete. ✓
3. **Type consistency:** `useDrillStack().openPeek` payload shape is `{ kind: "payer", payerId } | { kind: "rule", rule }` — used identically across Tasks 1.1, 5.8, 5.9. `eventKindToUrl` returns string | null — used identically across Tasks 2.5, 3.x, 4.5. `useProviderDrawerUrlState` / `useRemitDrawerUrlState` / `useAckDrawerUrlState` all mirror the same shape as `useDrawerUrlState` from SP4. ✓
4. **Risk coverage:** Spec §3 risk #1 (stack explosion) → DrillStackProvider enforces 2-level cap (Task 1.1). Risk #5 (Inbox row vs checkbox) → not directly addressed; add an extra check in Task 4.4 + 5.4 that the click handler is on the row body, not the checkbox.
5. **One missing item to add:** Add to Task 4.4 / 5.4: the `Lane` component's checkbox is a separate DOM target — verify the checkbox click doesn't bubble to the row click. The fix is `e.stopPropagation()` in the checkbox `onChange`. Confirm with `src/components/inbox/Lane.tsx` when implementing.
---
## Execution Choice
Plan complete and saved to `docs/superpowers/plans/2026-06-21-cyclone-universal-drilldown.md`. Two execution options:
1. **Subagent-Driven (recommended)** — Dispatch a fresh subagent per task, review between tasks, fast iteration. Best for this plan because each phase has many small TDD tasks where context isolation helps.
2. **Inline Execution** — Execute tasks in this session with checkpoints for review.
Which approach?