# 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.
