to be clickable: 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 `` for "Provider" (around line 286). It currently renders `{provider?.name ?? "Unknown"}{c.providerNpi}`. - [ ] **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 : navigate(`/providers?provider=${encodeURIComponent(c.providerNpi)}`)}> {provider?.name ?? "Unknown"} {c.providerNpi} ``` - [ ] **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): 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 `` 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: { 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 No recent claims.; } return ( {claims.map((c) => ( {c.id} {c.patientName ?? "—"} {fmt.usd(c.billedAmount)} ))} View all claims → ); } ``` - [ ] **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: Overview Claims Activity {data ? : } {data ? : } {data ? : } ``` - [ ] **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 No recent activity.; } return ( {items.map((a) => ( {new Date(a.ts).toLocaleString()} {a.kind} ))} ); } ``` - [ ] **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( {}} />); // 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: ``` - [ ] **Step 2: Make rows clickable** In the table row map (around line 218), change: ```tsx // Before: hasAdjustments ? toggleExpand(r.id) : undefined} ... > // After: open(r.id)} className="cursor-pointer drillable" > ``` Remove the `expanded` state, the `toggleExpand` function, and the conditional inline-expand `` 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 `` 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: navigate(`/remittances?remit=${encodeURIComponent(row.id)}`)} onSelectionChange={...} /> // For the unmatched lane, rows have a `kind` of "claim" or "remit" — dispatch: { 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 ``. - [ ] **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 with DrillableCell** Find the `{r.claimId}` line in the remits table. Wrap with `DrillableCell` that navigates to `/claims?claim=ID`: ```tsx navigate(`/claims?claim=${encodeURIComponent(r.claimId)}`)}> {r.claimId} ``` - [ ] **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 View remittance → ``` - [ ] **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( {}} />); 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 ( 999 ACK #{ack.id} {ack.sourceBatchId} Parsed {ack.parsedAt ? fmt.dateShort(ack.parsedAt) : "—"} 999 ); } ``` ```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 ( {ack.acceptedCount} accepted · {ack.rejectedCount} rejected · {ack.receivedCount} received ); } return ( Segment Value Status {segments.map((s, i) => ( {s.segment} {s.value} {s.status} ))} ); } ``` ```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 ( { if (!o) onClose(); }}> {ackId === null ? null : isLoading || !data ? ( ) : ( <> { 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); } }} /> > )} ); } ``` ```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: open(String(a.id))} className="cursor-pointer drillable" ... > // At the end of returned JSX: ``` - [ ] **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 navigate(`/claims?claim=${encodeURIComponent(row.id)}`)} onSelectionChange={...} /> // Payer-Rejected: same as rejected // Done today: rows have a kind (claim or remit) { 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 `` 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: setSelectedClaim(c.id)} aria-pressed={active} ...> ... card content ... // After: navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}> {c.id} {c.patientName} ... 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"} ``` 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 navigate(`/claims?claim=${encodeURIComponent(id)}`)}> {id} ``` 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 ? ( 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 → ) : 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): {payerPeekOpen ? : null} ``` - [ ] **Step 2: Wire the payer cell click** In `PartiesGrid.tsx`, wrap the payer name with `DrillableCell`: ```tsx openPeek({ kind: "payer", payerId: payer.id })}> {payer.name} ``` 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 = { 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 ( Rule {rule} {help.description} {help.x12 ? ( X12 reference {help.x12} ) : null} ); } ``` - [ ] **Step 2: Wire the peek in ClaimDrawer** Add to the `useDrillStack` block in `ClaimDrawer.tsx`: ```tsx {topPeek?.kind === "rule" ? : null} ``` - [ ] **Step 3: Wire the validation issue row click** In `ValidationPanel.tsx`, wrap the `` rule row: ```tsx openPeek({ kind: "rule", rule: issue.rule })}> {issue.rule} — {issue.message} ``` - [ ] **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: : 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?
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 `` for "Provider" (around line 286). It currently renders `{provider?.name ?? "Unknown"}{c.providerNpi}`. - [ ] **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 : navigate(`/providers?provider=${encodeURIComponent(c.providerNpi)}`)}> {provider?.name ?? "Unknown"} {c.providerNpi} ``` - [ ] **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): 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 `` 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: { 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 No recent claims.; } return ( {claims.map((c) => ( {c.id} {c.patientName ?? "—"} {fmt.usd(c.billedAmount)} ))} View all claims → ); } ``` - [ ] **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: Overview Claims Activity {data ? : } {data ? : } {data ? : } ``` - [ ] **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 No recent activity.; } return ( {items.map((a) => ( {new Date(a.ts).toLocaleString()} {a.kind} ))} ); } ``` - [ ] **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( {}} />); // 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: ``` - [ ] **Step 2: Make rows clickable** In the table row map (around line 218), change: ```tsx // Before: hasAdjustments ? toggleExpand(r.id) : undefined} ... > // After: open(r.id)} className="cursor-pointer drillable" > ``` Remove the `expanded` state, the `toggleExpand` function, and the conditional inline-expand `` 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 `` 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: navigate(`/remittances?remit=${encodeURIComponent(row.id)}`)} onSelectionChange={...} /> // For the unmatched lane, rows have a `kind` of "claim" or "remit" — dispatch: { 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 ``. - [ ] **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 with DrillableCell** Find the `{r.claimId}` line in the remits table. Wrap with `DrillableCell` that navigates to `/claims?claim=ID`: ```tsx navigate(`/claims?claim=${encodeURIComponent(r.claimId)}`)}> {r.claimId} ``` - [ ] **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 View remittance → ``` - [ ] **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( {}} />); 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 ( 999 ACK #{ack.id} {ack.sourceBatchId} Parsed {ack.parsedAt ? fmt.dateShort(ack.parsedAt) : "—"} 999 ); } ``` ```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 ( {ack.acceptedCount} accepted · {ack.rejectedCount} rejected · {ack.receivedCount} received ); } return ( Segment Value Status {segments.map((s, i) => ( {s.segment} {s.value} {s.status} ))} ); } ``` ```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 ( { if (!o) onClose(); }}> {ackId === null ? null : isLoading || !data ? ( ) : ( <> { 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); } }} /> > )} ); } ``` ```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: open(String(a.id))} className="cursor-pointer drillable" ... > // At the end of returned JSX: ``` - [ ] **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 navigate(`/claims?claim=${encodeURIComponent(row.id)}`)} onSelectionChange={...} /> // Payer-Rejected: same as rejected // Done today: rows have a kind (claim or remit) { 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 `` 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: setSelectedClaim(c.id)} aria-pressed={active} ...> ... card content ... // After: navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}> {c.id} {c.patientName} ... 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"} ``` 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 navigate(`/claims?claim=${encodeURIComponent(id)}`)}> {id} ``` 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 ? ( 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 → ) : 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): {payerPeekOpen ? : null} ``` - [ ] **Step 2: Wire the payer cell click** In `PartiesGrid.tsx`, wrap the payer name with `DrillableCell`: ```tsx openPeek({ kind: "payer", payerId: payer.id })}> {payer.name} ``` 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 = { 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 ( Rule {rule} {help.description} {help.x12 ? ( X12 reference {help.x12} ) : null} ); } ``` - [ ] **Step 2: Wire the peek in ClaimDrawer** Add to the `useDrillStack` block in `ClaimDrawer.tsx`: ```tsx {topPeek?.kind === "rule" ? : null} ``` - [ ] **Step 3: Wire the validation issue row click** In `ValidationPanel.tsx`, wrap the `` rule row: ```tsx openPeek({ kind: "rule", rule: issue.rule })}> {issue.rule} — {issue.message} ``` - [ ] **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: : 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?