# Sub-project 4 — Per-Claim Detail Drawer: Design Spec **Date:** 2026-06-20 **Status:** Draft (awaiting user review) **Branch:** `claim-drawer` **Aesthetic direction:** Modern (geometric sans + bold borders + electric blue accent) ## 1. Scope A right-side slide-over drawer that opens when the operator clicks any claim row on `/claims`. The drawer shows the full context for one claim in a self-contained panel: header, validation issues, service lines, diagnoses, parties (provider/subscriber/payer), raw segments, matched remittance (if any), and state history timeline. Sequential navigation: `j`/`k` or `↑`/`↓` steps between claims with the drawer staying open. URL syncs to `?claim={id}` so the drawer state is shareable and bookmarkable. ## 2. Out of scope (deferred to future sub-projects) - **Resubmit / edit actions** — needs backend write endpoints; deferred to a later SP4 phase. - **Notes / comments on claims** — separate feature, no DB schema today. - **Bulk actions** — single-operator tool; no multi-select needed. - **Auto-advance to next denied claim after closing drawer** — nice-to-have, not v1. - **Remittance detail drawer** — covered by SP3's `/api/remittances/{id}` and a follow-up if needed. - **The other 4 SP4 features from the README** — batch diff view, NDJSON streaming UI, advanced filters + saved sets, keyboard cheatsheet for the rest of the app. The cheatsheet entry for THIS drawer ships; the rest of the app's keyboard nav is deferred. ## 3. Locked decisions ### 3.1 Component decomposition The drawer is composed of 1 root component + 10 leaf components, each with one purpose. This keeps individual files small and tests focused. | Component | Responsibility | |-----------|---------------| | `ClaimDrawer.tsx` | Root: renders `Sheet`, fetches detail via `useClaimDetail`, handles j/k keyboard nav, manages URL sync | | `ClaimDrawerHeader.tsx` | Claim ID, state badge, total amount, close button | | `ValidationPanel.tsx` | Errors + warnings grouped by rule; per-rule counts | | `ServiceLinesTable.tsx` | All service lines with procedure, modifiers, charge, units, date | | `DiagnosesList.tsx` | HI segment codes with qualifiers | | `PartiesGrid.tsx` | 3 cards: Billing Provider, Subscriber, Payer | | `RawSegmentsPanel.tsx` | Collapsible `
` with full segment list (debug aid) | | `MatchedRemitCard.tsx` | Conditional; shows matched remittance summary + "View remittance →" link | | `StateHistoryTimeline.tsx` | Vertical timeline of `ActivityEvent` rows for this claim | | `ClaimDrawerSkeleton.tsx` | Loading state with shimmering placeholders | | `ClaimDrawerError.tsx` | 404 / network error / retry | All live under `src/components/ClaimDrawer/`. Each is a leaf component with explicit props (no context drilling beyond `claim: ClaimDetail`). ### 3.2 Backend endpoint: `GET /api/claims/{claim_id}` New endpoint returning the full claim detail. Read-only. **Response shape:** ```json { "id": "C-12345", "batchId": "8f4a...", "state": "submitted", "stateLabel": "Submitted", "billedAmount": 100.00, "patientName": "John Doe", "providerNpi": "1234567890", "providerName": "Test Provider", "payerName": "COHCPF", "payerId": "SKCO0", "submissionDate": "2026-06-11T00:00:00Z", "serviceDateFrom": "2026-06-11", "serviceDateTo": "2026-06-11", "parsedAt": "2026-06-11T12:00:00Z", "diagnoses": [{"code": "Z00", "qualifier": "ABK"}], "serviceLines": [ {"lineNumber": 1, "procedureQualifier": "HC", "procedureCode": "99213", "modifiers": [], "charge": 80.00, "units": 1.0, "unitType": "UN", "serviceDate": "2026-06-11"} ], "parties": { "billingProvider": {"name": "...", "npi": "...", "taxId": "...", "address": {...}}, "subscriber": {"firstName": "...", "lastName": "...", "memberId": "...", "dob": "...", "gender": "..."}, "payer": {"name": "...", "id": "..."} }, "validation": { "passed": false, "errors": [{"rule": "R050_diagnosis_present", "severity": "error", "message": "..."}], "warnings": [] }, "rawSegments": [["ISA", ...], ["CLM", ...]], "matchedRemittance": null, "stateHistory": [ {"kind": "claim_submitted", "ts": "2026-06-11T12:00:00Z", "batchId": "8f4a..."}, {"kind": "manual_match", "ts": "2026-06-12T10:00:00Z", "remittanceId": "R-9876"} ] } ``` **Implementation:** - Add `CycloneStore.get_claim_detail(claim_id) -> dict | None` to `backend/src/cyclone/store.py` - Reads from `claims.raw_json` (already populated by SP1 ingest) → deserializes to `ClaimOutput` → maps to UI shape - Queries `activity_events WHERE claim_id = ?` ordered by `ts DESC` for `stateHistory` - If `claims.matched_remittance_id` is set, looks up the remittance and embeds a summary (`{id, totalPaid, status, ackDate}`) - 404 if claim not found ### 3.3 Visual design tokens ```css :root { /* Surface */ --surface: #FAFAF7; /* cream-white drawer background */ --surface-dim: #6B6B6B22; /* list dim overlay when drawer open */ /* Ink */ --ink-primary: #0A0A0A; --ink-secondary: #6B6B6B; --ink-tertiary: #A0A0A0; /* Borders (the brutalist accent) */ --border-heavy: #1A1A1A; /* 2px section dividers */ --border-default: #E5E5E2; /* 1px hairline borders */ /* Accent */ --accent: #1D4ED8; /* electric blue */ --accent-hover: #1E40AF; --focus-ring: #1D4ED8; /* Semantic */ --error: #DC2626; --error-bg: #FEF2F2; --success: #15803D; --success-bg: #F0FDF4; --warning: #B45309; --warning-bg: #FFFBEB; } ``` **Type stack:** - Display / labels / body: **Inter Tight** (400, 500, 600, 700, 800 weights) via Google Fonts or local WOFF2 - Monospace (IDs, amounts, codes): **JetBrains Mono** (400, 600) — same family as SP3 Industrial direction - Section labels: 10px uppercase, letter-spacing 0.18em, font-weight 700, color `--ink-secondary` **Layout:** - Drawer width: **560px** on desktop (`≥1024px`), **420px** on tablet (768-1023px), **full-screen** sheet on `<768px` - Section dividers: 2px solid `--border-heavy` (signature Modern touch) - Inner padding: 24px sides, 20px vertical per section - Big numbers: 32px font for the total charge; 24px for service-line charges; 18px for diagnosis codes ### 3.4 Interaction patterns **Open:** - Click any row on `/claims` (whole row is the click target except the row checkbox if present). - URL updates to `?claim={id}` via `history.replaceState` (no navigation, no re-fetch of list). - Drawer slides in from right, 240ms ease-out. List dimmed to 60% opacity via overlay. **Sequential navigation:** - `j` or `↓` → next claim in the current sort order. - `k` or `↑` → previous claim. - Wraps: from last claim, `j` goes to first. - New claim detail fetches in background; previous detail stays visible during fetch (no flash of skeleton). - URL updates as you navigate (`?claim={newId}`). **Close:** - `ESC` key, X button, click on the dimmed overlay. - URL strips the `?claim=` param. **Loading:** - Skeleton with shimmering placeholders matching section heights (~8-12 skeleton blocks). - Fetch expected ~200-400ms on local backend; user perceives ~instant. **Errors:** - 404 (claim ID in URL but no row): "Claim not found" + Close button + a hint to refresh the list. - Network error: "Couldn't reach the server" + Retry button. - Both inline in the drawer, not a toast (drawer is the focused surface). **Keyboard cheatsheet:** - `?` toggles a small centered overlay (the drawer is dimmed further). - Lists: `j` next, `k` prev, `↑`/`↓` same as j/k, `esc` close, `?` toggle this help. - Dismisses on any other key or click outside. ### 3.5 URL state contract - `/claims` → list, no drawer. - `/claims?claim=C-12345` → list with drawer open for C-12345. - Browser back/forward navigates through claim selections; Esc closes and pops the URL state. - Deep link works: opening `/claims?claim=C-12345` directly loads the list AND opens the drawer for that claim. - If the claim ID in the URL doesn't exist (was deleted, stale link), the drawer shows the 404 state. ### 3.6 Frontend file layout ``` src/ ├── components/ │ └── ClaimDrawer/ │ ├── ClaimDrawer.tsx │ ├── ClaimDrawerHeader.tsx │ ├── ValidationPanel.tsx │ ├── ServiceLinesTable.tsx │ ├── DiagnosesList.tsx │ ├── PartiesGrid.tsx │ ├── RawSegmentsPanel.tsx │ ├── MatchedRemitCard.tsx │ ├── StateHistoryTimeline.tsx │ ├── ClaimDrawerSkeleton.tsx │ ├── ClaimDrawerError.tsx │ └── index.ts # barrel export ├── hooks/ │ ├── useClaimDetail.ts # fetches GET /api/claims/{id} │ ├── useDrawerKeyboard.ts # j/k/esc handlers │ └── useDrawerUrlState.ts # ?claim= sync ├── lib/ │ └── api.ts # +api.getClaimDetail(id), +api.getClaimHistory(id) (optional split) └── pages/ ├── Claims.tsx # wire click + drawer render └── Claims.test.tsx # +new tests ``` ### 3.7 Testing targets **Frontend (Vitest + React Testing Library):** - `ClaimDrawerHeader.test.tsx` — renders ID, state badge, total; close button calls callback - `ValidationPanel.test.tsx` — empty state, error-only, warning-only, both; grouping by rule - `ServiceLinesTable.test.tsx` — empty, single line, multiple lines; procedure code formatting - `DiagnosesList.test.tsx` — empty, single, multiple; qualifier shown when present - `PartiesGrid.test.tsx` — all 3 cards; missing fields gracefully omitted - `RawSegmentsPanel.test.tsx` — collapsed by default; toggle expands - `MatchedRemitCard.test.tsx` — null state (no "View" button); populated state with link - `StateHistoryTimeline.test.tsx` — empty, single event, multiple events; kind-color mapping - `ClaimDrawer.test.tsx` — full lifecycle: open, fetch detail, display sections, j/k nav, esc close, URL sync - `Claims.test.tsx` — click row opens drawer, deep link opens drawer, esc closes, URL clears **Backend (pytest):** - `test_get_claim_detail_happy_path` — full claim with all relations - `test_get_claim_detail_not_found_returns_404` - `test_get_claim_detail_state_history_includes_manual_match` - `test_get_claim_detail_matched_remittance_summary_inlined` **Target counts:** ~8 new frontend tests, ~4 new backend tests. Existing 10 frontend + 409 backend tests must all stay green. ### 3.8 Smoke test (end-of-SP4) 1. Start backend (`backend/.venv/bin/python -m cyclone serve`). 2. Start frontend (`npm run dev`). 3. Open `http://localhost:5173/claims`. 4. Click first claim row → drawer opens with all sections populated. 5. Press `k` → drawer updates to previous claim; URL updates. 6. Press `j` repeatedly → wrap behavior works. 7. Press `?` → cheatsheet shows. 8. Press `esc` → drawer closes; URL strips `?claim=`. 9. Open `http://localhost:5173/claims?claim=` directly → drawer opens on page load. 10. Commit empty `smoke: end-to-end SP4 (claim drawer) flow passes`. ## 4. Risk areas 1. **Backend payload weight.** A claim with 50 service lines and 50 raw segments is ~50KB JSON. The drawer endpoint loads on every j/k press. Cache the parsed `ClaimOutput` in the React Query / SWR cache so navigation between claims is instant after the first fetch. 2. **ActivityEvent volume.** A claim with 20 manual_match/unmatch events is verbose. Default `stateHistory` to last 50; expose `?limit=N` if the operator wants more. 3. **Keyboard nav collisions.** `j`/`k` collide with browser find-in-page. Acceptable tradeoff for an internal tool — operator can `esc` out of the drawer to use browser search. Documented in the cheatsheet. 4. **Drawer + mobile.** Full-screen sheet on mobile means the list is hidden. Operator on mobile loses context. Acceptable for v1 (operator is on desktop per project context). 5. **URL sync race condition.** User opens drawer, navigates to claim 5, then refreshes. URL has `?claim=5` but cache has claim 1's detail. Page load must honor the URL claim, not the cache. The `useDrawerUrlState` hook reads the URL on mount and triggers the fetch. ## 5. Migration / rollout - Single feature branch `claim-drawer` cut from `main` (parent: post-SP3 merge, `a1dc8a5`). - Worktree at `.worktrees/claim-drawer/`, following SP1-3 pattern. - No DB migrations (uses existing tables: `claims`, `batches`, `activity_events`, `remittances`). - No new dependencies (Radix `Dialog` / `Sheet` from shadcn/ui — already in package.json). - Merge to main after smoke passes; cleanup worktree + branch.