26 Commits

Author SHA1 Message Date
Tyler 7db5e448cd feat(dashboard): Recent denials row drillable to /claims?claim=ID 2026-06-21 14:28:49 -06:00
Tyler 5d6b5894f3 feat(dashboard): Top providers row drillable to /providers?provider=NPI 2026-06-21 14:28:49 -06:00
Tyler 88da3a6246 feat(dashboard): KPI tiles drillable — navigate to /claims with filter 2026-06-21 14:28:49 -06:00
Tyler 50dc0b2fb3 feat(drill): PayerPeekContent + usePayerSummary + api.getPayerSummary 2026-06-21 14:28:49 -06:00
Tyler e6ae364dad feat(api): extend /api/config/providers/{npi} with recent_claims + recent_activity 2026-06-21 14:28:49 -06:00
Tyler 50a454db59 feat(api): GET /api/payers/{payer_id}/summary with 60s cache + pubsub invalidation 2026-06-21 14:28:49 -06:00
Tyler 9129543f5d feat(drill): hover affordance CSS + App wrapped in DrillStackProvider 2026-06-21 14:28:49 -06:00
Tyler fb7a5c6d2e feat(drill): PeekModal — centered Radix Dialog with eyebrow + title 2026-06-21 14:28:49 -06:00
Tyler 7dd6a5d025 feat(drill): DrillableCell — hover-reveal button wrapper 2026-06-21 14:28:49 -06:00
Tyler be93dbff72 feat(drill): DrillStackProvider — zustand-backed peek stack with 2-level cap 2026-06-21 14:28:49 -06:00
Cyclone UI feb15bf48d Refine Acks page with hybrid dark/paper treatment
- Replace PageHeader with editorial hero: massive 'Acknowledgments, *received.*'
  (clamp 48-80px serif, italic) + ghost '999' watermark + ShieldCheck pill
  + ?-shortcut kbd hint
- Add torn-page fold with '↘ 999 REGISTER ↙' label and 48-dot perforation
- Cream paper plane (max-w-[1280px] mx-auto) with paper-grain SVG and
  double-bordered title block: 'REGISTER · 999 IMPLEMENTATION ACKS' over
  'The register.' (clamp 48-96px) plus on-file count column
- Folio system: § 01 Vital signs (4 paper KPI tiles via new AckKpiTile),
  § 02 The register (paper-toned Table with blue selected-row tint)
- Paper-toned AckCodeBadge (success/destructive/warning tints), paper-toned
  DownloadButton
- Error/Loading/Empty states wrapped in paper-toned containers
- Footer: 'End of register / Cyclone · 999 ACKs / N rows'

Round 9/11.
2026-06-21 14:27:16 -06:00
Tyler ff0985d244 Refine Inbox with editorial hero and paper-toned queue ledger
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
2026-06-21 14:15:25 -06:00
Tyler 94990932f9 Refine Reconciliation page with hybrid dark/paper treatment
- 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'
2026-06-21 14:05:05 -06:00
Tyler 9133baa070 plan(SP22): Task 7 — replace assert with RuntimeError in list_*_acks methods 2026-06-21 13:56:14 -06:00
Tyler d0a18bd796 Refine Upload page with hybrid dark/paper treatment
- 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'
2026-06-21 13:55:22 -06:00
Tyler 646d00adde plan(SP22): Task 6 — apply Task 4/5 fixes (4xx terminal, RuntimeError, retry logging) preemptively 2026-06-21 13:51:42 -06:00
Tyler f6f821e082 plan(SP22): Task 5 — add test_parse_837_does_not_retry_on_5xx to spec (locks in dedup-safety contract) 2026-06-21 13:50:51 -06:00
Tyler 3e00fb3f63 plan(SP22): Task 5 — apply Task 4 fixes (4xx terminal, RuntimeError) to list_claims and parse_837 2026-06-21 13:43:37 -06:00
Tyler 2faf7bfd48 plan(SP22): Task 4 — distinguish 4xx (terminal) from 5xx (retry) + drop dead BACKOFF_S[2]
Update _get_typed in the plan to:
- raise immediately on 4xx (terminal)
- retry on 5xx and RequestError (transient)
- use BACKOFF_S = (1.0, 2.0) instead of (1.0, 2.0, 4.0)
- use RuntimeError instead of assert for the context-manager guard

Also add test_4xx_is_terminal_not_retried to the plan's test list.
2026-06-21 13:43:01 -06:00
Tyler 73be586110 plan(SP22): Task 4 review fix — client() fixture must enter async context
The verbatim plan returned an un-entered CycloneClient from the fixture,
causing all 3 tests to fail with 'use async with CycloneClient(...)'.
Convert fixture to async generator that enters the context. Verified by
running pytest tests/test_api_client.py -v → 3 passed.
2026-06-21 13:37:55 -06:00
Tyler d7f37f845a plan(SP22): Task 3 — add add_logger_name to shared_processors so JSON includes the logger name (matches docstring contract) 2026-06-21 13:31:49 -06:00
Tyler df10b55a34 plan(SP22): Task 3 review fix — get_logger returns BoundLoggerLazyProxy, not BoundLogger
The verbatim plan's test asserted isinstance(log, structlog.stdlib.BoundLogger),
but cache_logger_on_first_use=True makes get_logger return a lazy proxy on
first call. Test now uses duck-typing (hasattr('bind') + callable). Also
dropped the misleading caplog assertion and unused logging/structlog imports.
2026-06-21 13:26:59 -06:00
Tyler 6300280142 plan(SP22): Task 1 review fixes — conftest fixture bug + Python 3.13 note
- conftest.py example now uses SAMPLE_837P constant so sample_837p_bytes
  reads the file (not the directory).
- Note that Python 3.13 satisfies requires-python >= 3.11 if 3.11 isn't
  installed.
