12 KiB
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 <details> 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:
{
"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 | Nonetobackend/src/cyclone/store.py - Reads from
claims.raw_json(already populated by SP1 ingest) → deserializes toClaimOutput→ maps to UI shape - Queries
activity_events WHERE claim_id = ?ordered byts DESCforstateHistory - If
claims.matched_remittance_idis set, looks up the remittance and embeds a summary ({id, totalPaid, status, ackDate}) - 404 if claim not found
3.3 Visual design tokens
: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}viahistory.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:
jor↓→ next claim in the current sort order.kor↑→ previous claim.- Wraps: from last claim,
jgoes 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:
ESCkey, 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:
jnext,kprev,↑/↓same as j/k,escclose,?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-12345directly 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 callbackValidationPanel.test.tsx— empty state, error-only, warning-only, both; grouping by ruleServiceLinesTable.test.tsx— empty, single line, multiple lines; procedure code formattingDiagnosesList.test.tsx— empty, single, multiple; qualifier shown when presentPartiesGrid.test.tsx— all 3 cards; missing fields gracefully omittedRawSegmentsPanel.test.tsx— collapsed by default; toggle expandsMatchedRemitCard.test.tsx— null state (no "View" button); populated state with linkStateHistoryTimeline.test.tsx— empty, single event, multiple events; kind-color mappingClaimDrawer.test.tsx— full lifecycle: open, fetch detail, display sections, j/k nav, esc close, URL syncClaims.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 relationstest_get_claim_detail_not_found_returns_404test_get_claim_detail_state_history_includes_manual_matchtest_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)
- Start backend (
backend/.venv/bin/python -m cyclone serve). - Start frontend (
npm run dev). - Open
http://localhost:5173/claims. - Click first claim row → drawer opens with all sections populated.
- Press
k→ drawer updates to previous claim; URL updates. - Press
jrepeatedly → wrap behavior works. - Press
?→ cheatsheet shows. - Press
esc→ drawer closes; URL strips?claim=. - Open
http://localhost:5173/claims?claim=<known-id>directly → drawer opens on page load. - Commit empty
smoke: end-to-end SP4 (claim drawer) flow passes.
4. Risk areas
- 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
ClaimOutputin the React Query / SWR cache so navigation between claims is instant after the first fetch. - ActivityEvent volume. A claim with 20 manual_match/unmatch events is verbose. Default
stateHistoryto last 50; expose?limit=Nif the operator wants more. - Keyboard nav collisions.
j/kcollide with browser find-in-page. Acceptable tradeoff for an internal tool — operator canescout of the drawer to use browser search. Documented in the cheatsheet. - 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).
- URL sync race condition. User opens drawer, navigates to claim 5, then refreshes. URL has
?claim=5but cache has claim 1's detail. Page load must honor the URL claim, not the cache. TheuseDrawerUrlStatehook reads the URL on mount and triggers the fetch.
5. Migration / rollout
- Single feature branch
claim-drawercut frommain(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/Sheetfrom shadcn/ui — already in package.json). - Merge to main after smoke passes; cleanup worktree + branch.