258 lines
12 KiB
Markdown
258 lines
12 KiB
Markdown
# 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:**
|
|
```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=<known-id>` 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.
|