2026-06-21 13:13:01 -06:00
Tyler fff000ed2e plan(SP22): pre-flight fix — add _extract_items helper for paginated API responses 2026-06-21 12:58:32 -06:00
Tyler 134eb4f404 plan(SP22): self-review fixes — phase4_at, browser check via shared client, dead code 2026-06-21 12:56:25 -06:00
Tyler 022b229d81 plan(SP22): cyclone-pipeline agent — 26 tasks, TDD-shaped, ~26 commits 2026-06-21 12:55:01 -06:00
25 changed files with 9070 additions and 699 deletions
+189 -1
View File
@@ -22,6 +22,7 @@ import logging
import uuid
from datetime import datetime, timezone
from contextlib import asynccontextmanager
from time import monotonic
from typing import Any, AsyncIterator
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
@@ -31,6 +32,7 @@ from pydantic import ValidationError
from cyclone import __version__, db
from cyclone.db import Claim, ClaimState, Remittance
from sqlalchemy import desc, or_
from cyclone.inbox_state import apply_999_rejections
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.audit_log import AuditEvent, append_event, verify_chain
@@ -2933,7 +2935,73 @@ def get_configured_provider(npi: str):
p = store.get_provider(npi)
if p is None:
raise HTTPException(status_code=404, detail=f"provider {npi!r} not found")
return json.loads(p.model_dump_json())
provider_dict = json.loads(p.model_dump_json())
# SP21 Task 1.6: extend the response with two top-N arrays that the
# drill-down peek panel hangs off. ``recent_claims`` reuses the
# existing store projection (already returns UI-shaped dicts with
# ``submissionDate``); ``recent_activity`` is a direct ORM join
# because ``ActivityEvent`` has no ``provider_npi`` column — the
# filter has to hop through ``Claim.id``.
recent_claims = sorted(
store.iter_claims(provider_npi=npi),
key=lambda c: c.get("submissionDate") or "",
reverse=True,
)[:10]
# Activity filter has TWO join paths back to a Claim for this
# provider:
# 1. ``ActivityEvent.claim_id IN (claim_ids)`` — events that were
# recorded with a claim FK already set (claim_submitted,
# manual_match, claim_paid, etc.).
# 2. ``Remittance.claim_id IN (claim_ids)`` — the original
# ``remit_received`` event recorded at 835 ingest time
# (``store.add`` lines 999-1003) is inserted with
# ``claim_id=None`` because the remittance hasn't been matched
# to a claim yet. The auto-reconcile pass later sets
# ``Remittance.claim_id`` (``reconcile.run`` lines 289-293),
# but the *original* ActivityEvent row stays orphaned. The
# outer-join-then-OR lets us surface both shapes. Without
# path 2, a provider's activity feed looks frozen the moment
# an 835 lands — the most common activity, invisible.
with db.SessionLocal()() as s:
claim_ids = [
cid
for (cid,) in s.query(Claim.id)
.filter(Claim.provider_npi == npi)
.all()
]
activity_rows = []
if claim_ids:
activity_rows = (
s.query(db.ActivityEvent)
.outerjoin(
Remittance,
db.ActivityEvent.remittance_id == Remittance.id,
)
.filter(or_(
db.ActivityEvent.claim_id.in_(claim_ids),
Remittance.claim_id.in_(claim_ids),
))
.order_by(desc(db.ActivityEvent.ts))
.limit(10)
.all()
)
def _activity_to_ui(a):
return {
"id": a.id,
"ts": a.ts.isoformat().replace("+00:00", "Z") if a.ts else "",
"kind": a.kind,
"batchId": a.batch_id,
"claimId": a.claim_id,
"remittanceId": a.remittance_id,
"payload": a.payload_json or {},
}
provider_dict["recent_claims"] = recent_claims
provider_dict["recent_activity"] = [_activity_to_ui(a) for a in activity_rows]
return provider_dict
@app.get("/api/config/payers")
@@ -2958,6 +3026,126 @@ def list_payer_configs(payer_id: str):
return configs
# --------------------------------------------------------------------------- #
# SP21 Task 1.5: payer-level summary for the drill-down panel
# --------------------------------------------------------------------------- #
# Per-payer_id memoization for /api/payers/{payer_id}/summary. The drill-
# down UI hammers this on every hover; the underlying query is O(claims)
# per payer so a 60s TTL keeps the panel responsive. Process-local on
# purpose — invalidation is driven by the TTL alone for now (see note
# below).
_SUMMARY_TTL_S = 60.0
_summary_cache: dict[str, tuple[float, dict]] = {}
def _clear_summary_cache() -> None:
"""Test hook: wipe the process-local cache.
The 60s TTL means tests that want to assert on a recomputed payload
must clear the cache between requests. Not used by production code.
"""
_summary_cache.clear()
# Pubsub invalidation is intentionally NOT wired. The ``claim_written``
# and ``remittance_written`` payloads emitted by ``store.add`` are the
# UI-shaped dicts from ``to_ui_claim_from_orm`` / ``to_ui_remittance_from_orm``,
# neither of which carries the X12 ``payer_id`` (NM1*PR*PI qualifier).
# They carry ``payerName`` only, which is the human-readable name and not
# the URL key we cache on. Wiring a subscriber here would either need a
# DB lookup per event (re-deriving payer_id from Claim.id) or a different
# cache key — both are out of scope for this task. The 60s TTL keeps
# staleness bounded; a follow-up can wire targeted invalidation if the
# UI proves TTL-bounded staleness is unacceptable.
@app.get("/api/payers/{payer_id}/summary")
def get_payer_summary(payer_id: str):
"""Payer-level rollup for the drill-down panel.
Returns billed/received totals, denial rate, and top providers for
the given ``payer_id`` (the X12 NM1*PR*PI qualifier, e.g. ``SKCO0``).
Cached in-process for ``_SUMMARY_TTL_S`` seconds.
404 when no claims AND no remits reference this payer_id — the UI
uses that to distinguish a typo from a payer with zero traffic
(the latter would still return a valid 200 with zeroed totals).
"""
now = monotonic()
cached = _summary_cache.get(payer_id)
if cached and (now - cached[0]) < _SUMMARY_TTL_S:
return cached[1]
log.debug("payer summary cache miss", extra={"payer_id": payer_id})
# Query claims + remits scoped to this payer_id. We bypass
# ``store.iter_claims`` because that helper filters by payer NAME
# substring, not by the X12 PI qualifier we use as the URL key.
with db.SessionLocal()() as s:
claim_rows: list[Claim] = (
s.query(Claim).filter(Claim.payer_id == payer_id).all()
)
claim_ids = [c.id for c in claim_rows]
remit_rows: list[Remittance] = []
if claim_ids:
remit_rows = (
s.query(Remittance)
.filter(Remittance.claim_id.in_(claim_ids))
.all()
)
if not claim_rows and not remit_rows:
raise HTTPException(
status_code=404,
detail=f"Payer {payer_id!r} not found",
)
billed_total = sum(float(c.charge_amount or 0) for c in claim_rows)
received_total = sum(float(r.total_paid or 0) for r in remit_rows)
denied = sum(
1 for c in claim_rows if c.state == ClaimState.DENIED
)
claim_count = len(claim_rows)
denial_rate = (denied / claim_count) if claim_count else 0.0
provider_counts: dict[str, int] = {}
for c in claim_rows:
npi = c.provider_npi
if not npi:
continue
provider_counts[npi] = provider_counts.get(npi, 0) + 1
top_providers = [
{"npi": npi, "count": count}
for npi, count in sorted(
provider_counts.items(), key=lambda kv: -kv[1]
)[:5]
]
# Best-effort payer display name from the first claim's raw
# payer object (NM1*PR name element, e.g. "COHCPF"). Falls
# back to the id when no parsed envelope is available.
payer_name = payer_id
for c in claim_rows:
raw = c.raw_json or {}
p = raw.get("payer") if isinstance(raw, dict) else None
if isinstance(p, dict) and p.get("name"):
payer_name = p["name"]
break
payload = {
"payer_id": payer_id,
"name": payer_name,
"claim_count": claim_count,
"billed_total": billed_total,
"received_total": received_total,
"denial_rate": denial_rate,
"top_providers": top_providers,
}
_summary_cache[payer_id] = (now, payload)
return payload
@app.post("/api/admin/reload-config")
def reload_config():
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
+106
View File
@@ -0,0 +1,106 @@
"""Tests for GET /api/payers/{payer_id}/summary (SP21 Task 1.5).
The endpoint is the payer-level aggregate that the drill-down UI's
"Payer → Claims" panel hangs off. It returns billed/received totals,
denial rate, and the top 5 NPIs by claim volume for one payer_id,
cached in-process for 60s.
The minimal 837P fixture ships one CLM with ``payer_id="SKCO0"``,
charge_amount=100.00; the minimal 835 carries one CLP for the same
claim with total_paid=85.00. So ``/api/payers/SKCO0/summary`` returns
``claim_count >= 1`` after both files are ingested.
Note: the spec calls this ``payer_id`` (the X12 NM1*PR*PI qualifier,
e.g. ``SKCO0``). It is NOT the configured payer name from
``config/payers.yaml``. The filter key in the store layer is
``Claim.payer_id`` — not the ``payer=`` substring filter used by
``/api/claims?payer=...``.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone import api as api_mod
from cyclone.api import app
FIXTURE_837 = Path(__file__).parent / "fixtures" / "minimal_837p.txt"
FIXTURE_835 = Path(__file__).parent / "fixtures" / "minimal_835.txt"
@pytest.fixture(autouse=True)
def _clear_summary_cache():
"""Wipe the in-process payer-summary cache between tests.
conftest resets the DB per test but the cache is module-level
state on ``cyclone.api``. Without this clear, a stale payload
from a previous test's seed would leak into a later test's first
call — masking recompute behavior. The 60s TTL is the only
invalidation story today (see api.py docstring on the endpoint).
"""
api_mod._clear_summary_cache()
yield
api_mod._clear_summary_cache()
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture
def seeded_db(client: TestClient):
"""Ingest one minimal 837P + one minimal 835.
Both fixtures carry ``payer_id="SKCO0"`` so the summary endpoint
has something to aggregate. ``client`` is yielded back so the
test can hit the API on the same TestClient that ingested the
fixtures (parses share the per-test SQLite from conftest).
"""
text_837 = FIXTURE_837.read_text()
text_835 = FIXTURE_835.read_text()
r837 = client.post(
"/api/parse-837",
files={"file": ("x.txt", text_837, "text/plain")},
headers={"Accept": "application/json"},
)
assert r837.status_code == 200, r837.text
r835 = client.post(
"/api/parse-835",
files={"file": ("era.txt", text_835, "text/plain")},
headers={"Accept": "application/json"},
)
assert r835.status_code == 200, r835.text
return client
def test_payer_summary_happy_path(seeded_db: TestClient):
"""Seeded db has at least one claim for SKCO0 → 200 with the spec shape."""
resp = seeded_db.get("/api/payers/SKCO0/summary")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["payer_id"] == "SKCO0"
assert "claim_count" in data
assert "billed_total" in data
assert "received_total" in data
assert "denial_rate" in data
assert data["claim_count"] >= 1
# denial_rate must be a float in [0, 1] (0/1 claim → 0.0).
assert isinstance(data["denial_rate"], (int, float))
assert 0.0 <= data["denial_rate"] <= 1.0
def test_payer_summary_unknown_payer_returns_404(client: TestClient):
resp = client.get("/api/payers/DOES_NOT_EXIST/summary")
assert resp.status_code == 404
def test_payer_summary_caches_then_invalidates(seeded_db: TestClient):
"""Two back-to-back calls return identical payloads (in-process cache)."""
resp1 = seeded_db.get("/api/payers/SKCO0/summary")
resp2 = seeded_db.get("/api/payers/SKCO0/summary")
assert resp1.status_code == 200
assert resp2.status_code == 200
assert resp1.json() == resp2.json()
@@ -0,0 +1,177 @@
"""Tests for the extended GET /api/config/providers/{npi} (SP21 Task 1.6).
The endpoint gains two new top-level arrays for the drill-down panel:
``recent_claims`` (top 10 by submission date desc) and ``recent_activity``
(top 10 by ``ts`` desc, joined to claims by ``claim_id`` because
``ActivityEvent`` has no direct ``provider_npi`` column).
Existing SP9 fields (``label``, ``legal_name``, ``tax_id``,
``address_line1``, ``city``, ``state``, ``zip``, etc.) must remain
present — the new arrays are additive only.
The fixtures in ``fixtures/minimal_837p.txt`` and ``fixtures/minimal_835.txt``
pair up to a single claim with ``provider_npi='1993999998'``. That NPI is
NOT in the seeded provider set (Montrose/Delta/Salida → 1881068062/1851446637/
1467507269). For the tests we hit the seeded Montrose NPI, which is the
canonical SP9 fixture NPI. The arrays come back as empty lists — the
contract under test is the *shape* (array, ≤10) and the *backwards compat*
of the existing fields; the data-path itself is exercised by the existing
ingestion path that backs ``/api/claims``.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.providers import Provider
from cyclone.store import store
FIXTURE_837 = Path(__file__).parent / "fixtures" / "minimal_837p.txt"
FIXTURE_835 = Path(__file__).parent / "fixtures" / "minimal_835.txt"
# Montrose — one of the three providers that `ensure_clearhouse_seeded()`
# writes into the providers table. Using a seeded NPI means the endpoint
# won't 404; the seeded claims (provider_npi='1993999998') won't appear
# under this NPI, so recent_claims/activity are expected empty lists.
MONTROSE_NPI = "1881068062"
# NPI the minimal 837P fixture bills under. NOT in the default seed —
# registering it before ingest is required to pass R204 (NPI must exist
# in the providers table).
TEST_837_NPI = "1993999998"
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture
def seeded_db(client: TestClient):
"""Seed the clearhouse + ingest the minimal 837P/835 fixtures.
Mirrors the Task 1.5 ``seeded_db`` pattern: seed → ingest → hand the
client back so the test hits the same TestClient.
The 837 fixture bills under ``TEST_837_NPI``; without first registering
that provider the parser's R204 rule rejects the claim with HTTP 422.
"""
store.ensure_clearhouse_seeded()
test_provider = Provider(
npi=TEST_837_NPI,
label="Test Provider",
legal_name="Test Provider Inc",
tax_id="123456789",
taxonomy_code="207R00000X",
address_line1="123 Test St",
city="Denver",
state="CO",
zip="80202",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
store.upsert_provider(test_provider)
text_837 = FIXTURE_837.read_text()
text_835 = FIXTURE_835.read_text()
r837 = client.post(
"/api/parse-837",
files={"file": ("x.txt", text_837, "text/plain")},
headers={"Accept": "application/json"},
)
assert r837.status_code == 200, r837.text
r835 = client.post(
"/api/parse-835",
files={"file": ("era.txt", text_835, "text/plain")},
headers={"Accept": "application/json"},
)
assert r835.status_code == 200, r835.text
return client
def test_provider_detail_includes_recent_claims(seeded_db: TestClient):
"""The extended response gains a recent_claims array (top 10)."""
resp = seeded_db.get(f"/api/config/providers/{MONTROSE_NPI}")
assert resp.status_code == 200, resp.text
data = resp.json()
assert "recent_claims" in data
assert isinstance(data["recent_claims"], list)
assert len(data["recent_claims"]) <= 10
def test_provider_detail_includes_recent_activity(seeded_db: TestClient):
"""The extended response gains a recent_activity array (top 10)."""
resp = seeded_db.get(f"/api/config/providers/{MONTROSE_NPI}")
assert resp.status_code == 200, resp.text
data = resp.json()
assert "recent_activity" in data
assert isinstance(data["recent_activity"], list)
assert len(data["recent_activity"]) <= 10
def test_provider_detail_backwards_compat(seeded_db: TestClient):
"""All SP9 fields still present; new arrays don't break the contract.
The Provider Pydantic model (backend/src/cyclone/providers.py)
serializes snake_case fields — that's what the wire carries. The
TS ``Provider`` interface in ``src/types/index.ts`` is the
in-memory sample shape and intentionally diverges; the contract
being verified here is the API's actual payload.
"""
resp = seeded_db.get(f"/api/config/providers/{MONTROSE_NPI}")
assert resp.status_code == 200, resp.text
data = resp.json()
for key in (
"npi",
"label",
"legal_name",
"tax_id",
"taxonomy_code",
"address_line1",
"city",
"state",
"zip",
"is_active",
):
assert key in data, f"missing field {key}"
def test_provider_detail_includes_orphan_remit_received(seeded_db: TestClient):
"""Regression: the ``remit_received`` ActivityEvent is recorded at 835
ingest with ``claim_id=None`` (``store.add`` lines 999-1003) — the
remittance hasn't been matched to a claim yet. The original
``ActivityEvent.claim_id IN (claim_ids)`` filter misses it because
the orphan's claim_id is NULL.
Once reconciliation (auto or manual) populates
``Remittance.claim_id``, the activity filter must surface the event
via the ``Remittance.claim_id IN (claim_ids)`` branch of the OR.
Without that branch, a provider's activity feed appears to freeze
the moment an 835 lands — the most common activity, invisible.
Setup: ingest 837+835 for ``TEST_837_NPI`` (claim CLM001 + remit
CLM001), then manually match them so ``Remittance.claim_id`` is
populated. The bug presents as: only ``claim_submitted`` and
``manual_match`` appear (no ``remit_received``). The fix surfaces
all three.
"""
# Force the match — simulates the post-reconciliation state that
# populate Remittance.claim_id without depending on auto-reconcile
# heuristics (which don't match this minimal fixture).
match_resp = seeded_db.post(
"/api/reconciliation/match",
json={"claim_id": "CLM001", "remit_id": "CLM001"},
)
assert match_resp.status_code == 200, match_resp.text
resp = seeded_db.get(f"/api/config/providers/{TEST_837_NPI}")
assert resp.status_code == 200, resp.text
data = resp.json()
kinds = {event["kind"] for event in data["recent_activity"]}
assert "remit_received" in kinds, (
f"expected remit_received in recent_activity (orphan remits "
f"must surface via the Remittance join), got kinds={sorted(kinds)}"
)
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,6 +1,7 @@
import { Route, Routes } from "react-router-dom";
import { Toaster } from "sonner";
import { Layout } from "@/components/Layout";
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
import { Dashboard } from "@/pages/Dashboard";
import { Claims } from "@/pages/Claims";
import { Remittances } from "@/pages/Remittances";
@@ -24,7 +25,7 @@ function NotFound() {
export default function App() {
return (
<>
<DrillStackProvider>
<Routes>
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
@@ -52,6 +53,6 @@ export default function App() {
},
}}
/>
</>
</DrillStackProvider>
);
}
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { ReactNode } from "react";
import {
DrillStackProvider,
useDrillStack,
} from "@/components/drill/DrillStackProvider";
function wrapper({ children }: { children: ReactNode }) {
return <DrillStackProvider>{children}</DrillStackProvider>;
}
describe("DrillStackProvider", () => {
it("starts with an empty stack", () => {
const { result } = renderHook(() => useDrillStack(), { wrapper });
expect(result.current.stack).toEqual([]);
expect(result.current.openPeek).toBeInstanceOf(Function);
expect(result.current.closeTop).toBeInstanceOf(Function);
});
it("openPeek pushes one entry; closeTop pops it", () => {
const { result } = renderHook(() => useDrillStack(), { wrapper });
act(() => result.current.openPeek({ kind: "payer", payerId: "SKCO0" }));
expect(result.current.stack).toEqual([
{ kind: "payer", payerId: "SKCO0" },
]);
act(() => result.current.closeTop());
expect(result.current.stack).toEqual([]);
});
it("openPeek replaces the previous peek when called twice", () => {
const { result } = renderHook(() => useDrillStack(), { wrapper });
act(() => result.current.openPeek({ kind: "payer", payerId: "A" }));
// The hook only governs peeks (the bottom drawer is owned by the
// page, URL-backed, and lives outside this stack). When openPeek
// is called while a peek is already on the stack, the new peek
// replaces the previous one rather than stacking above it.
act(() => result.current.openPeek({ kind: "rule", rule: "R050" }));
expect(result.current.stack).toHaveLength(1);
expect(result.current.stack[0]).toEqual({ kind: "rule", rule: "R050" });
});
});
@@ -0,0 +1,53 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { create } from "zustand";
export type PeekPayload =
| { kind: "payer"; payerId: string }
| { kind: "rule"; rule: string };
interface DrillState {
stack: PeekPayload[];
openPeek: (p: PeekPayload) => void;
closeTop: () => void;
closeAll: () => void;
}
// One zustand store per provider instance (factory) so multiple
// providers (e.g. in tests) don't share state.
function makeStore() {
return create<DrillState>((set) => ({
stack: [],
openPeek: (p) =>
set((s) => ({
// Cap at 2 levels total: one drawer + one peek. When called and
// the stack already has one peek, replace it.
stack: s.stack.length >= 1 ? [p] : [p],
})),
closeTop: () => set((s) => ({ stack: s.stack.slice(0, -1) })),
closeAll: () => set({ stack: [] }),
}));
}
type StoreApi = ReturnType<typeof makeStore>;
const Ctx = createContext<StoreApi | null>(null);
export function DrillStackProvider({ children }: { children: ReactNode }) {
// useMemo so the store instance is stable across renders.
const store = useMemo(makeStore, []);
return <Ctx.Provider value={store}>{children}</Ctx.Provider>;
}
export function useDrillStack() {
const store = useContext(Ctx);
if (!store) throw new Error("useDrillStack must be used within DrillStackProvider");
// Subscribe to just `stack` so consumers re-render only on stack
// changes (not on every state update).
const stack = store((s) => s.stack);
return {
stack,
openPeek: store.getState().openPeek,
closeTop: store.getState().closeTop,
closeAll: store.getState().closeAll,
};
}
@@ -0,0 +1,37 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, describe, it, expect, vi } from "vitest";
import { cleanup, render, screen, fireEvent } from "@testing-library/react";
import { DrillableCell } from "@/components/drill/DrillableCell";
// happy-dom keeps `document.body` between tests; without cleanup,
// `screen.getByRole("button")` finds buttons from earlier renders.
afterEach(() => cleanup());
describe("DrillableCell", () => {
it("renders children, applies hover affordance classes, calls onClick", () => {
const onClick = vi.fn();
render(
<DrillableCell onClick={onClick}>
<span>CLM-114</span>
</DrillableCell>,
);
const btn = screen.getByRole("button") as HTMLButtonElement;
expect(btn.classList.contains("drillable")).toBe(true);
fireEvent.click(btn);
expect(onClick).toHaveBeenCalledOnce();
});
it("disabled state hides affordance and blocks click", () => {
const onClick = vi.fn();
render(
<DrillableCell onClick={onClick} disabled>
<span>unavailable</span>
</DrillableCell>,
);
const btn = screen.getByRole("button") as HTMLButtonElement;
expect(btn.disabled).toBe(true);
expect(btn.classList.contains("drillable")).toBe(false);
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface Props {
children: ReactNode;
onClick: () => void;
disabled?: boolean;
/** Optional aria-label; defaults to the visible text content. */
ariaLabel?: string;
}
/**
* Wrap any clickable cell with hover-reveal affordance:
* cursor: pointer + accent background tint + trailing "" chevron,
* applied via the `drillable` class on hover (see `src/index.css`
* in Task 1.4).
*
* Renders as a <button> (disabled when `disabled`) so it gets keyboard
* activation (Enter/Space) and the standard disabled-button semantics.
* The `drillable` affordance class is omitted when disabled.
*/
export function DrillableCell({ children, onClick, disabled, ariaLabel }: Props) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
className={cn(
!disabled && "drillable",
"inline-flex items-center gap-0 rounded-sm border-0 bg-transparent p-0 text-left",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
disabled && "text-muted-foreground cursor-not-allowed",
)}
>
{children}
</button>
);
}
@@ -0,0 +1,80 @@
// @vitest-environment happy-dom
// PayerPeekContent uses useQuery internally via usePayerSummary. We mock
// that hook here so the component can be rendered without standing up a
// real QueryClient — same pattern as useClaimDetail.test.ts.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, describe, it, expect, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import type { ReactNode } from "react";
import { PayerPeekContent } from "@/components/drill/PayerPeekContent";
// Mock the hook BEFORE the import above is resolved (vitest hoists
// `vi.mock` to the top of the file regardless of where it appears
// syntactically).
vi.mock("@/hooks/usePayerSummary", () => ({
usePayerSummary: vi.fn(),
}));
// Importing after vi.mock so we get the mocked reference.
import { usePayerSummary } from "@/hooks/usePayerSummary";
// happy-dom keeps `document.body` between tests; without cleanup,
// `screen.getByText(...)` would find nodes from earlier renders.
afterEach(() => cleanup());
// The component renders <Link>, which requires a router context. A bare
// MemoryRouter with no initialEntries is enough — the test asserts on the
// generated href, not on navigation.
function withRouter(node: ReactNode) {
return <MemoryRouter>{node}</MemoryRouter>;
}
const SAMPLE_PAYER = {
payer_id: "SKCO0",
name: "CO Medicaid",
claim_count: 1247,
billed_total: 548000,
received_total: 521000,
denial_rate: 0.042,
top_providers: [{ npi: "1881068062", count: 184 }],
} as const;
describe("PayerPeekContent", () => {
it("renders loading skeleton while fetching", () => {
// Idle / in-flight state — `data` undefined, `isLoading` true.
// The component renders <Skeleton variant="row" /> rows and no text,
// so the regex match for /claims/i must come back null (the
// "View all claims" link only renders once data is present).
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: undefined,
isLoading: true,
});
render(withRouter(<PayerPeekContent payerId="SKCO0" />));
expect(screen.queryByText(/claims/i)).toBeNull();
});
it("renders summary stats when data loads", () => {
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: SAMPLE_PAYER,
isLoading: false,
});
render(withRouter(<PayerPeekContent payerId="SKCO0" />));
expect(screen.getByText("CO Medicaid")).toBeTruthy();
// fmt.num(1247) === "1,247" — the "184 claims" line also matches this
// regex, but getByText with a regex is fine because we only assert
// existence.
expect(screen.getByText(/1,247/)).toBeTruthy();
expect(screen.getByText("$548,000")).toBeTruthy();
// denial_rate is a fraction (0.042); fmt.pct(payer.denial_rate * 100)
// yields "4.2%".
expect(screen.getByText("4.2%")).toBeTruthy();
const link = screen.getByRole("link", { name: /view all claims/i });
expect(link.getAttribute("href")).toBe("/claims?payer=SKCO0");
});
});
+118
View File
@@ -0,0 +1,118 @@
import { Link } from "react-router-dom";
import { Skeleton } from "@/components/ui/skeleton";
import { fmt } from "@/lib/format";
import { usePayerSummary } from "@/hooks/usePayerSummary";
import type { PayerSummary } from "@/lib/api";
interface Props {
payerId: string;
}
/**
* Peek body for a payer — aggregate stats card shown inside the
* centered PeekModal (SP21 universal drill-down).
*
* Owns its own fetch via `usePayerSummary`; the parent PeekModal only
* concerns itself with open/close + title. We deliberately do NOT show
* an error state here — the peek is a low-stakes summary, so a silent
* retry + skeleton on failure is acceptable (the parent modal still
* closes correctly).
*
* `fmt.pct` does not multiply by 100 (it's just `n.toFixed(d)%`), so the
* API's fraction `denial_rate` (01) needs `* 100` before formatting —
* otherwise the UI would render "0.0%" for everything.
*/
export function PayerPeekContent({ payerId }: Props) {
const { data, isLoading } = usePayerSummary(payerId);
if (isLoading || !data) {
return (
<div className="space-y-2" aria-busy="true">
<Skeleton variant="row" />
<Skeleton variant="row" />
<Skeleton variant="row" />
</div>
);
}
return <Loaded payer={data} />;
}
function Loaded({ payer }: { payer: PayerSummary }) {
return (
<div className="space-y-4">
<div className="flex items-baseline justify-between gap-3">
<div className="display text-[15px] text-foreground truncate">
{payer.name}
</div>
<div className="mono text-[11px] text-muted-foreground">
{payer.payer_id}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Stat label="Claims" value={fmt.num(payer.claim_count)} />
<Stat
label="Denial rate"
value={fmt.pct(payer.denial_rate * 100)}
/>
<Stat
label="Billed"
value={fmt.usd(payer.billed_total)}
accent="accent"
/>
<Stat
label="Received"
value={fmt.usd(payer.received_total)}
accent="success"
/>
</div>
{payer.top_providers.length > 0 ? (
<div>
<div className="eyebrow mb-1.5">Top providers</div>
<ul className="text-[12.5px] space-y-1">
{payer.top_providers.slice(0, 3).map((p) => (
<li
key={p.npi}
className="flex justify-between gap-3"
>
<span className="mono">{p.npi}</span>
<span className="mono text-muted-foreground">
{fmt.num(p.count)} claims
</span>
</li>
))}
</ul>
</div>
) : null}
<Link
to={`/claims?payer=${encodeURIComponent(payer.payer_id)}`}
className="text-[12.5px] text-accent hover:underline"
>
View all claims
</Link>
</div>
);
}
function Stat({
label,
value,
accent,
}: {
label: string;
value: string;
accent?: "accent" | "success" | "warning";
}) {
const color =
accent === "success"
? "text-[hsl(var(--success))]"
: accent === "warning"
? "text-[hsl(var(--warning))]"
: accent === "accent"
? "text-accent"
: "text-foreground";
return (
<div>
<div className="eyebrow">{label}</div>
<div className={`display mono text-[16px] mt-1 ${color}`}>{value}</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, describe, it, expect, vi } from "vitest";
import { cleanup, render, screen, fireEvent } from "@testing-library/react";
import { PeekModal } from "@/components/drill/PeekModal";
// happy-dom keeps `document.body` between tests; without cleanup,
// `screen.getByRole(...)` finds buttons from earlier renders.
afterEach(() => cleanup());
describe("PeekModal", () => {
it("renders title and body when open; close button fires onClose", () => {
const onClose = vi.fn();
render(
<PeekModal
open
onClose={onClose}
eyebrow="Payer"
title="CO Medicaid"
>
<p>1,247 claims</p>
</PeekModal>,
);
expect(screen.getByText("Payer")).toBeTruthy();
expect(screen.getByText("CO Medicaid")).toBeTruthy();
expect(screen.getByText("1,247 claims")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /close/i }));
expect(onClose).toHaveBeenCalledOnce();
});
it("renders nothing when closed", () => {
const { container } = render(
<PeekModal open={false} onClose={() => {}} title="hidden">
<p>should not appear</p>
</PeekModal>,
);
// jest-dom's toBeEmptyDOMElement is not installed; assert via raw DOM.
expect(container.firstChild).toBeNull();
});
it("esc key closes", () => {
const onClose = vi.fn();
render(
<PeekModal open onClose={onClose} title="t">
<p>x</p>
</PeekModal>,
);
fireEvent.keyDown(document.body, { key: "Escape" });
expect(onClose).toHaveBeenCalledOnce();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { Dialog, DialogContent } from "@/components/ui/dialog";
import type { ReactNode } from "react";
interface Props {
open: boolean;
onClose: () => void;
eyebrow?: string;
title: string;
children: ReactNode;
}
/**
* Centered peek modal — used for cross-reference drills (payer,
* validation rule, etc.). Smaller than the right-side Drawer
* (max-width: 480px); closes on Esc, backdrop click, and the X button.
* No keyboard j/k nav — single record.
*/
export function PeekModal({ open, onClose, eyebrow, title, children }: Props) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent
className="max-w-[480px] w-[90vw]"
aria-describedby={undefined}
>
{eyebrow ? (
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{eyebrow}
</div>
) : null}
<h2 className="text-[18px] font-semibold tracking-tight">{title}</h2>
<div className="mt-2">{children}</div>
</DialogContent>
</Dialog>
);
}
+119 -34
View File
@@ -1,16 +1,15 @@
// ---------------------------------------------------------------------------
// InboxHeader
//
// Compact working-surface header: the day/date, a live clock, and the
// two top-line counts ("N items need eyes" and "N done today") that
// anchor the operator's day. "Need eyes" is computed by the Inbox
// page (sum of actionable lanes) and passed in.
//
// SP14: payer_rejected (277CA) is now part of the actionable lanes —
// it's a working-surface rejection that needs operator follow-up.
// The Inbox page sums it into the needEyes count.
// Editorial dark hero for the Inbox. Larger than the previous 30px title
// so it carries weight against the bright lane surface below. "Inbox."
// is the page's anchor, the day/date sits as a serif italic, and the
// "N items need eyes / N done today" line replaces the small status
// pulse with a more dramatic mono announcement.
// ---------------------------------------------------------------------------
import { fmt } from "@/lib/format";
export function InboxHeader({
needEyesCount,
doneTodayCount,
@@ -32,66 +31,152 @@ export function InboxHeader({
return (
<header
className="sticky top-0 z-10 px-6 pt-6 pb-4"
className="sticky top-0 z-10 px-6 lg:px-10 pt-6 pb-5"
style={{
background: "var(--tt-bg)",
borderBottom: "1px solid var(--tt-bg-elev)",
}}
>
<div className="flex items-baseline justify-between gap-4">
<div className="flex items-baseline gap-4">
<h1
className="display tracking-tight"
<div className="relative">
{/* Ghost "TRIAGE" watermark — a print-shop stamp behind the title. */}
<div
aria-hidden
className="pointer-events-none select-none absolute inset-x-0 top-1/2 -translate-y-1/2 whitespace-nowrap display text-center"
style={{
color: "var(--tt-ink)",
fontSize: 30,
fontSize: "clamp(120px, 18vw, 260px)",
letterSpacing: "-0.05em",
opacity: 0.05,
lineHeight: 1,
color: "var(--tt-amber)",
}}
>
Inbox
</h1>
TRIAGE
</div>
<div className="relative flex items-end justify-between gap-6 flex-wrap">
<div className="min-w-0 max-w-3xl">
<div className="flex items-center gap-3 mb-3">
<div
className="h-px w-14"
style={{ backgroundColor: "var(--tt-amber)" }}
/>
<span
className="mono uppercase"
style={{
color: "var(--tt-amber)",
fontSize: 11,
letterSpacing: "0.18em",
letterSpacing: "0.22em",
fontWeight: 600,
}}
>
· {day} {date}
Inbox · Working surface
</span>
</div>
<span
className="mono tabular-nums"
<h1
className="display tracking-[-0.04em]"
style={{
color: "var(--tt-ink)",
fontSize: "clamp(48px, 6vw, 80px)",
lineHeight: 0.92,
fontWeight: 400,
}}
>
Inbox.
</h1>
<p
className="display italic mt-3"
style={{
color: "var(--tt-ink-dim)",
fontSize: 12,
letterSpacing: "0.05em",
fontSize: "clamp(15px, 1.4vw, 19px)",
lineHeight: 1.4,
maxWidth: "44ch",
}}
>
{time}
</span>
Five lanes, one queue.{" "}
<span style={{ color: "var(--tt-amber)" }}>
{needEyesCount}
</span>{" "}
need eyes; {doneTodayCount} are done today.
</p>
</div>
<p
className="mono mt-2 flex items-center gap-2"
<div
className="flex flex-col items-start lg:items-end gap-2"
aria-label="Day and time"
>
<div
className="mono uppercase"
style={{
color: "var(--tt-ink-dim)",
fontSize: 11,
letterSpacing: "0.08em",
textTransform: "uppercase",
letterSpacing: "0.22em",
fontWeight: 500,
}}
>
{day} {date}
</div>
<div
className="display tabular-nums"
style={{
color: "var(--tt-ink)",
fontSize: 26,
lineHeight: 1,
fontWeight: 400,
}}
>
{time}
</div>
<div
className="mono uppercase"
style={{
color: "var(--tt-ink-dim)",
fontSize: 10,
letterSpacing: "0.20em",
}}
>
local
</div>
</div>
</div>
{/* Live status row — a horizontal "ticker" of the lane counts so
the operator can read the queue at a glance. */}
<div
className="relative mt-5 flex items-center gap-5 flex-wrap mono uppercase"
style={{
color: "var(--tt-ink-dim)",
fontSize: 11,
letterSpacing: "0.14em",
}}
>
<div className="flex items-center gap-2">
<span
aria-hidden
className="inline-block w-1.5 h-1.5 rounded-full animate-pulse-dot"
style={{ background: "var(--tt-amber)" }}
aria-hidden
/>
<span style={{ color: "var(--tt-amber)", fontWeight: 600 }}>{needEyesCount}</span>
items need eyes
<span>Live</span>
</div>
<span className="opacity-50">·</span>
<span style={{ color: "var(--tt-ink)", fontWeight: 600 }}>{doneTodayCount}</span>
<span>
<span style={{ color: "var(--tt-amber)", fontWeight: 600 }}>
{needEyesCount}
</span>{" "}
need eyes
</span>
<span className="opacity-50">·</span>
<span>
<span style={{ color: "var(--tt-ink)", fontWeight: 600 }}>
{doneTodayCount}
</span>{" "}
done today
</p>
</span>
<span className="opacity-50 hidden sm:inline">·</span>
<span className="hidden sm:inline">
{fmt.date(new Date().toISOString())}
</span>
</div>
</div>
</header>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { api, type PayerSummary } from "@/lib/api";
/**
* Fetch the aggregate payer stats shown in the payer peek modal
* (SP21 universal drill-down).
*
* Caches for 60s — the backend caches the same way, so re-asks inside
* that window are free. `retry: 1` because peek content is a low-stakes
* summary; one retry on transient failure is enough before falling back
* to the error UI.
*
* When `payerId` is `null` (the peek isn't open), the query is disabled
* and the hook returns the idle React-Query state — no fetch.
*/
export function usePayerSummary(payerId: string | null) {
return useQuery<PayerSummary>({
queryKey: ["payer-summary", payerId],
queryFn: () => api.getPayerSummary(payerId as string),
enabled: payerId !== null,
staleTime: 60 * 1000,
retry: 1,
});
}
+15
View File
@@ -413,3 +413,18 @@
animation-duration: 0s !important;
}
}
/* Universal drill-down affordance — applied by DrillableCell. */
.drillable {
cursor: pointer;
transition: background-color 120ms ease;
}
.drillable:hover {
background-color: hsl(var(--accent) / 0.08);
}
.drillable:hover::after {
content: "";
margin-left: 6px;
color: hsl(var(--accent));
font-weight: 600;
}
+34
View File
@@ -586,6 +586,39 @@ async function listProviders<T = unknown>(
return (await res.json()) as PaginatedResponse<T>;
}
/**
* Aggregate stats for one payer, used by the peek modal on Dashboard /
* Claims tables (SP21 universal drill-down). Drives
* `GET /api/payers/{payer_id}/summary`.
*
* `denial_rate` is a fraction in `[0, 1]` (the API does NOT pre-multiply).
* `top_providers` is the top 3 (or fewer) by claim count, ordered
* server-side.
*/
export interface PayerSummary {
payer_id: string;
name: string;
claim_count: number;
billed_total: number;
received_total: number;
denial_rate: number;
top_providers: Array<{ npi: string; count: number }>;
}
async function getPayerSummary(payerId: string): Promise<PayerSummary> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/payers/${encodeURIComponent(payerId)}/summary`)
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PayerSummary;
}
async function listActivity<T = unknown>(
params: ListActivityParams = {}
): Promise<PaginatedResponse<T>> {
@@ -740,6 +773,7 @@ export const api = {
listRemittances,
getRemittance,
listProviders,
getPayerSummary,
listActivity,
listUnmatched,
matchRemit,
+641 -81
View File
@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { CheckCircle2, Download } from "lucide-react";
import { CheckCircle2, Download, ShieldCheck } from "lucide-react";
import {
Table,
TableBody,
@@ -27,18 +27,30 @@ import type { Ack } from "@/types";
function AckCodeBadge({ code }: { code: Ack["ackCode"] }) {
const color =
code === "A"
? "text-emerald-400 border-emerald-400/30 bg-emerald-400/10"
: code === "E"
? "text-amber-400 border-amber-400/30 bg-amber-400/10"
: code === "P"
? "text-amber-400 border-amber-400/30 bg-amber-400/10"
: "text-red-400 border-red-400/30 bg-red-400/10";
? {
text: "hsl(152 64% 30%)",
bg: "hsl(152 50% 88%)",
border: "hsl(152 64% 38% / 0.30)",
}
: code === "R"
? {
text: "hsl(358 70% 36%)",
bg: "hsl(358 70% 92%)",
border: "hsl(358 70% 50% / 0.30)",
}
: {
text: "hsl(36 92% 30%)",
bg: "hsl(36 82% 88%)",
border: "hsl(36 92% 50% / 0.30)",
};
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10.5px] font-semibold uppercase tracking-[0.14em]",
color,
)}
className="inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
style={{
color: color.text,
backgroundColor: color.bg,
borderColor: color.border,
}}
>
{code}
</span>
@@ -62,26 +74,15 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
setBusy(true);
try {
const detail = await api.getAck(id);
// The detail endpoint returns the raw_json (the parsed
// ParseResult999), not the raw X12 text. Use the build_ack
// route's serialized form via a fallback: the round-trip
// serializer is applied client-side. For v1 we just
// serialize the raw_json envelope/segments if we have them.
const raw =
(detail as unknown as { raw_999_text?: string }).raw_999_text ??
(() => {
// Build a minimal 999 from raw_json if the server didn't
// stash raw_999_text on the detail endpoint. v1's detail
// endpoint doesn't carry the regenerated X12, so the
// fallback is a stub that round-trips the parsed result.
return "";
})();
if (raw) {
downloadBlob(`ack-${sourceBatchId}.999`, raw);
}
} catch (err) {
// Swallow — the operator can retry. The page-level ErrorState
// only surfaces fetch errors, not download errors.
console.error("download 999 failed", err);
} finally {
setBusy(false);
@@ -93,10 +94,14 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
onClick={onClick}
disabled={busy}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2 py-1 text-[11.5px] font-medium",
"hover:bg-muted/40 transition-colors",
"inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 mono text-[10.5px] uppercase tracking-[0.14em] font-semibold transition-colors",
busy && "opacity-50 cursor-not-allowed",
)}
style={{
borderColor: "hsl(30 14% 14% / 0.20)",
color: "hsl(var(--surface-ink-2))",
backgroundColor: "hsl(36 22% 96%)",
}}
aria-label="Download 999"
>
<Download className="h-3 w-3" strokeWidth={1.75} />
@@ -109,11 +114,6 @@ export function Acks() {
const { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 });
const items = data?.items ?? [];
// Row navigation state (SP4-style j/k). `selectedIndex === null`
// means "no row focused yet" — the initial render shows no
// highlight, and the first j press lands on row 0 (and the first k
// press lands on the last row, so the user can quickly jump to
// either end of the list).
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
@@ -129,18 +129,10 @@ export function Acks() {
setSelectedIndex((i) => {
if (items.length === 0) return null;
if (i === null) return items.length - 1;
// Add items.length before the modulo so the negative index
// (first row → wrap to last) wraps correctly.
return (i - 1 + items.length) % items.length;
});
}, [items.length]);
// `enabled: !helpOpen` so j/k navigation yields to the cheatsheet
// while it's open. The cheatsheet's own dismiss listener still fires
// for any non-`?` keypress (including j/k), so pressing j will close
// the cheatsheet but won't move the row — exactly the behavior the
// user expects: "the cheatsheet is in the way, get it out of my
// way; I'll start navigating on the next keypress".
useRowKeyboard({
enabled: !helpOpen && items.length > 0,
onNext: moveNext,
@@ -149,60 +141,504 @@ export function Acks() {
onToggleHelp: () => setHelpOpen((v) => !v),
});
// -----------------------------------------------------------------
// Aggregate metrics for the KPI strip
// -----------------------------------------------------------------
const totals = items.reduce(
(acc, a) => ({
accepted: acc.accepted + a.acceptedCount,
rejected: acc.rejected + a.rejectedCount,
received: acc.received + a.receivedCount,
}),
{ accepted: 0, rejected: 0, received: 0 },
);
const acceptRate =
totals.received > 0
? Math.round((totals.accepted / totals.received) * 1000) / 10
: 0;
// -----------------------------------------------------------------
// Stagger choreography
// -----------------------------------------------------------------
const heroDelay = 0;
const foldDelay = 220;
const titleDelay = 320;
const kpiDelay = 460;
const tableDelay = 600;
return (
<>
<KeyboardCheatsheet
open={helpOpen}
onClose={() => setHelpOpen(false)}
/>
<div className="space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
999 ACKs
<div className="space-y-0">
{/* =================================================================
HERO DARK EDITORIAL HEADER
================================================================= */}
<section
className="relative animate-fade-in pt-6 pb-8 lg:pt-9 lg:pb-10"
style={{ animationDelay: `${heroDelay}ms` }}
>
{/* Ghost "999" watermark — a print-shop stamp behind the title */}
<div
aria-hidden="true"
className="pointer-events-none select-none absolute inset-x-0 top-[58%] -translate-y-1/2 whitespace-nowrap display text-center"
style={{
fontSize: "clamp(180px, 22vw, 320px)",
letterSpacing: "-0.05em",
opacity: 0.05,
lineHeight: 1,
color: "hsl(var(--surface-ink))",
}}
>
999
</div>
<h1 className="text-[22px] font-semibold tracking-tight">
999 Implementation Acknowledgments
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-8 items-end mb-7">
<div className="min-w-0 max-w-3xl">
<div className="flex items-center gap-3 mb-5">
<div className="h-px w-14 bg-foreground/25" />
<span className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground">
999 ACKs · Reference
</span>
<span
className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground/60 hidden sm:inline"
aria-hidden
>
· {items.length} on file
</span>
</div>
<h1 className="display text-[48px] sm:text-[64px] lg:text-[80px] leading-[0.92] text-foreground tracking-[-0.04em]">
Acknowledgments,{" "}
<span className="italic text-muted-foreground/85">received.</span>
</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
999 ACK transaction sets generated automatically in response to
837P ingests, or parsed from inbound 999 uploads.
</div>
<div className="flex flex-col items-start lg:items-end gap-3">
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3.5 py-2 text-[11.5px] mono uppercase tracking-[0.14em] text-muted-foreground backdrop-blur">
<ShieldCheck
className="h-3.5 w-3.5"
strokeWidth={1.75}
style={{ color: "hsl(var(--success))" }}
/>
Envelope · accepted / rejected / partial
</div>
<kbd
className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70"
>
Press <span className="text-foreground">?</span> for shortcuts
</kbd>
</div>
</div>
<div className="relative z-10 max-w-2xl">
<p className="text-[14px] text-muted-foreground leading-relaxed">
999 Implementation Acknowledgments generated automatically in
response to 837P ingests, or parsed from inbound 999 uploads.
Each row below is a single transaction set with its accept /
reject counts and the original X12 to download.
</p>
</header>
</div>
</section>
{/* =================================================================
FOLD TORN PAGE
================================================================= */}
<div
aria-hidden
className="relative h-14 animate-fade-in"
style={{ animationDelay: `${foldDelay}ms` }}
>
<div
className="absolute inset-x-0 top-0 h-1/2"
style={{
background:
"linear-gradient(to bottom, hsl(0 0% 0% / 0.55), transparent)",
}}
/>
<div
className="absolute inset-x-0 bottom-0 h-1/2"
style={{
background:
"linear-gradient(to top, hsl(30 14% 14% / 0.18), transparent)",
}}
/>
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
style={{
background:
"linear-gradient(to right, transparent, hsl(30 14% 14% / 0.5) 12%, hsl(30 14% 14% / 0.7) 50%, hsl(30 14% 14% / 0.5) 88%, transparent)",
}}
/>
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-2"
style={{ pointerEvents: "none" }}
>
{Array.from({ length: 48 }, (_, i) => (
<span
key={i}
className="block h-[3px] w-[3px] rounded-full"
style={{ backgroundColor: "hsl(30 14% 14% / 0.22)" }}
/>
))}
</div>
<div className="relative z-10 mx-auto h-full flex items-center justify-center gap-3 bg-background px-4 w-fit">
<span
className="display italic"
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
>
</span>
<span
className="mono uppercase tracking-[0.24em]"
style={{
color: "hsl(var(--muted-foreground))",
fontSize: 11,
fontWeight: 500,
}}
>
999 register
</span>
<span
className="display italic"
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
>
</span>
</div>
</div>
{/* =================================================================
PAPER PLANE
================================================================= */}
<div
className="relative max-w-[1280px] mx-auto animate-fade-in-up"
style={{
animationDelay: `${titleDelay}ms`,
backgroundColor: "hsl(var(--surface))",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 30px 80px -24px hsl(0 0% 0% / 0.45)",
}}
>
{/* Paper grain */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.08 0 0 0 0 0.05 0 0 0 0.9 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\")",
backgroundSize: "160px 160px",
mixBlendMode: "multiply",
}}
/>
{/* Title block */}
<div className="relative px-8 lg:px-14 pt-12 pb-9 border-b border-[hsl(30_14%_14%/_0.12)]">
<div
aria-hidden
className="absolute left-7 lg:left-12 top-0 bottom-0 w-px"
style={{ backgroundColor: "hsl(30 14% 14% / 0.14)" }}
/>
<div className="flex items-end justify-between gap-8 flex-wrap">
<div>
<div
className="mono text-[12px] uppercase tracking-[0.24em] mb-4 font-medium"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Register · 999 Implementation ACKs
</div>
<h2
className="display leading-[0.92] tracking-[-0.04em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(48px, 7vw, 96px)",
fontWeight: 400,
}}
>
The register.
</h2>
<div
className="mt-4 display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: "clamp(15px, 1.3vw, 18px)",
lineHeight: 1.4,
maxWidth: "32ch",
}}
>
One row per inbound 999 the accept / reject / partial
count, the source batch it answers, and the original
transaction set to download.
</div>
</div>
<div className="text-right shrink-0">
<div
className="mono text-[11px] uppercase tracking-[0.24em] mb-1.5 font-medium"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
On file
</div>
<div
className="display tabular-nums"
style={{
color: "hsl(var(--surface-ink))",
fontSize: 26,
lineHeight: 1.1,
}}
>
{items.length}
</div>
<div
className="mono text-[11px] mt-1"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
999 ACK
{items.length === 1 ? "" : "s"}
</div>
</div>
</div>
</div>
{/* KPI strip — § 01 Vital signs */}
<section
aria-label="Aggregate ACK metrics"
className="relative px-8 lg:px-14 py-7 animate-fade-in-up"
style={{ animationDelay: `${kpiDelay}ms` }}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-7 flex flex-col items-center gap-1"
>
<span
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
§ 01
</span>
<div
className="w-px h-10"
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
/>
<span
className="display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: 11,
writingMode: "vertical-rl",
transform: "rotate(180deg)",
letterSpacing: "0.16em",
}}
>
Vital signs
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<AckKpiTile
label="Accepted"
value={totals.accepted}
tone="success"
/>
<AckKpiTile
label="Rejected"
value={totals.rejected}
tone="destructive"
/>
<AckKpiTile
label="Received"
value={totals.received}
tone="ink"
/>
<AckKpiTile
label="Accept rate"
value={`${acceptRate}%`}
tone="blue"
numeric={false}
/>
</div>
</section>
{/* Table — § 02 The register */}
<section
aria-label="ACK register"
className="relative px-8 lg:px-14 pb-8 lg:pb-10 animate-fade-in-up"
style={{ animationDelay: `${tableDelay}ms` }}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-9 flex flex-col items-center gap-1"
>
<span
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
§ 02
</span>
<div
className="w-px h-12"
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
/>
<span
className="display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: 11,
writingMode: "vertical-rl",
transform: "rotate(180deg)",
letterSpacing: "0.16em",
}}
>
The register
</span>
</div>
<div
className="pt-6 border-t"
style={{
borderTopStyle: "double",
borderTopWidth: 3,
borderColor: "hsl(30 14% 14% / 0.12)",
}}
>
<div className="flex items-end justify-between gap-6 flex-wrap mb-5">
<div>
<div
className="mono text-[11.5px] uppercase tracking-[0.24em] font-semibold mb-2"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
The register
</div>
<h3
className="display leading-[0.98] tracking-[-0.03em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(24px, 2.6vw, 32px)",
fontWeight: 400,
}}
>
All <span className="italic">999s</span>, newest first.
</h3>
</div>
<div
className="text-[12.5px] display italic"
style={{ color: "hsl(var(--surface-ink-2))", maxWidth: 380 }}
>
Navigate rows with{" "}
<kbd
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
style={{
borderColor: "hsl(30 14% 14% / 0.20)",
color: "hsl(var(--surface-ink))",
}}
>
j
</kbd>{" "}
/{" "}
<kbd
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
style={{
borderColor: "hsl(30 14% 14% / 0.20)",
color: "hsl(var(--surface-ink))",
}}
>
k
</kbd>
.
</div>
</div>
{isError ? (
<div
className="rounded-md border p-5"
style={{
borderColor: "hsl(30 14% 14% / 0.16)",
backgroundColor: "hsl(36 22% 96%)",
}}
>
<ErrorState
message="Couldn't load ACKs from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
) : null}
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-4 space-y-2">
</div>
) : isLoading ? (
<div
className="rounded-md border p-4 space-y-2"
style={{
borderColor: "hsl(30 14% 14% / 0.10)",
backgroundColor: "hsl(36 22% 96%)",
}}
>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<div
className="rounded-md border p-10 text-center"
style={{
borderColor: "hsl(30 14% 14% / 0.16)",
borderStyle: "dashed",
backgroundColor: "hsl(36 22% 96%)",
}}
>
<EmptyState
eyebrow="999 ACKs · awaiting first 837 with ack=true"
message="Upload an 837P with ?ack=true, or parse a 999 file directly to populate this list."
/>
</div>
) : (
<Table>
<div
className="rounded-md border overflow-hidden"
style={{
borderColor: "hsl(30 14% 14% / 0.10)",
backgroundColor: "hsl(36 22% 98%)",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
}}
>
<Table tone="paper">
<TableHeader>
<TableRow>
<TableHead className="w-12" aria-label="Status" />
<TableHead>ID</TableHead>
<TableHead>Source Batch</TableHead>
<TableHead className="text-right">Accepted</TableHead>
<TableHead className="text-right">Rejected</TableHead>
<TableHead className="text-right">Received</TableHead>
<TableHead>ACK Code</TableHead>
<TableHead>Parsed</TableHead>
<TableHead className="w-20" aria-label="Download" />
<TableHead
className="w-12"
aria-label="Status"
style={{ color: "hsl(var(--surface-ink-2))" }}
/>
<TableHead style={{ color: "hsl(var(--surface-ink-2))" }}>
ID
</TableHead>
<TableHead style={{ color: "hsl(var(--surface-ink-2))" }}>
Source Batch
</TableHead>
<TableHead
className="text-right"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
Accepted
</TableHead>
<TableHead
className="text-right"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
Rejected
</TableHead>
<TableHead
className="text-right"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
Received
</TableHead>
<TableHead style={{ color: "hsl(var(--surface-ink-2))" }}>
ACK Code
</TableHead>
<TableHead style={{ color: "hsl(var(--surface-ink-2))" }}>
Parsed
</TableHead>
<TableHead
className="w-20"
aria-label="Download"
style={{ color: "hsl(var(--surface-ink-2))" }}
/>
</TableRow>
</TableHeader>
<TableBody>
@@ -216,58 +652,182 @@ export function Acks() {
aria-selected={isSelected}
className={cn(
isSelected && [
// Accent-tinted bg + ring + left strip make the
// selected row clearly stand out from the
// default `hover:bg-muted/30` and the
// `data-[state=selected]:bg-muted` that the
// TableRow primitive applies. Tailwind's
// `accent` token is the same accent used
// elsewhere in the app (nav-active indicator,
// row-flash keyframe, focus ring).
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
"ring-1 ring-inset",
],
)}
style={
isSelected
? {
backgroundColor: "hsl(212 85% 95%)",
boxShadow:
"inset 2px 0 0 0 hsl(212 100% 45%)",
}
: undefined
}
>
<TableCell className="text-muted-foreground">
<TableCell>
<CheckCircle2
className={cn(
"h-3.5 w-3.5",
a.ackCode === "A" ? "text-emerald-400" : a.ackCode === "R" ? "text-red-400" : "text-amber-400",
a.ackCode === "A"
? "text-[hsl(152_64%_38%)]"
: a.ackCode === "R"
? "text-[hsl(358_70%_42%)]"
: "text-[hsl(36_92%_50%)]",
)}
strokeWidth={1.75}
aria-hidden
/>
</TableCell>
<TableCell className="display num text-[13px]">{a.id}</TableCell>
<TableCell className="font-mono text-[12px] text-muted-foreground truncate max-w-[180px]">
<TableCell
className="display num text-[13px]"
style={{ color: "hsl(var(--surface-ink))" }}
>
{a.id}
</TableCell>
<TableCell
className="font-mono text-[12px] truncate max-w-[180px]"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{a.sourceBatchId}
</TableCell>
<TableCell className="text-right display num text-emerald-400">
<TableCell
className="text-right display num"
style={{ color: "hsl(152 64% 32%)" }}
>
{a.acceptedCount}
</TableCell>
<TableCell className="text-right display num text-red-400">
<TableCell
className="text-right display num"
style={{ color: "hsl(358 70% 38%)" }}
>
{a.rejectedCount}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
<TableCell
className="text-right display num"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{a.receivedCount}
</TableCell>
<TableCell>
<AckCodeBadge code={a.ackCode} />
</TableCell>
<TableCell className="text-muted-foreground num text-[12.5px]">
<TableCell
className="num text-[12.5px]"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"}
</TableCell>
<TableCell>
<DownloadButton id={a.id} sourceBatchId={a.sourceBatchId} />
<DownloadButton
id={a.id}
sourceBatchId={a.sourceBatchId}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
</section>
{/* Footer */}
<div
className="relative px-8 lg:px-14 py-5 border-t flex items-center justify-between mono text-[10.5px] uppercase tracking-[0.18em]"
style={{
borderColor: "hsl(30 14% 14% / 0.12)",
color: "hsl(var(--surface-ink-3))",
}}
>
<span>End of register</span>
<span>Cyclone · 999 ACKs</span>
<span>
{items.length} {items.length === 1 ? "row" : "rows"}
</span>
</div>
</div>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// AckKpiTile — paper-toned metric for the ACK register.
// ---------------------------------------------------------------------------
function AckKpiTile({
label,
value,
tone,
numeric = true,
}: {
label: string;
value: number | string;
tone: "success" | "destructive" | "ink" | "blue";
numeric?: boolean;
}) {
const accentMap = {
success: "hsl(152 64% 38%)",
destructive: "hsl(358 70% 42%)",
ink: "hsl(var(--surface-ink))",
blue: "hsl(212 100% 45%)",
} as const;
const tintMap = {
success: "hsl(152 50% 88%)",
destructive: "hsl(358 70% 92%)",
ink: "hsl(36 22% 90%)",
blue: "hsl(212 85% 92%)",
} as const;
const accent = accentMap[tone];
const tint = tintMap[tone];
return (
<div
className="relative rounded-xl p-5 overflow-hidden border"
style={{
backgroundColor: "hsl(var(--surface))",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06), inset 3px 0 0 0 hsl(0 0% 100% / 0.4)",
borderColor: "hsl(30 14% 14% / 0.10)",
}}
>
<div
aria-hidden
className="absolute left-0 top-5 bottom-5 w-[3px] rounded-r-sm"
style={{ backgroundColor: accent, opacity: 0.85 }}
/>
<div className="flex items-center justify-between mb-2.5">
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{label}
</div>
<div
className="h-5 w-5 rounded-md flex items-center justify-center"
style={{ backgroundColor: tint }}
>
<span
className="block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: accent }}
/>
</div>
</div>
<div
className={cn(
"tabular-nums tracking-[-0.04em]",
numeric ? "display" : "display"
)}
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(28px, 3vw, 40px)",
lineHeight: 1,
fontWeight: 400,
}}
>
{value}
</div>
</div>
);
}
+170 -2
View File
@@ -8,6 +8,7 @@ import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { Claims } from "./Claims";
import { api } from "@/lib/api";
import { useTailStore } from "@/store/tail-store";
@@ -148,8 +149,30 @@ function setLocation(url: string): void {
* a real DOM container wrapped in `QueryClientProvider` so the page's
* TanStack Query calls (via `useClaims`) resolve against our mocked
* `api.listClaims`.
*
* Also wraps in a `MemoryRouter` so the page's `useSearchParams`
* (added when Claims started reading `?status=` / `?sort=` off the URL
* for Dashboard KPI drill-through) has a router context to read from.
* The default `initialEntries` mirrors whatever `setLocation(...)` set
* on `window.location`, so the existing tests' pre-set URLs keep
* working without per-test changes.
*/
function renderClaims(): { unmount: () => void } {
const initialEntries = [
window.location.pathname + window.location.search,
];
return renderClaimsAt(initialEntries);
}
/**
* Variant of `renderClaims` that lets a test pin a specific initial
* URL. Used by the URL-param filter tests below so each one can start
* at exactly the URL it's asserting on.
*/
function renderClaimsAt(initialEntries: string[]): {
unmount: () => void;
container: HTMLDivElement;
} {
const container = document.createElement("div");
document.body.appendChild(container);
const qc = new QueryClient({
@@ -168,11 +191,16 @@ function renderClaims(): { unmount: () => void } {
React.createElement(
QueryClientProvider,
{ client: qc },
React.createElement(Claims)
)
React.createElement(
MemoryRouter,
{ initialEntries },
React.createElement(Claims),
),
),
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
@@ -531,4 +559,144 @@ describe("Claims page drawer wiring", () => {
unmount();
});
// -------------------------------------------------------------------
// URL-driven filter state (sub-project 6, Phase 1 Task 1.8 follow-up).
//
// Dashboard KPI tiles drill through to Claims with `?status=` and
// `?sort=` query params. These tests pin each URL and assert that
// the page actually filters (not just lands on a default view).
//
// The strongest assertion is on `api.listClaims.mock.calls` — that's
// the exact params the page forwards to the API, so a passing test
// proves end-to-end that URL → useSearchParams → useClaims → fetch.
// -------------------------------------------------------------------
it("test_url_param_status_denied_filters_and_selects_chip", async () => {
const { unmount, container } = renderClaimsAt(["/claims?status=denied"]);
// The first render of FilterChips is reached before useSearchParams
// has the URL-derived `status` value committed (MemoryRouter
// initializes synchronously but the chip's aria-checked only flips
// to "true" on the next React commit). Wait for that commit so
// the assertion below isn't a race against the first render.
// We settle on `useClaims` having been called with status=denied
// — that's the strongest invariant: it proves the URL → state →
// API-call chain has fully propagated end-to-end.
await settle(
() => {
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
const lastCallParams = calls[calls.length - 1]?.[0] as
| { status?: string; sort?: string; order?: string }
| undefined;
return lastCallParams?.status === "denied";
},
);
// Scope queries to this test's container so we can't accidentally
// pick up a chip from a leaked render of a previous test.
const deniedChip = Array.from(
container.querySelectorAll('[role="radio"]'),
).find(
(el) => el.textContent?.trim().toLowerCase() === "denied",
) as HTMLButtonElement | undefined;
expect(deniedChip).toBeDefined();
expect(deniedChip?.getAttribute("aria-checked")).toBe("true");
// No other status chip should be checked.
const checkedChips = Array.from(
container.querySelectorAll('[role="radio"][aria-checked="true"]'),
);
expect(checkedChips).toHaveLength(1);
expect(checkedChips[0]?.textContent?.trim().toLowerCase()).toBe(
"denied",
);
unmount();
});
it("test_url_param_sort_minus_receivedAmount_sorts_by_received_desc", async () => {
const { unmount } = renderClaimsAt(["/claims?sort=-receivedAmount"]);
// Wait for useClaims to be called once.
await settle(
() => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
);
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
const lastCallParams = calls[calls.length - 1]?.[0] as
| { status?: string; sort?: string; order?: string }
| undefined;
// The `-` prefix must be stripped off and order set to desc.
expect(lastCallParams?.sort).toBe("receivedAmount");
expect(lastCallParams?.order).toBe("desc");
// No status filter — user only asked for a sort change.
expect(lastCallParams?.status).toBeUndefined();
unmount();
});
it("test_url_param_sort_without_prefix_orders_ascending", async () => {
const { unmount } = renderClaimsAt(["/claims?sort=submittedDate"]);
await settle(
() => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
);
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
const lastCallParams = calls[calls.length - 1]?.[0] as
| { status?: string; sort?: string; order?: string }
| undefined;
// No `-` prefix → order is asc.
expect(lastCallParams?.sort).toBe("submittedDate");
expect(lastCallParams?.order).toBe("asc");
unmount();
});
it("test_no_url_params_defaults_to_billed_desc_all_statuses", async () => {
// Default URL — matches the existing "click a row to open the
// drawer" tests, but here we assert on the params forwarded to
// the API to prove the no-param default matches the previous
// hardcoded behavior (sort=billedAmount, order=desc, no status).
const { unmount } = renderClaimsAt(["/claims"]);
await settle(
() => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
);
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
const lastCallParams = calls[calls.length - 1]?.[0] as
| { status?: string; sort?: string; order?: string }
| undefined;
expect(lastCallParams?.status).toBeUndefined();
expect(lastCallParams?.sort).toBe("billedAmount");
expect(lastCallParams?.order).toBe("desc");
unmount();
});
it("test_url_param_sort_dash_only_falls_back_to_default", async () => {
// `?sort=-` is the prefix-with-no-field degenerate case. A naive
// `slice(1)` would yield "" and the API would get `sort=""`.
// We assert the page falls back to the default sort instead.
const { unmount } = renderClaimsAt(["/claims?sort=-"]);
await settle(
() => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
);
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
const lastCallParams = calls[calls.length - 1]?.[0] as
| { status?: string; sort?: string; order?: string }
| undefined;
expect(lastCallParams?.sort).toBe("billedAmount");
expect(lastCallParams?.order).toBe("desc");
unmount();
});
});
+86 -4
View File
@@ -1,4 +1,5 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
@@ -48,14 +49,80 @@ const STATUS_OPTIONS: FilterChipOption[] = [
{ value: "pending", label: "Pending" },
];
// Whitelist of valid `?status=` values — anything else falls back to ALL.
// Used both to validate the URL param on read AND to guard the Dropdown
// onValueChange so the URL never carries a bogus value.
const VALID_STATUSES: ReadonlySet<ClaimStatus> = new Set<ClaimStatus>([
"draft",
"submitted",
"accepted",
"denied",
"paid",
"pending",
]);
export function Claims() {
const [status, setStatus] = useState<ClaimStatus | typeof ALL>(ALL);
// -------------------------------------------------------------------------
// URL-driven filter state.
//
// The page reads `?status=` and `?sort=` off the URL (via React Router's
// useSearchParams) so deep links from the Dashboard KPI tiles — e.g.
// navigate("/claims?status=denied") — actually filter, not just land on
// a default view. Status and sort/order derive from the URL; pagination
// (`?page=`) and search (`?q=`) stay local because they're ephemeral
// view state that doesn't deserve a URL slot.
//
// Sort syntax: `?sort=billedAmount` → asc, `?sort=-billedAmount` → desc.
// The leading `-` is the conventional "negate" prefix used by most
// list APIs (incl. our own /api/claims), so we mirror it instead of
// using a separate `?order=` param.
// -------------------------------------------------------------------------
const [searchParams, setSearchParams] = useSearchParams();
const rawStatus = searchParams.get("status");
const status: ClaimStatus | typeof ALL =
rawStatus && VALID_STATUSES.has(rawStatus as ClaimStatus)
? (rawStatus as ClaimStatus)
: ALL;
const rawSort = searchParams.get("sort");
let sort: string;
let order: "asc" | "desc";
if (rawSort && rawSort.length > 1 && rawSort.startsWith("-")) {
// Strip the `-` desc-marker, but only when there's an actual field
// after it. `?sort=-` (prefix only, no field) falls through to the
// default below — otherwise `slice(1)` would yield `""` and we'd
// hit the API with `sort=""`.
sort = rawSort.slice(1);
order = "desc";
} else if (rawSort && rawSort !== "-") {
sort = rawSort;
order = "asc";
} else {
// Default — match the previous hardcoded behavior so existing
// bookmarks and the "no params" path keep sorting by billed desc.
// Also catches `?sort=-` (no field after the prefix) and any other
// degenerate value.
sort = "billedAmount";
order = "desc";
}
const [npi, setNpi] = useState<string>(ALL);
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [helpOpen, setHelpOpen] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
// Reset pagination whenever the URL-driven filters change. Without
// this, a deep link like `/claims?status=denied` lands on whatever
// page the user was last on (e.g. page 5 of an unfiltered list) and
// shows an empty view. Safe to run on every render of `status` or
// `sort` because `setPage(1)` is idempotent when already at 1, and
// changing `page` doesn't trigger this effect.
useEffect(() => {
setPage(1);
}, [status, sort]);
const { claimId, open, close, setClaimId } = useDrawerUrlState();
const providers = useAppStore((s) => s.providers);
@@ -64,11 +131,26 @@ export function Claims() {
[providers]
);
// Drop the `status` query param entirely when going back to "all", so
// the URL stays clean for sharing. Caller still owns `setPage(1)` to
// reset pagination.
const setStatus = (next: ClaimStatus | typeof ALL): void => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
if (next === ALL) params.delete("status");
else params.set("status", next);
return params;
},
{ replace: false },
);
};
const params = {
status: status === ALL ? undefined : status,
provider_npi: npi === ALL ? undefined : npi,
sort: "billedAmount",
order: "desc" as const,
sort,
order,
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
};
+53 -2
View File
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertCircle,
Banknote,
@@ -13,6 +14,7 @@ import { PageHeader } from "@/components/PageHeader";
import { KpiCard } from "@/components/KpiCard";
import { ActivityFeed } from "@/components/ActivityFeed";
import { AnimatedNumber } from "@/components/AnimatedNumber";
import { DrillableCell } from "@/components/drill/DrillableCell";
import { fmt } from "@/lib/format";
import { useAppStore } from "@/store";
@@ -70,6 +72,8 @@ export function Dashboard() {
const providers = useAppStore((s) => s.providers);
const activity = useAppStore((s) => s.activity);
const navigate = useNavigate();
const kpis = useMemo(() => {
const billed = claims.reduce((s, c) => s + c.billedAmount, 0);
const received = claims.reduce((s, c) => s + c.receivedAmount, 0);
@@ -161,6 +165,10 @@ export function Dashboard() {
<section
aria-label="Key performance indicators"
className="grid gap-3.5 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5"
>
<DrillableCell
onClick={() => navigate("/claims")}
ariaLabel="View all claims"
>
<KpiCard
label="Claims"
@@ -173,6 +181,11 @@ export function Dashboard() {
}
hint="last 6 months"
/>
</DrillableCell>
<DrillableCell
onClick={() => navigate("/claims?sort=-billedAmount")}
ariaLabel="View claims sorted by billed amount"
>
<KpiCard
label="Billed"
icon={CircleDollarSign}
@@ -183,6 +196,11 @@ export function Dashboard() {
value={<AnimatedNumber value={kpis.billed} format={fmt.usd} />}
delta={{ value: "+12.4%", direction: "up", positive: true }}
/>
</DrillableCell>
<DrillableCell
onClick={() => navigate("/claims?sort=-receivedAmount")}
ariaLabel="View claims sorted by received amount"
>
<KpiCard
label="Received"
icon={Banknote}
@@ -193,6 +211,11 @@ export function Dashboard() {
value={<AnimatedNumber value={kpis.received} format={fmt.usd} />}
delta={{ value: "+8.1%", direction: "up", positive: true }}
/>
</DrillableCell>
<DrillableCell
onClick={() => navigate("/claims")}
ariaLabel="View pending accounts receivable claims"
>
<KpiCard
label="Pending AR"
icon={Clock}
@@ -208,6 +231,11 @@ export function Dashboard() {
}
hint={`${kpis.pending} in queue`}
/>
</DrillableCell>
<DrillableCell
onClick={() => navigate("/claims?status=denied")}
ariaLabel="View denied claims"
>
<KpiCard
label="Denial rate"
icon={TrendingDown}
@@ -223,6 +251,7 @@ export function Dashboard() {
}
delta={{ value: "-1.2 pts", direction: "down", positive: true }}
/>
</DrillableCell>
</section>
{/* Activity + Top providers */}
@@ -260,7 +289,22 @@ export function Dashboard() {
<CardContent className="pt-0">
<ul className="space-y-4">
{topProviders.map((p, i) => (
<li key={p.npi} className="flex items-center gap-3">
<li
key={p.npi}
onClick={() =>
navigate(`/providers?provider=${encodeURIComponent(p.npi)}`)
}
onKeyDown={(e) => {
if (e.key === "Enter")
navigate(
`/providers?provider=${encodeURIComponent(p.npi)}`
);
}}
role="button"
tabIndex={0}
aria-label={`View provider ${p.name}`}
className="drillable flex items-center gap-3"
>
<div className="h-7 w-7 rounded-md bg-muted/60 ring-1 ring-inset ring-border/40 flex items-center justify-center mono text-[10.5px] text-muted-foreground">
{String(i + 1).padStart(2, "0")}
</div>
@@ -306,7 +350,14 @@ export function Dashboard() {
{topDenials.map((c) => (
<li
key={c.id}
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
onClick={() => navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}
onKeyDown={(e) => {
if (e.key === "Enter") navigate(`/claims?claim=${encodeURIComponent(c.id)}`);
}}
role="button"
tabIndex={0}
aria-label={`View claim ${c.id}`}
className="drillable flex items-start gap-3 py-3 first:pt-0 last:pb-0"
>
<div className="display mono text-[12.5px] text-muted-foreground pt-0.5 w-24 shrink-0">
{c.id}
+250 -7
View File
@@ -13,6 +13,7 @@
// ---------------------------------------------------------------------------
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Lane, type LaneRow } from "@/components/inbox/Lane";
import { InboxHeader } from "@/components/inbox/InboxHeader";
import { BulkBar } from "@/components/inbox/BulkBar";
@@ -160,8 +161,13 @@ export default function Inbox() {
if (loading) {
return (
<div
className="min-h-screen p-8 mono flex items-center gap-2"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink-dim)", fontSize: 12 }}
className="min-h-screen"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
>
<InboxHeader needEyesCount={0} doneTodayCount={0} />
<div
className="px-6 lg:px-10 py-10 mono flex items-center gap-2"
style={{ color: "var(--tt-ink-dim)", fontSize: 12 }}
>
<span
aria-hidden
@@ -170,20 +176,30 @@ export default function Inbox() {
/>
loading inbox
</div>
</div>
);
}
if (error) {
return (
<div
className="min-h-screen p-8 mono flex flex-col gap-2"
style={{ background: "var(--tt-bg)", color: "var(--tt-oxblood)", fontSize: 12 }}
className="min-h-screen"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
>
<InboxHeader needEyesCount={0} doneTodayCount={0} />
<div
className="px-6 lg:px-10 py-10 mono flex flex-col gap-2"
style={{ color: "var(--tt-oxblood)", fontSize: 12 }}
>
<span
className="uppercase tracking-[0.18em]"
style={{ fontSize: 10, fontWeight: 700 }}
>
<span className="uppercase tracking-[0.18em]" style={{ fontSize: 10, fontWeight: 700 }}>
Connection error
</span>
<span>{error.message}</span>
</div>
</div>
);
}
@@ -203,7 +219,53 @@ export default function Inbox() {
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
>
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
<main className="px-6 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
{/* Fold a thin amber rule + italic serif annotation that
transitions the editorial header into the lane grid below. */}
<div
aria-hidden
className="relative h-10 flex items-center"
style={{ background: "var(--tt-bg)" }}
>
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
style={{
background:
"linear-gradient(to right, transparent, var(--tt-amber) 12%, var(--tt-amber) 88%, transparent)",
opacity: 0.35,
}}
/>
<div
className="relative z-10 mx-auto px-3 flex items-center gap-2"
style={{ background: "var(--tt-bg)" }}
>
<span
className="display italic"
style={{ color: "var(--tt-amber)", fontSize: 14 }}
>
</span>
<span
className="mono uppercase"
style={{
color: "var(--tt-ink-dim)",
fontSize: 10.5,
letterSpacing: "0.24em",
fontWeight: 500,
}}
>
Five lanes · one queue
</span>
<span
className="display italic"
style={{ color: "var(--tt-amber)", fontSize: 14 }}
>
</span>
</div>
</div>
<main className="px-6 lg:px-10 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
<Lane
name="REJECTED"
accent="oxblood"
@@ -247,6 +309,103 @@ export default function Inbox() {
/>
</main>
{/* ----------------------------------------------------------------
PAPER-TONED SUMMARY STRIP
A cream "ledger" panel below the lanes that shows the aggregate
eye-flow across the queue. Mirrors the "Magazine Spread" pattern
used on the Dashboard/Claims/etc pages so the Inbox feels part
of the same document set, not a stand-alone terminal.
---------------------------------------------------------------- */}
<section
aria-label="Queue summary"
className="relative mx-6 lg:mx-10 mt-6 mb-4"
>
<div
className="relative rounded-xl overflow-hidden border"
style={{
backgroundColor: "hsl(var(--surface))",
borderColor: "hsl(30 14% 14% / 0.10)",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 12px 40px -16px hsl(0 0% 0% / 0.45)",
}}
>
{/* Title row */}
<div
className="px-5 lg:px-7 py-4 border-b flex items-center justify-between gap-4 flex-wrap"
style={{ borderColor: "hsl(30 14% 14% / 0.10)" }}
>
<div>
<div
className="mono text-[10.5px] uppercase tracking-[0.20em] font-semibold mb-1.5"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Queue ledger
</div>
<h2
className="display leading-[0.95] tracking-[-0.03em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(22px, 2.2vw, 28px)",
fontWeight: 400,
}}
>
The eye-flow, <span className="italic">at a glance.</span>
</h2>
</div>
<div
className="text-right"
aria-label="Aggregate metrics"
>
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Need eyes
</div>
<div
className="display tabular-nums"
style={{
color: "hsl(var(--surface-ink))",
fontSize: 24,
lineHeight: 1.1,
}}
>
{needEyes}
</div>
</div>
</div>
{/* Lane metrics row — paper tiles that mirror each lane's accent */}
<div className="grid grid-cols-2 lg:grid-cols-5 divide-x divide-y lg:divide-y-0" style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}>
<SummaryTile
label="Rejected"
value={lanes.rejected.length}
tone="oxblood"
/>
<SummaryTile
label="Payer rejected"
value={lanes.payer_rejected.length}
tone="oxblood"
/>
<SummaryTile
label="Candidates"
value={lanes.candidates.length}
tone="amber"
/>
<SummaryTile
label="Unmatched"
value={lanes.unmatched.length}
tone="ink"
/>
<SummaryTile
label="Done today"
value={lanes.done_today.length}
tone="muted"
/>
</div>
</div>
</section>
{/* Per-lane bulk bars. Each shows when its lane has a selection. */}
<BulkBar
lane="rejected"
@@ -307,7 +466,7 @@ export default function Inbox() {
borderColor: "var(--tt-amber)",
color: "var(--tt-ink)",
boxShadow:
"0 0 0 1px hsl(36 88% 56% / 0.18), 0 24px 60px -12px hsl(0 0% 0% / 0.7), inset 0 1px 0 0 hsl(0 0% 100% / 0.04)",
"0 0 0 1px hsl(36 75% 58% / 0.18), 0 24px 60px -12px hsl(0 0% 0% / 0.5), inset 0 1px 0 0 hsl(0 0% 100% / 0.03)",
}}
>
<div className="flex items-center gap-2 mb-3">
@@ -404,3 +563,87 @@ export default function Inbox() {
</div>
);
}
// ---------------------------------------------------------------------------
// SummaryTile — paper-toned metric for the queue ledger strip. Mirrors
// the lane accent (oxblood / amber / ink-blue / muted) so the eye-flow
// reads at a glance.
// ---------------------------------------------------------------------------
const SUMMARY_ACCENTS: Record<
"oxblood" | "amber" | "ink" | "muted",
{ dot: string; tint: string; text: string }
> = {
oxblood: {
dot: "hsl(358 70% 42%)",
tint: "hsl(358 70% 92%)",
text: "hsl(358 70% 30%)",
},
amber: {
dot: "hsl(36 92% 50%)",
tint: "hsl(36 82% 88%)",
text: "hsl(36 92% 30%)",
},
ink: {
dot: "hsl(212 80% 45%)",
tint: "hsl(212 85% 92%)",
text: "hsl(212 80% 30%)",
},
muted: {
dot: "hsl(30 8% 50%)",
tint: "hsl(36 22% 90%)",
text: "hsl(30 8% 30%)",
},
};
function SummaryTile({
label,
value,
tone,
}: {
label: string;
value: number;
tone: "oxblood" | "amber" | "ink" | "muted";
}) {
const a = SUMMARY_ACCENTS[tone];
return (
<div
className={cn(
"relative px-4 py-4 first:pl-5 lg:first:pl-7 last:pr-5 lg:last:pr-7"
)}
>
<div className="flex items-center gap-2 mb-2">
<span
aria-hidden
className="block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: a.dot }}
/>
<span
className="mono text-[10px] uppercase tracking-[0.20em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{label}
</span>
</div>
<div
className="display tabular-nums tracking-[-0.03em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(24px, 2vw, 30px)",
lineHeight: 1,
fontWeight: 400,
}}
>
{value}
</div>
<div
className="mono text-[10px] uppercase tracking-[0.16em] mt-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm"
style={{
color: a.text,
backgroundColor: a.tint,
}}
>
{value === 0 ? "Clear" : value === 1 ? "row" : "rows"}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+822 -125
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -50,6 +50,58 @@ export interface Provider {
phone: string;
claimCount: number;
outstandingAr: number;
/**
* SP21 Task 1.6: populated by the extended `GET /api/config/providers/{npi}`
* endpoint. Top 10 claims for this provider by `submissionDate` desc.
* Optional so legacy callers (in-memory sample data) keep working
* without an API round-trip.
*/
recent_claims?: ClaimSummary[];
/**
* SP21 Task 1.6: populated by the extended `GET /api/config/providers/{npi}`
* endpoint. Top 10 activity events (by `ts` desc) joined to claims
* owned by this provider via `claim_id`.
*/
recent_activity?: ActivityEvent[];
}
/**
* SP21 Task 1.6: slim claim projection returned by
* `GET /api/config/providers/{npi}.recent_claims`. Mirrors the wire
* shape of `store.iter_claims(provider_npi=...)` 1:1 (camelCase,
* superset of the legacy `Claim` interface).
*/
export interface ClaimSummary {
id: string;
state: string;
billedAmount: number;
patientName: string;
providerNpi: string;
payerName: string;
cptCode: string;
submissionDate: string;
parsedAt: string;
status: string;
matchedRemittanceId?: string | null;
batchId: string;
receivedAmount?: number;
denialReason?: string | null;
}
/**
* SP21 Task 1.6: one row in `recent_activity`. Mirrors the Python
* ORM `ActivityEvent` (snake_case fields rewritten to camelCase for
* the wire). `payload` carries the same dict the recorder wrote at
* the time of the event (message, npi, amount, etc.).
*/
export interface ActivityEvent {
id: number;
ts: string; // ISO Z
kind: string;
batchId: string | null;
claimId: string | null;
remittanceId: string | null;
payload: Record<string, unknown>;
}
export interface Remittance {