SP21 Task 2.5 — the Dashboard's 'Recent activity' card now routes clicks
to the matching entity drawer / page by event kind:
- claim_* → /claims?claim=<id> (drawer in Phase 5)
- provider_added → /providers?provider=<npi> (ProviderDrawer)
- remit_received → toast 'coming in a later phase' (RemitDrawer in Phase 4)
- anything else → toast (manual_match, unknown kinds)
Implementation:
- New src/lib/event-routing.ts with the eventKindToUrl() helper,
plus a unit test covering all 6 + default branches.
- src/components/ActivityFeed.tsx gains an optional onItemClick
prop; when set, each row gets role='button', tabIndex=0, the
drillable hover affordance (chevron + tint), and an Enter/Space
keybinding. e.stopPropagation() is called before the handler so a
parent row click can't double-fire (same fix as Task 2.4).
- src/pages/Dashboard.tsx wires the handler on the 'Recent activity'
card via eventKindToUrl + sonner toast for unhandled kinds.
- Backend: CycloneStore.recent_activity() now exposes claimId and
remittanceId on each row (read from ActivityEvent.claim_id /
remittance_id) so the routing helper has the entity ids it needs.
- The frontend Activity interface gains optional claimId /
remittanceId fields; the in-memory sample data and the
addClaim store action populate them so the dashboard works in
both API-configured and sample-data modes.
Quality-fix iteration on 078c9ad. Resolves the 4 Important issues
flagged in the code review while preserving everything that's working.
Issue 1 — ProviderDrawer silently swallowed error states (infinite
skeleton on any failure). Now mirrors ClaimDrawer/RemitDrawer:
- Destructure { data, isLoading, isError, error, refetch }
- Compute errorKind = ApiError(404) → not_found, else → network
- New ProviderDrawerError.tsx renders the right copy + retry/close
- Body branches on errorKind → loading → data
Issue 2 — useProviderDetail did not match the useClaimDetail contract:
- 404 retry guard (no retries on 404, <=3 on everything else)
- Explicit typed return shape { data, isLoading, isError, error,
refetch }
- npi === null short-circuit after the useQuery call
Issue 3 — Provider drilldown was non-functional in demo mode. Added
the useSyncExternalStore fallback to useAppStore.providers (same
pattern as useProviders), gated by api.isConfigured. Unknown NPI in
demo mode surfaces via the error branch instead of an infinite
skeleton.
Issue 4 — Test was shape-only (1 case). Rewrote with vi.hoisted +
vi.fn() hook mock and 7 cases covering null/loading/404/network/
close/success/hook-call-shape.
Issue 5 — Replaced h-[calc(100%-64px)] with a flex layout
(flex h-full flex-col on DialogContent, flex h-full flex-col
overflow-y-auto on inner wrappers). Mirrors RemitDrawer's pattern;
no more coupling to DrillDrawerHeader's padding/font sizes.
Issue 7 — Removed the dead npi === null ? null : (...) wrapper
inside DialogContent; Dialog open={npi !== null} already gates it.
Issue 8 — Trailing newlines added across all touched + created files.
Self-review: tsc clean on changed files; drawer test suite 211/211
passing; full test suite 369/372 (3 pre-existing Inbox/Reconciliation
failures unchanged). ProviderDrawer now 7/7.
- Replace small header with editorial hero: massive 'Compare *batches.*'
(clamp 48-80px serif, italic for 'batches.') + ghost 'DIFF' watermark
+ GitCompareArrows pill (837P/835/first vs second) + pick-a-batch hint
- Add torn-page fold with '↘ A → B ↙' label and 48-dot perforation
- Cream paper plane (max-w-[1280px] mx-auto) with paper-grain SVG and
double-bordered title block: 'SHEET 01 · COMPARE TWO BATCHES' over
'Compare them.' (clamp 48-96px) plus A→B wire indicator (blue A,
amber B, paper-ink-3 placeholder)
- New Folio helper renders the § N + vertical-rl italic label; reused
on the picks and diff sections
- § 01 The picks: BatchPicker gets a paper-toned card with side badge
(A blue / B amber tinted square) and claim count, preserving the
data-testid='diff-picker-{a,b}' and 'diff-picker-kind-{kind}' and
'diff-picker-option-{id}' contracts
- § 02 The diff: conditional rendering (awaiting-picks dashed card,
loading skeleton, not-found, error, empty diff, full diff view)
with paper-toned wrappers
- BatchDiffView refactored to paper-toned chrome: SideMeta cards
(warm surface, surface-ink text), SummaryCards → new DiffKpiTile
helper (success/destructive/amber/ink accent rails with lucide
icons), SectionTables → tone='paper' tables with cream header band
and surface-ink text. All section/row/indicator testids preserved.
- Action footer: 'Comparing A with B.' italic note + Refresh / Clear
picks buttons; paper-toned container, sits inside the paper plane
- Bottom footer: 'End of sheet 01 / Cyclone · Batch diff / A vs B
or awaiting picks'
Round 11/11 — last page in the hybrid sweep.
- Add tone='paper' prop to shared Table/TableHeader/TableRow/TableHead/
TableCell components; paper-toned chrome swaps dark muted/20 header
for cream paper, dark hover for warm cream hover, dark selected for
paper blue tint, muted-foreground text for surface-ink. Dark tone
is the default and unchanged.
- Extend BatchesList with tone='paper' variant: cream hover, warm
surface-ink text, paper-tinted open-row (blue tint + ring). Kind
badge (text-sky-300 / text-amber-300 + data-testid) and row
data-testid='batch-row-{id}' preserved for the test contract.
- Replace PageHeader with editorial hero: massive 'Parsed *batches.*'
(clamp 48-80px serif, italic for 'batches.') + ghost 'PARSED'
watermark + Files pill (837P/835/envelope) + tap-to-open hint
- Add torn-page fold with '↘ THE REGISTER ↙' label and 48-dot perforation
- Cream paper plane (max-w-[1280px] mx-auto) with paper-grain SVG and
double-bordered title block: 'REGISTER · PARSED BATCHES' over
'The register.' (clamp 48-96px) plus on-file count column
- Folio system: § 01 Vital signs (4 paper KPI tiles via new
BatchesKpiTile: On file / 837P / 835 / Claims with ink/blue/amber/
success accent rails and Icons from lucide), § 02 The register
(paper-toned BatchesList)
- Error/Loading/Empty states wrapped in paper-toned containers
- Footer: 'End of register / Cyclone · Batches / N rows'
Round 10/11.
InboxHeader:
- Editorial dark hero replaces the 30px sticky header
- Massive 'Inbox.' (clamp 48-80px) with ghost amber 'TRIAGE' watermark
- Italic subtitle: 'Five lanes, one queue. N need eyes; N done today.'
- Right column: SUN JUN 21 / 14:15 / local
- Live status row: amber pulse + counts + date
Inbox page:
- Amber fold with '↘ FIVE LANES · ONE QUEUE ↙' between header and lanes
- Lanes stay dark (working surface preserved)
- New paper-toned Queue ledger panel below lanes:
- Title block: 'The eye-flow, at a glance.' with need-eyes total
- 5 paper tiles (Rejected / Payer rejected / Candidates / Unmatched / Done today)
- Each tile shows lane accent dot + count + 'Clear'/'rows' chip
- Loading and error states now also use the new InboxHeader for consistency
- SummaryTile helper with paper-toned metric cards
- Dark editorial hero: 'Manual pairing.' (80px serif, italic pairing)
- 'AUTO-MATCH PAUSED' amber status pill on the right
- Ghost 'PAIRED' watermark behind the hero
- Dramatic torn-page fold with shadow + perforation + 'PAIR THEM BY HAND' label
- Paper plane with title block ('RECONCILIATION · SHEET 01' / 'Pair them.' 96px)
- Folio system (§ 01 Vital signs, § 02 The match, § 03 The action)
- KPI strip with 3 paper-toned tiles (Unmatched claims / remits / in queue)
- Two-column pairing surface with paper cards, blue/amber accent rails
and a center vertical bridge that flips to green-merge when both selected
- Active row uses paper-tinted blue (claim) or amber (remit) backgrounds
- Action footer with italic 'Ready to pair' / 'Now pick a remit' states
- Empty state: 'Nothing pending.' with circle-dashed icon on cream
- Footer: 'END OF SHEET 01 / CYCLONE · RECONCILIATION / N ROWS PENDING'
- Dark editorial hero: 'Parse an X12 file.' (80px serif, italic X12)
- Ghost 'DROP' watermark behind the hero
- Dramatic torn-page fold with shadow + perforation + 'DROP & PARSE' label
- Paper plane with title block ('INGESTION · SHEET 01' / 'Drop a file.' 96px)
- Right column accepts indicator (.txt / 837P · 835 / backend status pill)
- Folio system (§ 01 The drop, § 02 The stream, § 03 The archive)
- Paper-toned drop zone, selects, and claim cards (replaces dark surface-2)
- Stream section with progress bar (gradient fill) and 2-column totals
- Recent batches list with 'batches' count in footer
- Empty state with inbox icon when no batches exist
- Footer: 'END OF SHEET 01 / CYCLONE · INGESTION / N BATCHES'
Closes the gap between the SP10 backend (5 lanes) and the SP6
frontend (4 lanes). The Payer-Rejected lane (277CA STC A4/A6/A7)
is now rendered alongside Rejected/Candidates/Unmatched/Done,
with an Acknowledge bulk action that drops claims from the
working surface without erasing the original 277CA rejection
event (audit log stays intact, SP11).
Backend:
* Migration 0010: add payer_rejected_acknowledged_at +
payer_rejected_acknowledged_actor columns + partial index.
* db.py: surface the two new columns on the Claim model.
* inbox_lanes.py: filter acknowledged claims out of the
payer_rejected lane; expose the new fields on the row payload
for forward-compat (e.g. a future 'Recently acknowledged' view).
* api.py:
- POST /api/inbox/payer-rejected/acknowledge
Bulk-acknowledge. Idempotent. Returns transitioned /
already_acked / not_found / not_rejected counts so the UI
can show '3 of 5 were already acknowledged' on a noop bulk.
Writes a 'claim.payer_rejected_acknowledged' event to the
SP11 hash-chained audit log.
- GET /api/inbox/export.csv: accept 'payer_rejected' lane.
* test_acks.py: bump user_version assertion to 10.
* test_lane_filter_acknowledged.py: 4 tests for the lane filter
and forward-compat row payload.
* test_payer_rejected_acknowledge.py: 6 tests for the endpoint
(happy path, idempotency, no-op on non-rejected, missing
ids, 400 on empty, audit-log wiring + chain integrity).
Frontend:
* lib/inbox-api.ts: add payer_rejected to InboxLanes, add
acknowledgePayerRejected(), update exportInboxCsvUrl union.
* hooks/useInboxLanes.ts: add payer_rejected to initial state.
* hooks/useInboxLanes.test.ts: add payer_rejected to mocks.
* components/inbox/BulkBar.tsx: add 'payer_rejected' lane with
Acknowledge action (no Resubmit, no Dismiss — payer-rejected
is not eligible for either).
* components/inbox/BulkBar.test.tsx: add payer_rejected test.
* pages/Inbox.tsx: render the 5th lane, hook up onAcknowledge,
include payer_rejected in the needEyes count.
* pages/Inbox.test.tsx: 3 new tests (5-lane render, need-eyes
count, acknowledge action hits the right endpoint).
* components/inbox/InboxHeader.tsx: doc comment now explains
why payer_rejected rolls up into need-eyes.
Pre-existing typecheck warnings in BulkBar.test.tsx / InboxRow
.test.tsx / Lane.tsx / download.test.ts are unchanged from
main — not touched here.
Test counts: backend 724 -> 734 (+10). Frontend 350 -> 354 (+4).
- inbox-api.test.ts: pin ?download=true POST contract; blob + filename +
X-Cyclone-Serialize-Errors parsing; non-2xx error surfacing.
- Inbox.test.tsx: end-to-end multi-select → Resubmit + Download path
verifies api call args and downloadBlob wiring.
- inbox-api.ts: drop redundant isConfigured short-circuit in download
variant (backend has its own auth gate) and switch error reading to
res.text() to match Blob response shape.
Wire the new ``?download=true`` resubmit endpoint into the Inbox
page. Operators can now ask the backend for a ZIP of regenerated
837s straight from the rejected-claims bulk action, with the
``X-Cyclone-Serialize-Errors`` header surfaced as a non-blocking
warning so partial successes don't swallow per-claim failures.
* ``src/lib/inbox-api.ts``: new ``resubmitRejectedWithDownload``
helper returning ``{blob, filename, serializeErrors}`` so callers
can hand the bundle to the new ``downloadBlob`` utility without
re-parsing headers.
* ``src/lib/download.ts``: new ``downloadBlob(blob, filename)`` plus
a test covering the extension/content-type mapping and the
"use the suggested filename when present" rule.
* ``src/pages/Inbox.tsx``: rejected-claims bulk action now exposes
a "Resubmit & download" button next to the existing JSON path,
wired through the helper. Conflicts and per-claim serialize
errors render in the existing toast/result surface.
Tests: 4 new download.ts tests, 5 inbox-api tests (including
serialize-errors header parsing).
Three pieces:
- src/lib/download.ts: generic downloadTextFile(filename, mime, text)
helper. Mirrors csv.ts:downloadCsv but takes an explicit MIME type and
drops the BOM prepend (which would corrupt the ISA segment).
- src/lib/api.ts: serializeClaim837(id) → {text, filename}. Fetches
GET /api/claims/{id}/serialize-837, pulls the suggested filename from
Content-Disposition (falls back to claim-{id}.x12 if the header is
missing). Throws ApiError on non-2xx so callers can branch on .status.
- ClaimDrawerHeader: Download icon button between the amount and the
close button. Click → api.serializeClaim837 → downloadTextFile.
Disabled + 'Downloading 837 file' aria-label while the fetch is in
flight so the click feels responsive. Optional onError prop surfaces
fetch failures; defaults to a no-op so existing callers stay clean.
Tests: 3 download.test.ts, 3 api.test.ts, 2 header.test.ts (happy
path + error path). Frontend: 350 passing (+8 from 342).
Existing test fixtures (ClaimDrawer, ClaimDrawerHeader, Claims pages)
construct partial ClaimDetail objects without the new slim
lineReconciliation projection. Other SP7 fields follow the optional
pattern (matchedLines?, serviceLinePayments?, claimLevelAdjustments?),
so apply the same to lineReconciliation.
Backend always emits it for matched claims; UI treats absent as
"no per-line audit yet".
The live-tail stream (now reachable through the new Vite /api proxy)
started delivering activity rows with kind="manual_match", a kind the
ActivityFeed kindConfig map didn't know about. The map lookup returned
undefined, and reading .icon on it threw — taking the whole Activity
Log page down.
Two fixes:
1. Add "manual_match" to the ActivityKind union in src/types/index.ts
and to the kindConfig map in src/components/ActivityFeed.tsx (with
a Wrench icon + neutral tone), so the kind is properly typed and
rendered.
2. Add a FALLBACK_KIND guard so any future backend-emitted kind that
arrives before the frontend is updated no longer crashes the page.
kindConfig stays exhaustive for known kinds, so TypeScript still
catches missing entries at compile time.
Also adds src/components/ActivityFeed.test.tsx with three regression
tests (empty state, multi-item render, unknown-kind safety).