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
86 KiB
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):
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
// 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
// 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
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
// 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
// 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
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
// 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
// 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
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.drillableblock 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:
/* 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>:
// 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
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/payerslives — find withgrep -rn 'payers/{payer_id}' backend/src/cyclone/api_routers/) -
Create:
backend/tests/test_payer_summary.py -
Step 1: Write the failing test
# 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:
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:
# 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
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}(likelybackend/src/cyclone/api_routers/providers.pyor similar — find withgrep -rn 'providers/{npi}' backend/src/) -
Modify:
src/types/index.ts(extendProviderinterface) -
Create:
backend/tests/test_provider_extended_response.py -
Step 1: Write the failing test
# 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:
# 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:
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
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(addgetPayerSummarymethod) -
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:
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
// 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
// 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
// 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
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:
import { useNavigate } from "react-router-dom";
import { DrillableCell } from "@/components/drill/DrillableCell";
Inside the Dashboard() function body, before the return:
const navigate = useNavigate();
Then wrap each <KpiCard>:
// 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
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
// 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:
<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
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:
// 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
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
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
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
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
// 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
// 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
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
// 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
// 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
// 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>
);
}
// 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>
);
}
// 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>
);
}
// 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
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
// 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
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
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
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 — checksrc/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
// 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
// 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
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
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
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
// 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
// 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)
// 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
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
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
// src/hooks/useRemitDrawerUrlState.ts — copy from useProviderDrawerUrlState
// and substitute "remit" everywhere.
- Step 3: Commit
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:
// 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
// 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
// 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:
// 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
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
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
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
// 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
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 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:
<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
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
<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
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
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
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
// 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
// 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
// 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>
);
}
// 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>
);
}
// 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>
);
}
// src/components/AckDrawer/index.ts
export { AckDrawer } from "./AckDrawer";
- Step 4: Run test + commit
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
// 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
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:
// 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
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
// 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
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:
<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
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:
{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
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
// 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:
<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
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:
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:
<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:
<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
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
// 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
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
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
git tag sp21-complete
git checkout main
git merge --ff-only universal-drilldown
git push origin main --tags
- Step 4: Cleanup worktree
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:
-
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.
-
Placeholder scan: No "TBD" / "TODO" / "similar to Task N" in steps. All code blocks are complete. ✓
-
Type consistency:
useDrillStack().openPeekpayload shape is{ kind: "payer", payerId } | { kind: "rule", rule }— used identically across Tasks 1.1, 5.8, 5.9.eventKindToUrlreturns string | null — used identically across Tasks 2.5, 3.x, 4.5.useProviderDrawerUrlState/useRemitDrawerUrlState/useAckDrawerUrlStateall mirror the same shape asuseDrawerUrlStatefrom SP4. ✓ -
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.
-
One missing item to add: Add to Task 4.4 / 5.4: the
Lanecomponent's checkbox is a separate DOM target — verify the checkbox click doesn't bubble to the row click. The fix ise.stopPropagation()in the checkboxonChange. Confirm withsrc/components/inbox/Lane.tsxwhen implementing.
Execution Choice
Plan complete and saved to docs/superpowers/plans/2026-06-21-cyclone-universal-drilldown.md. Two execution options:
- 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.
- Inline Execution — Execute tasks in this session with checkpoints for review.
Which approach?