Layer A of the SP35 defense-in-depth fix. Before SP35 the dropdown
silently defaulted to '837p' and never changed when a file was dropped
on the page — uploading an 835 file routed it to /api/parse-837 which
(prior to SP35 Task 2) silently persisted an empty batch.
The change:
1. New pure helper src/lib/x12-detect.ts:
- detectKindFromText(text) reads the first ~4KB and returns the
DetectedKind ('837p' | '835' | '999' | '277ca' | 'ta1' | 'unknown')
by matching the ST01 segment (or the bare TA1 segment for the
no-ST envelope). Cheap substring scan; never invokes tokenize().
- detectKindFromFile(file) is the File-aware wrapper used by the UI.
- detectedKindToParsedBatchKind maps the DetectedKind to the kind
the Upload dropdown supports. Returns null for 999/277CA/TA1 so
the UI can surface a clean 'this file isn't supported here' hint.
2. Upload.tsx: pickFile is now async and reads the file before storing
it. If the detected kind differs from the dropdown's current value,
it switches the dropdown and toasts a hint. If the detected kind is
999/277CA/TA1 (Upload doesn't ingest those), it shows an error toast.
20 new tests in src/lib/x12-detect.test.ts cover the 6 DetectedKind
paths, the File wrapper, case-insensitivity, garbage input, the
ST*8370 false-positive guard, and the detectedKindToParsedBatchKind
mapping.
- backend: GET /api/batches now returns acceptedCount/rejectedCount/
pendingCount/billedTotal/topRejectionReason/hasProblem per item
(one GROUP BY query + one ordered scan, no N+1)
- backend: 2 tests pin the 837p full-bucket case + the 835 zero case
- frontend: BatchSummary extended with 6 optional fields
(backwards compat preserved)
- frontend: new RecentBatchesWidget renders one row per batch with
status icon + billed total + accepted count + top rejection reason
- frontend: 837p rows show $ total + 'N/M accepted'; 835 rows show
payment count (Remittance has no batch-level total_charge)
- frontend: full-width row between KPI tiles and Activity on the
Dashboard; click navigates to /batches?batch=ID
- frontend: 3 component tests cover empty/clean/problem/835 branches
- backend: _ack_summary_for_claims helper attaches claim_acks payload
to inbox rejected-lane rows (3 tests)
- frontend: InboxRow renders newest 3 AK2 chips + '+N more' overflow
under the rejection reason, with '999 not linked' marker when an
ack couldn't be linked to a claim
- frontend: per-row Resubmit button downloads a single corrected 837
via api.serializeClaim837 (no bulk modal, no zip — one click, one
.x12 file)
- frontend: 2 new tests for the chip rendering and the per-row
download flow
The 1,156 accepted acks were burying the 5 rejections — the operator
had to scroll the whole table to find them. Two changes:
1. Sidebar '999 ACKs' nav item gets a warning-toned badge showing
the total rejected segment count from aggregates.rejected_count
(mirrors the Reconciliation unmatched badge pattern). One number,
visible from any page.
2. Acks page table now sorts rejected rows to the top, then
newest-id-first. All 5 rejections fit on page 1 since they're a
tiny fraction of the total — no pagination needed.
New files:
- src/hooks/useAckStats.ts (lightweight aggregates-only fetch)
- src/hooks/useAckStats.test.tsx (2 tests)
- src/components/Sidebar.test.tsx (3 tests)
Pre-existing baseline: 10 frontend failures (api.test.ts exportBatch837,
tail-stream.test.ts acks/ta1_acks targeting, Inbox/InboxHeader copy).
Unchanged.
Three small follow-ups after the live-tail triplet wired up:
* evictOldest is now generic over (K extends string|number, V) so
the addAck / addTa1Ack call sites don't need a type assertion.
* Acks.test.tsx no longer spreads importOriginal() — TypeScript
was choking on the inferred 'unknown' from the mock factory.
Use the same ApiError / mock shape that Claims.test.tsx uses.
* tail-stream.test.ts guards the optional gen.return() with
so strict mode TS2722 stops complaining about possibly-undefined.
The 999 register opens /api/acks/stream via useTailStream("acks"),
merges the snapshot + tail through useMergedTail("acks", ...) so
new rows appear without a manual refresh, and surfaces the
connection state via <TailStatusPill> in the hero. The TA1 section
gets the same triplet against /api/ta1-acks/stream.
Both pills sit in the page header so the operator can see at a
glance whether the live-tail connection is healthy, without having
to open the drawer or refresh the page. A stalled/error stream
shows a Reconnect button inline.
The Acks test mock adds useTailStream at the module level so the
page renders without opening a real fetch. New test asserts both
TailStatusPills mount in the page tree.
The Remittances page's three KPI tiles — REMITS / TOTAL PAID /
ADJUSTMENTS — were computed page-locally via items.reduce(...) over
the merged tail of the current page + live delta. With a 100-row
default limit, a 1,739-row population showed count=100, paid=$16,934,
adjustments=$147 — silently understating reality because the page
hadn't loaded the remaining rows yet.
This change mirrors the silent-incompleteness fix that
/api/dashboard/kpis (commit 59c3275) and /api/remittances (commit
d81b6ed) made for their tiles:
* CycloneStore.summarize_remittances() iterates the full filtered
remittance population (no limit) and returns
{count, total_paid, total_adjustments}. Mirrors iter_remittances
with limit=_ITER_UNBOUNDED.
* GET /api/remittances/summary — server endpoint with the same
filter parameters as /api/remittances. Registered BEFORE the
/api/remittances/stream handler so FastAPI doesn't treat
'summary' as a stream sub-path.
* api.listRemittanceSummary + useRemittanceSummary hook.
* Remittances.tsx swaps off items.reduce, consumes the server
summary. Tiles render the server totals so the values reflect
the entire DB population, not the page-local sample.
Verified live: /api/remittances/summary returns
{count: 1739, total_paid: 227181.58, total_adjustments: 13792.65},
which matches DB ground truth exactly.
Tests: 8 new backend tests in test_api_remittances_summary.py; 1 new
frontend test in Remittances.test.tsx (kpi_tiles_use_server_summary_not
_page_local_reduce) plus the page-level test for the zero-valued mock
default.
The Dashboard was hardcoded to useClaims({ limit: 100 }) and reduce
KPIs client-side. With 60k+ claims in production, every tile
(Billed $940K, Received $59K, Denial rate, Pending AR, monthly
sparkline, top providers, recent denials) was computed from a
0.16% sample of the dataset — silently wrong numbers on the
operator's primary view.
Fix: add GET /api/dashboard/kpis that aggregates server-side in
one read over the entire claim population. The new useDashboardKpis
hook consumes it and polls every 60s. Dashboard.tsx drops
useClaims({limit:100}) + useProviders() + the client-side
buildMonthly reduce.
Backend:
- store.py: dashboard_kpis() — one Claim query (selectinload on
batch to avoid N+1) + one bulk Remittance lookup, Python reduce
over the full population. Zero-filled response for empty DB.
- api.py: GET /api/dashboard/kpis behind matrix_gate, query-param
clamps (1..24 months, 0..50 top_n_*).
Frontend:
- api.ts: DashboardKpis types + getDashboardKpis() wrapper.
- useDashboardKpis.ts: TanStack Query hook, 60s refetchInterval,
bypass to data:undefined when not configured.
- Dashboard.tsx: switched to useDashboardKpis, extracted
ZERO_TOTALS constant, dropped the buildMonthly helper.
Tests:
- backend/tests/test_dashboard_kpis.py: 12 tests covering empty DB,
matched-remit math, pending-state semantics, monthly binning,
top-providers/top-denials sort + cap, orphan-claim defensive
guard, HTTP wiring + param validation.
- src/hooks/useDashboardKpis.test.ts: 3 tests for the hook
contract (configured path, unconfigured fallback, param
passthrough).
- src/pages/Dashboard.test.tsx: wrapped renders in
QueryClientProvider + stubbed useAuth + isConfigured=false. This
fixes 3 pre-existing Dashboard test failures (the page never had
a QueryClient set up because useClaims/useProviders were the
first useQuery hooks in the page).
Reviewer fixes (same commit):
1. topDenials sort placed empty submissionDate claims first under
reverse-lex. Drop them at append time.
2. r.batch lazy-load → N+1 on 60k rows. selectinload(Claim.batch).
3. pending_states rebuilt per call as a mutable set — moved to
module-level _DASHBOARD_PENDING_STATES frozenset.
4. Module-level ProviderORM import inconsistent with the
"local import inside the function" pattern — moved inline.
The Acks page silently capped at 100 of 1056 rows in the operator's
DB: the eyebrow read `${items.length} on file` (not the server's
`total`), the KPI strip summed from the 100 visible items, and the
endpoint accepted no `offset` — so the user had no signal that 956
more acks existed. Same class of bug on the Activity page (cap=200,
no 'X of Y' hint).
Backend
- /api/acks (acks.py): add `offset`, bump `le=1000`→`le=5000`,
slice `rows[offset:offset+limit]`, return server-side
`aggregates` (accepted/rejected/received summed over the full
row set, not the page) so the KPI strip reflects every persisted
999 instead of just the visible 50.
- /api/activity list + stream (api.py): bump `le=500`→`le=5000` so
the page can ask for a denser snapshot.
- /api/277ca-acks (api.py): bump `le=1000`→`le=5000` for
consistency.
- /api/ta1-acks: left at `le=1000` — TA1s aren't shipped today and
the structural fix (offset + aggregates) wasn't applied, so a
larger cap would just make the same latent silent-failure easier
to hit. (TODO: fold in the same shape when Gainwell starts
shipping TA1s.)
Frontend
- listAcks (api.ts): accept `offset`, surface `aggregates`,
adapt wire `*_count` keys to the in-page
`accepted`/`rejected`/`received` shape so the page can use
`data.aggregates` as a drop-in for the page-local fallback
accumulator.
- useAcks (hooks): pass `offset` through; return type carries
`aggregates`.
- Acks.tsx: add `page` state (PAGE_SIZE=50), use `data.total`
for eyebrow + watermark (not `items.length`), use
`data.aggregates` for the KPI strip (with in-page fallback
accumulator on first paint), render `<Pagination>` when
`totalCount > PAGE_SIZE`. Footer row reads "N rows on file"
instead of "N rows".
- ActivityLog.tsx: bump `limit: 200`→`limit: 500`, eyebrow reads
"Activity · showing N most recent" to make the bounded-window
semantics honest (the endpoint doesn't expose a true total — it
reports events matching the current kind/since filter, capped at
the request limit).
Tests
- test_acks.py: 4 new tests pin the fix:
1. `offset` walks the full set; `has_more` flips at the
boundary.
2. `aggregates` reflects the full row set, not the page (the
silent-failure pin) — and stays stable across page slices.
3. `limit` cap of 5000 is enforced (422 above it).
4. `offset` past the end returns an empty page with stable
aggregates (a stale UI page state across a row count change
must not 500 or zero the KPIs).
Live smoke-verified: /api/acks?limit=2&offset=0 vs ?offset=2 return
the expected row slices, aggregates stable at 15/10/15 for 5 seeded
rows, /api/acks?limit=10000 rejected with 422.
Triage note: the TA1 section (`Ta1AcksSection`, lines 609-616 of
Acks.tsx) has the same latent silent-failure pattern (page-sums
KPIs, no offset on /api/ta1-acks). Left untouched because the
empty-state copy says Gainwell doesn't ship TA1s today and the
larger structural fix belongs in a follow-up.
Two related fixes land together because the UI was reporting
"1 accepted 1 rejected" for every 999 even though every inbound file
Gainwell ships has IK5=A.
1. Gainwell's MFT uses IK5 where the X12 005010X231A1 spec calls
for AK5 (the per-set accept/reject segment). The parser only
recognized AK5, so set_responses[0].set_accept_reject.code
defaulted to 'R' and the count summary showed all rejections.
_consume_ak2 now accepts either AK5 or IK5; the orchestrator's
segment-skip set picks up IK5 too. A new fixture
(minimal_999_ik5_gainwell.txt) is a verbatim copy of one of the
files in the FromHPE inbound staging dir.
2. _ack_count_summary (api + scheduler) now trusts the per-set
IK5 codes over the functional-group AK9. Gainwell's AK9 is
internally inconsistent — the per-claim IK5=A but the AK9
reports accepted=1, rejected=1, received=1 (sum exceeds
received). Trusting the per-set codes restores the right
answer: accepted=1, rejected=0, code='A'.
3. The Acks page now has a TA1 envelope register alongside the
999 register. TA1s are the lower-level sibling of the 999
(one row per inbound ISA/IEA). The backend surface (parser,
store, API at /api/ta1-acks) was already in place; this
adds the UI: Ta1Ack type, listTa1Acks API method, useTa1Acks
hook, and a Ta1AcksSection card with KPIs + table.
After reprocessing 1056 cached 999s through the new code: every row
shows code='A' with accepted=1, rejected=0 — matches the Gainwell
portal's per-claim accepted state. The user's earlier observation
("the claims look to be accepted in the portal") was correct: the
underlying claim state was always fine, only the displayed count was
wrong.
- backend/src/cyclone/parsers/parse_999.py | 21 ++-
- backend/src/cyclone/api.py | 11 +-
- backend/src/cyclone/scheduler.py | 13 +-
- backend/tests/test_parse_999.py | 32 ++++
- backend/tests/fixtures/minimal_999_ik5_gainwell.txt
- src/types/index.ts | 32 ++++
- src/lib/api.ts | 62 +++++-
- src/hooks/useTa1Acks.ts | 26 +++ (new)
- src/pages/Acks.tsx | 209 +++++++++++++++++++-
Gainwell's MFT ships every 999 with the same ISA interchange
control number (`000000001`) and one 999 ack covers a whole
batch, not a single claim — so the AK2 set_control_number
(patient_control_number) is the same for the ~96 999s in a
batch. With the old synthetic-id formula (`999-{icn}`), all 385
daily acks collapsed onto a single row the operator couldn't
distinguish.
The new formula is `999-{pcn}-{filename_hash8}` (or
`999-{icn}-{filename_hash8}` for envelope-only 999s without an
AK2). The PCN gives the operator a human-readable handle to the
claim batch; the 8-char hash of the inbound filename guarantees
uniqueness within a batch. Fits in the VARCHAR(32) source_batch_id
column (max 22 chars).
Also surface `patient_control_number` in /api/acks list response
(extracted from raw_json's set_responses[0].set_control_number)
and in the Acks UI as the primary label, with the synthetic id
shown dimmed after a middle dot. The detail endpoint already
exposed raw_json for the full 999 parse tree.
Add a second tab to the Upload page that surfaces the persisted batch
archive and lets the user re-download any 837P batch as a ZIP without
re-parsing the original file.
- Backend: /api/batches now carries per-row claimIds (837P only).
835 batches return an empty list, which the UI uses as the signal
to hide the Re-export button on those rows. Avoids an extra
round-trip to /api/batches/{id} per row.
- Frontend: BatchSummary.claimIds added to the list-endpoint type.
- Upload page: page body wrapped in Tabs.Root with a History trigger
that mirrors ?tab= in the URL for deep-link round-trip. The
History tab renders UploadHistory → HistoryTable → HistoryRow with
a one-click Re-export ZIP button per 837P row. The button calls
POST /api/batches/{id}/export-837 with the row's claim ids and
downloads the ZIP via downloadBlob. Falls back to the in-memory
parsedBatches store when the backend returns no rows so the tab
stays useful in sample-data mode.
- Backend tests: claimIds present on 837P rows, empty on 835 rows.
- Frontend tests: 13 tests covering tab switching, URL deep-link,
loading/error/empty states, the 837P-vs-835 button visibility
split, the Re-export happy path, and the failure toast.
Dashboard.tsx was reading claims/providers/activity directly from the
zustand `useAppStore`, which returns the hardcoded sample fixtures
(sampleClaims / sampleProviders / sampleActivity) regardless of whether
a backend session is active. The hooks useClaims / useProviders /
useActivity are wired correctly to /api/* when api.isConfigured, so
the fix is to swap the three direct store reads for the hooks.
buildMonthly's parameter is now typed as Claim[] (the live Claim
shape) instead of the store's heterogeneous slice, and the
`useAppStore` import is gone.
Verified end-to-end: after logging in as admin via the live server,
the Dashboard now shows $0 KPIs, "0 events / No activity yet.",
and the live operator name in the greeting — proving the hooks are
hitting /api/* and not falling back to the sample store.
Two issues surfaced by going live against the dev backend:
1. Dashboard greeting was hardcoded 'Good morning, Jordan.' in
src/pages/Dashboard.tsx. The sidebar already pulls from useAuth()
but the dashboard had been missed. Replace with a time-of-day
greeting ('morning' / 'afternoon' / 'evening') plus the live
operator's username from useAuth(). Falls back to 'there' while
/api/auth/me is in flight so we never flash 'undefined' at the
operator.
2. api.isConfigured was returning false whenever VITE_API_BASE_URL
was empty, which is the default in .env.example and the
recommended setting for the Vite dev proxy / nginx. With the flag
false, every hook (useClaims, useBatches, useActivity, ...)
threw notConfiguredError() and silently fell back to the
in-memory zustand store's hardcoded sample data — so 'files not
serving' was actually the Dashboard rendering fixtures, not the
real DB. With the flag now true, joinUrl('') produces relative
URLs that the proxy intercepts and forwards to FastAPI on :8000.
Set VITE_API_BASE_URL=disabled to opt out (useful for Storybook
/ offline UI work).
Also installs @radix-ui/react-tabs which was listed in package.json
but missing from node_modules — Vite was failing pre-transform on
tabs.tsx and refusing to compile downstream pages until deps were
re-installed via 'npm install'.
Re-apply f91d7b3 manually against current Upload.tsx / Inbox.tsx /
Reconciliation.tsx since main's UI text diverged from the original
cherry-pick (Release to 'parse' span, mono uppercase tracking, etc.).
The write affordances are now wrapped in <RoleGate allow={admin,user}>:
- Upload.tsx: dropzone + Parse/Clear buttons
- Inbox.tsx: rejected / payer_rejected / candidates BulkBars
- Reconciliation.tsx: 'Match selected' button
Test fixtures stub useAuth to return an admin user so RoleGate lets
the BulkBars / Match button render synchronously on first paint —
the tests don't need a full AuthProvider + /api/auth/me probe.
Backend:
- New POST /api/batches/{id}/export-837: regenerate X12 837 files
for a list of claim_ids into a ZIP using HCPF file naming standards,
with a unique interchange/group control number per export. Wire
the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
(NM1*40) blocks so the serializer no longer falls back to
CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
batch_id in both JSON and NDJSON response shapes so the frontend
can hit batch-scoped endpoints without an extra listBatches
round-trip.
- Filename helpers and the 837 serializer updated to match the new
HCPF envelope; tests cover batch export, parse batch_id, and the
serializer's control-number uniqueness guarantee.
Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
EditorialNote, ExportBar, TickerTape, and a charts/ set
(BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
colors to Tailwind theme tokens (bg-card, text-foreground,
border/60, etc.) for consistency with the rest of the instrument
chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
reworked to compose the new shared components and consume the new
batch-scoped API surface (notably ExportBar wired into Batches).
Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
components and the useBatchExport hook.
Rolls up into the v0.2.0 release tag.
Phase 5 Task 5.8 (PartiesGrid) and 5.9 (ValidationPanel) added useDrillStack()
calls, so Claims.test.tsx needs a DrillStackProvider wrapper around the
page render to keep the hook happy. Task 5.10 refactored the ClaimDrawer
header onto the shared DrillDrawerHeader, so the deep-link and close-button
tests need to find the title via <h2> and the close button via
aria-label='Close drawer' instead of the now-removed header-id /
header-close testids.
Without this fix the page-level Claims tests report 3 failures
(deep link, URL clear after close, ?-mark with drawer closed) that
were not failing on the Phase 4 base (9a313d2).
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.
- 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).