3fa61bb3f6
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
2627 lines
86 KiB
Markdown
2627 lines
86 KiB
Markdown
# 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?
|