Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7db5e448cd | |||
| 5d6b5894f3 | |||
| 88da3a6246 | |||
| 50dc0b2fb3 | |||
| e6ae364dad | |||
| 50a454db59 | |||
| 9129543f5d | |||
| fb7a5c6d2e | |||
| 7dd6a5d025 | |||
| be93dbff72 | |||
| feb15bf48d | |||
| ff0985d244 | |||
| 94990932f9 | |||
| 9133baa070 | |||
| d0a18bd796 | |||
| 646d00adde | |||
| f6f821e082 | |||
| 3e00fb3f63 | |||
| 2faf7bfd48 | |||
| 73be586110 | |||
| d7f37f845a | |||
| df10b55a34 | |||
| 6300280142 | |||
| fff000ed2e | |||
| 134eb4f404 | |||
| 022b229d81 |
+189
-1
@@ -22,6 +22,7 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from time import monotonic
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
||||||
@@ -31,6 +32,7 @@ from pydantic import ValidationError
|
|||||||
|
|
||||||
from cyclone import __version__, db
|
from cyclone import __version__, db
|
||||||
from cyclone.db import Claim, ClaimState, Remittance
|
from cyclone.db import Claim, ClaimState, Remittance
|
||||||
|
from sqlalchemy import desc, or_
|
||||||
from cyclone.inbox_state import apply_999_rejections
|
from cyclone.inbox_state import apply_999_rejections
|
||||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||||
from cyclone.audit_log import AuditEvent, append_event, verify_chain
|
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)
|
p = store.get_provider(npi)
|
||||||
if p is None:
|
if p is None:
|
||||||
raise HTTPException(status_code=404, detail=f"provider {npi!r} not found")
|
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")
|
@app.get("/api/config/payers")
|
||||||
@@ -2958,6 +3026,126 @@ def list_payer_configs(payer_id: str):
|
|||||||
return configs
|
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")
|
@app.post("/api/admin/reload-config")
|
||||||
def reload_config():
|
def reload_config():
|
||||||
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
|
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,7 @@
|
|||||||
import { Route, Routes } from "react-router-dom";
|
import { Route, Routes } from "react-router-dom";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { Layout } from "@/components/Layout";
|
import { Layout } from "@/components/Layout";
|
||||||
|
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||||
import { Dashboard } from "@/pages/Dashboard";
|
import { Dashboard } from "@/pages/Dashboard";
|
||||||
import { Claims } from "@/pages/Claims";
|
import { Claims } from "@/pages/Claims";
|
||||||
import { Remittances } from "@/pages/Remittances";
|
import { Remittances } from "@/pages/Remittances";
|
||||||
@@ -24,7 +25,7 @@ function NotFound() {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<DrillStackProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route index element={<Dashboard />} />
|
<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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` (0–1) 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// InboxHeader
|
// InboxHeader
|
||||||
//
|
//
|
||||||
// Compact working-surface header: the day/date, a live clock, and the
|
// Editorial dark hero for the Inbox. Larger than the previous 30px title
|
||||||
// two top-line counts ("N items need eyes" and "N done today") that
|
// so it carries weight against the bright lane surface below. "Inbox."
|
||||||
// anchor the operator's day. "Need eyes" is computed by the Inbox
|
// is the page's anchor, the day/date sits as a serif italic, and the
|
||||||
// page (sum of actionable lanes) and passed in.
|
// "N items need eyes / N done today" line replaces the small status
|
||||||
//
|
// pulse with a more dramatic mono announcement.
|
||||||
// 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.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { fmt } from "@/lib/format";
|
||||||
|
|
||||||
export function InboxHeader({
|
export function InboxHeader({
|
||||||
needEyesCount,
|
needEyesCount,
|
||||||
doneTodayCount,
|
doneTodayCount,
|
||||||
@@ -32,66 +31,152 @@ export function InboxHeader({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<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={{
|
style={{
|
||||||
background: "var(--tt-bg)",
|
background: "var(--tt-bg)",
|
||||||
borderBottom: "1px solid var(--tt-bg-elev)",
|
borderBottom: "1px solid var(--tt-bg-elev)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline justify-between gap-4">
|
<div className="relative">
|
||||||
<div className="flex items-baseline gap-4">
|
{/* Ghost "TRIAGE" watermark — a print-shop stamp behind the title. */}
|
||||||
<h1
|
<div
|
||||||
className="display tracking-tight"
|
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={{
|
style={{
|
||||||
color: "var(--tt-ink)",
|
fontSize: "clamp(120px, 18vw, 260px)",
|
||||||
fontSize: 30,
|
letterSpacing: "-0.05em",
|
||||||
|
opacity: 0.05,
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
|
color: "var(--tt-amber)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Inbox
|
TRIAGE
|
||||||
</h1>
|
</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
|
<span
|
||||||
className="mono uppercase"
|
className="mono uppercase"
|
||||||
style={{
|
style={{
|
||||||
color: "var(--tt-amber)",
|
color: "var(--tt-amber)",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
letterSpacing: "0.18em",
|
letterSpacing: "0.22em",
|
||||||
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
· {day} {date}
|
Inbox · Working surface
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<h1
|
||||||
className="mono tabular-nums"
|
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={{
|
style={{
|
||||||
color: "var(--tt-ink-dim)",
|
color: "var(--tt-ink-dim)",
|
||||||
fontSize: 12,
|
fontSize: "clamp(15px, 1.4vw, 19px)",
|
||||||
letterSpacing: "0.05em",
|
lineHeight: 1.4,
|
||||||
|
maxWidth: "44ch",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{time}
|
Five lanes, one queue.{" "}
|
||||||
</span>
|
<span style={{ color: "var(--tt-amber)" }}>
|
||||||
|
{needEyesCount}
|
||||||
|
</span>{" "}
|
||||||
|
need eyes; {doneTodayCount} are done today.
|
||||||
|
</p>
|
||||||
</div>
|
</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={{
|
style={{
|
||||||
color: "var(--tt-ink-dim)",
|
color: "var(--tt-ink-dim)",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
letterSpacing: "0.08em",
|
letterSpacing: "0.22em",
|
||||||
textTransform: "uppercase",
|
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
|
<span
|
||||||
|
aria-hidden
|
||||||
className="inline-block w-1.5 h-1.5 rounded-full animate-pulse-dot"
|
className="inline-block w-1.5 h-1.5 rounded-full animate-pulse-dot"
|
||||||
style={{ background: "var(--tt-amber)" }}
|
style={{ background: "var(--tt-amber)" }}
|
||||||
aria-hidden
|
|
||||||
/>
|
/>
|
||||||
<span style={{ color: "var(--tt-amber)", fontWeight: 600 }}>{needEyesCount}</span>
|
<span>Live</span>
|
||||||
items need eyes
|
</div>
|
||||||
<span className="opacity-50">·</span>
|
<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
|
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>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -413,3 +413,18 @@
|
|||||||
animation-duration: 0s !important;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -586,6 +586,39 @@ async function listProviders<T = unknown>(
|
|||||||
return (await res.json()) as PaginatedResponse<T>;
|
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>(
|
async function listActivity<T = unknown>(
|
||||||
params: ListActivityParams = {}
|
params: ListActivityParams = {}
|
||||||
): Promise<PaginatedResponse<T>> {
|
): Promise<PaginatedResponse<T>> {
|
||||||
@@ -740,6 +773,7 @@ export const api = {
|
|||||||
listRemittances,
|
listRemittances,
|
||||||
getRemittance,
|
getRemittance,
|
||||||
listProviders,
|
listProviders,
|
||||||
|
getPayerSummary,
|
||||||
listActivity,
|
listActivity,
|
||||||
listUnmatched,
|
listUnmatched,
|
||||||
matchRemit,
|
matchRemit,
|
||||||
|
|||||||
+641
-81
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { CheckCircle2, Download } from "lucide-react";
|
import { CheckCircle2, Download, ShieldCheck } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -27,18 +27,30 @@ import type { Ack } from "@/types";
|
|||||||
function AckCodeBadge({ code }: { code: Ack["ackCode"] }) {
|
function AckCodeBadge({ code }: { code: Ack["ackCode"] }) {
|
||||||
const color =
|
const color =
|
||||||
code === "A"
|
code === "A"
|
||||||
? "text-emerald-400 border-emerald-400/30 bg-emerald-400/10"
|
? {
|
||||||
: code === "E"
|
text: "hsl(152 64% 30%)",
|
||||||
? "text-amber-400 border-amber-400/30 bg-amber-400/10"
|
bg: "hsl(152 50% 88%)",
|
||||||
: code === "P"
|
border: "hsl(152 64% 38% / 0.30)",
|
||||||
? "text-amber-400 border-amber-400/30 bg-amber-400/10"
|
}
|
||||||
: "text-red-400 border-red-400/30 bg-red-400/10";
|
: 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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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]"
|
||||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10.5px] font-semibold uppercase tracking-[0.14em]",
|
style={{
|
||||||
color,
|
color: color.text,
|
||||||
)}
|
backgroundColor: color.bg,
|
||||||
|
borderColor: color.border,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{code}
|
{code}
|
||||||
</span>
|
</span>
|
||||||
@@ -62,26 +74,15 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const detail = await api.getAck(id);
|
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 =
|
const raw =
|
||||||
(detail as unknown as { raw_999_text?: string }).raw_999_text ??
|
(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 "";
|
return "";
|
||||||
})();
|
})();
|
||||||
if (raw) {
|
if (raw) {
|
||||||
downloadBlob(`ack-${sourceBatchId}.999`, raw);
|
downloadBlob(`ack-${sourceBatchId}.999`, raw);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Swallow — the operator can retry. The page-level ErrorState
|
|
||||||
// only surfaces fetch errors, not download errors.
|
|
||||||
console.error("download 999 failed", err);
|
console.error("download 999 failed", err);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -93,10 +94,14 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2 py-1 text-[11.5px] font-medium",
|
"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",
|
||||||
"hover:bg-muted/40 transition-colors",
|
|
||||||
busy && "opacity-50 cursor-not-allowed",
|
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"
|
aria-label="Download 999"
|
||||||
>
|
>
|
||||||
<Download className="h-3 w-3" strokeWidth={1.75} />
|
<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 { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 });
|
||||||
const items = data?.items ?? [];
|
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 [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
|
||||||
@@ -129,18 +129,10 @@ export function Acks() {
|
|||||||
setSelectedIndex((i) => {
|
setSelectedIndex((i) => {
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
if (i === null) return items.length - 1;
|
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;
|
return (i - 1 + items.length) % 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({
|
useRowKeyboard({
|
||||||
enabled: !helpOpen && items.length > 0,
|
enabled: !helpOpen && items.length > 0,
|
||||||
onNext: moveNext,
|
onNext: moveNext,
|
||||||
@@ -149,60 +141,504 @@ export function Acks() {
|
|||||||
onToggleHelp: () => setHelpOpen((v) => !v),
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<KeyboardCheatsheet
|
<KeyboardCheatsheet
|
||||||
open={helpOpen}
|
open={helpOpen}
|
||||||
onClose={() => setHelpOpen(false)}
|
onClose={() => setHelpOpen(false)}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-8 animate-fade-in">
|
<div className="space-y-0">
|
||||||
<header>
|
{/* =================================================================
|
||||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
HERO — DARK EDITORIAL HEADER
|
||||||
<span className="inline-block h-px w-6 bg-border" />
|
================================================================= */}
|
||||||
999 ACKs
|
<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>
|
</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>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
</div>
|
||||||
999 ACK transaction sets — generated automatically in response to
|
<div className="flex flex-col items-start lg:items-end gap-3">
|
||||||
837P ingests, or parsed from inbound 999 uploads.
|
<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>
|
</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 ? (
|
{isError ? (
|
||||||
|
<div
|
||||||
|
className="rounded-md border p-5"
|
||||||
|
style={{
|
||||||
|
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||||
|
backgroundColor: "hsl(36 22% 96%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="Couldn't load ACKs from the backend."
|
message="Couldn't load ACKs from the backend."
|
||||||
detail={error instanceof Error ? error.message : String(error)}
|
detail={error instanceof Error ? error.message : String(error)}
|
||||||
onRetry={() => refetch()}
|
onRetry={() => refetch()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
<div className="surface rounded-xl overflow-hidden">
|
<div
|
||||||
{isLoading ? (
|
className="rounded-md border p-4 space-y-2"
|
||||||
<div className="p-4 space-y-2">
|
style={{
|
||||||
|
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||||
|
backgroundColor: "hsl(36 22% 96%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<Skeleton key={i} variant="row" />
|
<Skeleton key={i} variant="row" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : 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
|
<EmptyState
|
||||||
eyebrow="999 ACKs · awaiting first 837 with ack=true"
|
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."
|
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>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-12" aria-label="Status" />
|
<TableHead
|
||||||
<TableHead>ID</TableHead>
|
className="w-12"
|
||||||
<TableHead>Source Batch</TableHead>
|
aria-label="Status"
|
||||||
<TableHead className="text-right">Accepted</TableHead>
|
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||||
<TableHead className="text-right">Rejected</TableHead>
|
/>
|
||||||
<TableHead className="text-right">Received</TableHead>
|
<TableHead style={{ color: "hsl(var(--surface-ink-2))" }}>
|
||||||
<TableHead>ACK Code</TableHead>
|
ID
|
||||||
<TableHead>Parsed</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-20" aria-label="Download" />
|
<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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -216,58 +652,182 @@ export function Acks() {
|
|||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
className={cn(
|
className={cn(
|
||||||
isSelected && [
|
isSelected && [
|
||||||
// Accent-tinted bg + ring + left strip make the
|
"ring-1 ring-inset",
|
||||||
// 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))]",
|
|
||||||
],
|
],
|
||||||
)}
|
)}
|
||||||
|
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
|
<CheckCircle2
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-3.5 w-3.5",
|
"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}
|
strokeWidth={1.75}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="display num text-[13px]">{a.id}</TableCell>
|
<TableCell
|
||||||
<TableCell className="font-mono text-[12px] text-muted-foreground truncate max-w-[180px]">
|
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}
|
{a.sourceBatchId}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right display num text-emerald-400">
|
<TableCell
|
||||||
|
className="text-right display num"
|
||||||
|
style={{ color: "hsl(152 64% 32%)" }}
|
||||||
|
>
|
||||||
{a.acceptedCount}
|
{a.acceptedCount}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right display num text-red-400">
|
<TableCell
|
||||||
|
className="text-right display num"
|
||||||
|
style={{ color: "hsl(358 70% 38%)" }}
|
||||||
|
>
|
||||||
{a.rejectedCount}
|
{a.rejectedCount}
|
||||||
</TableCell>
|
</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}
|
{a.receivedCount}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<AckCodeBadge code={a.ackCode} />
|
<AckCodeBadge code={a.ackCode} />
|
||||||
</TableCell>
|
</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) : "—"}
|
{a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<DownloadButton id={a.id} sourceBatchId={a.sourceBatchId} />
|
<DownloadButton
|
||||||
|
id={a.id}
|
||||||
|
sourceBatchId={a.sourceBatchId}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</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
@@ -8,6 +8,7 @@ import React, { act } from "react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Claims } from "./Claims";
|
import { Claims } from "./Claims";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useTailStore } from "@/store/tail-store";
|
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
|
* a real DOM container wrapped in `QueryClientProvider` so the page's
|
||||||
* TanStack Query calls (via `useClaims`) resolve against our mocked
|
* TanStack Query calls (via `useClaims`) resolve against our mocked
|
||||||
* `api.listClaims`.
|
* `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 } {
|
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");
|
const container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
const qc = new QueryClient({
|
const qc = new QueryClient({
|
||||||
@@ -168,11 +191,16 @@ function renderClaims(): { unmount: () => void } {
|
|||||||
React.createElement(
|
React.createElement(
|
||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
{ client: qc },
|
{ client: qc },
|
||||||
React.createElement(Claims)
|
React.createElement(
|
||||||
)
|
MemoryRouter,
|
||||||
|
{ initialEntries },
|
||||||
|
React.createElement(Claims),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
container,
|
||||||
unmount: () => {
|
unmount: () => {
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
container.remove();
|
container.remove();
|
||||||
@@ -531,4 +559,144 @@ describe("Claims page drawer wiring", () => {
|
|||||||
|
|
||||||
unmount();
|
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
@@ -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 { Search, X } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -48,14 +49,80 @@ const STATUS_OPTIONS: FilterChipOption[] = [
|
|||||||
{ value: "pending", label: "Pending" },
|
{ 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() {
|
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 [npi, setNpi] = useState<string>(ALL);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
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 { claimId, open, close, setClaimId } = useDrawerUrlState();
|
||||||
|
|
||||||
const providers = useAppStore((s) => s.providers);
|
const providers = useAppStore((s) => s.providers);
|
||||||
@@ -64,11 +131,26 @@ export function Claims() {
|
|||||||
[providers]
|
[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 = {
|
const params = {
|
||||||
status: status === ALL ? undefined : status,
|
status: status === ALL ? undefined : status,
|
||||||
provider_npi: npi === ALL ? undefined : npi,
|
provider_npi: npi === ALL ? undefined : npi,
|
||||||
sort: "billedAmount",
|
sort,
|
||||||
order: "desc" as const,
|
order,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset: (page - 1) * PAGE_SIZE,
|
offset: (page - 1) * PAGE_SIZE,
|
||||||
};
|
};
|
||||||
|
|||||||
+53
-2
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Banknote,
|
Banknote,
|
||||||
@@ -13,6 +14,7 @@ import { PageHeader } from "@/components/PageHeader";
|
|||||||
import { KpiCard } from "@/components/KpiCard";
|
import { KpiCard } from "@/components/KpiCard";
|
||||||
import { ActivityFeed } from "@/components/ActivityFeed";
|
import { ActivityFeed } from "@/components/ActivityFeed";
|
||||||
import { AnimatedNumber } from "@/components/AnimatedNumber";
|
import { AnimatedNumber } from "@/components/AnimatedNumber";
|
||||||
|
import { DrillableCell } from "@/components/drill/DrillableCell";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { useAppStore } from "@/store";
|
import { useAppStore } from "@/store";
|
||||||
|
|
||||||
@@ -70,6 +72,8 @@ export function Dashboard() {
|
|||||||
const providers = useAppStore((s) => s.providers);
|
const providers = useAppStore((s) => s.providers);
|
||||||
const activity = useAppStore((s) => s.activity);
|
const activity = useAppStore((s) => s.activity);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const kpis = useMemo(() => {
|
const kpis = useMemo(() => {
|
||||||
const billed = claims.reduce((s, c) => s + c.billedAmount, 0);
|
const billed = claims.reduce((s, c) => s + c.billedAmount, 0);
|
||||||
const received = claims.reduce((s, c) => s + c.receivedAmount, 0);
|
const received = claims.reduce((s, c) => s + c.receivedAmount, 0);
|
||||||
@@ -161,6 +165,10 @@ export function Dashboard() {
|
|||||||
<section
|
<section
|
||||||
aria-label="Key performance indicators"
|
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"
|
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
|
<KpiCard
|
||||||
label="Claims"
|
label="Claims"
|
||||||
@@ -173,6 +181,11 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
hint="last 6 months"
|
hint="last 6 months"
|
||||||
/>
|
/>
|
||||||
|
</DrillableCell>
|
||||||
|
<DrillableCell
|
||||||
|
onClick={() => navigate("/claims?sort=-billedAmount")}
|
||||||
|
ariaLabel="View claims sorted by billed amount"
|
||||||
|
>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Billed"
|
label="Billed"
|
||||||
icon={CircleDollarSign}
|
icon={CircleDollarSign}
|
||||||
@@ -183,6 +196,11 @@ export function Dashboard() {
|
|||||||
value={<AnimatedNumber value={kpis.billed} format={fmt.usd} />}
|
value={<AnimatedNumber value={kpis.billed} format={fmt.usd} />}
|
||||||
delta={{ value: "+12.4%", direction: "up", positive: true }}
|
delta={{ value: "+12.4%", direction: "up", positive: true }}
|
||||||
/>
|
/>
|
||||||
|
</DrillableCell>
|
||||||
|
<DrillableCell
|
||||||
|
onClick={() => navigate("/claims?sort=-receivedAmount")}
|
||||||
|
ariaLabel="View claims sorted by received amount"
|
||||||
|
>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Received"
|
label="Received"
|
||||||
icon={Banknote}
|
icon={Banknote}
|
||||||
@@ -193,6 +211,11 @@ export function Dashboard() {
|
|||||||
value={<AnimatedNumber value={kpis.received} format={fmt.usd} />}
|
value={<AnimatedNumber value={kpis.received} format={fmt.usd} />}
|
||||||
delta={{ value: "+8.1%", direction: "up", positive: true }}
|
delta={{ value: "+8.1%", direction: "up", positive: true }}
|
||||||
/>
|
/>
|
||||||
|
</DrillableCell>
|
||||||
|
<DrillableCell
|
||||||
|
onClick={() => navigate("/claims")}
|
||||||
|
ariaLabel="View pending accounts receivable claims"
|
||||||
|
>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Pending AR"
|
label="Pending AR"
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
@@ -208,6 +231,11 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
hint={`${kpis.pending} in queue`}
|
hint={`${kpis.pending} in queue`}
|
||||||
/>
|
/>
|
||||||
|
</DrillableCell>
|
||||||
|
<DrillableCell
|
||||||
|
onClick={() => navigate("/claims?status=denied")}
|
||||||
|
ariaLabel="View denied claims"
|
||||||
|
>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Denial rate"
|
label="Denial rate"
|
||||||
icon={TrendingDown}
|
icon={TrendingDown}
|
||||||
@@ -223,6 +251,7 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
delta={{ value: "-1.2 pts", direction: "down", positive: true }}
|
delta={{ value: "-1.2 pts", direction: "down", positive: true }}
|
||||||
/>
|
/>
|
||||||
|
</DrillableCell>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Activity + Top providers */}
|
{/* Activity + Top providers */}
|
||||||
@@ -260,7 +289,22 @@ export function Dashboard() {
|
|||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
{topProviders.map((p, i) => (
|
{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">
|
<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")}
|
{String(i + 1).padStart(2, "0")}
|
||||||
</div>
|
</div>
|
||||||
@@ -306,7 +350,14 @@ export function Dashboard() {
|
|||||||
{topDenials.map((c) => (
|
{topDenials.map((c) => (
|
||||||
<li
|
<li
|
||||||
key={c.id}
|
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">
|
<div className="display mono text-[12.5px] text-muted-foreground pt-0.5 w-24 shrink-0">
|
||||||
{c.id}
|
{c.id}
|
||||||
|
|||||||
+250
-7
@@ -13,6 +13,7 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { Lane, type LaneRow } from "@/components/inbox/Lane";
|
import { Lane, type LaneRow } from "@/components/inbox/Lane";
|
||||||
import { InboxHeader } from "@/components/inbox/InboxHeader";
|
import { InboxHeader } from "@/components/inbox/InboxHeader";
|
||||||
import { BulkBar } from "@/components/inbox/BulkBar";
|
import { BulkBar } from "@/components/inbox/BulkBar";
|
||||||
@@ -160,8 +161,13 @@ export default function Inbox() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="min-h-screen p-8 mono flex items-center gap-2"
|
className="min-h-screen"
|
||||||
style={{ background: "var(--tt-bg)", color: "var(--tt-ink-dim)", fontSize: 12 }}
|
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
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -170,20 +176,30 @@ export default function Inbox() {
|
|||||||
/>
|
/>
|
||||||
loading inbox…
|
loading inbox…
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="min-h-screen p-8 mono flex flex-col gap-2"
|
className="min-h-screen"
|
||||||
style={{ background: "var(--tt-bg)", color: "var(--tt-oxblood)", fontSize: 12 }}
|
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
|
Connection error
|
||||||
</span>
|
</span>
|
||||||
<span>{error.message}</span>
|
<span>{error.message}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +219,53 @@ export default function Inbox() {
|
|||||||
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
||||||
>
|
>
|
||||||
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
|
<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
|
<Lane
|
||||||
name="REJECTED"
|
name="REJECTED"
|
||||||
accent="oxblood"
|
accent="oxblood"
|
||||||
@@ -247,6 +309,103 @@ export default function Inbox() {
|
|||||||
/>
|
/>
|
||||||
</main>
|
</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. */}
|
{/* Per-lane bulk bars. Each shows when its lane has a selection. */}
|
||||||
<BulkBar
|
<BulkBar
|
||||||
lane="rejected"
|
lane="rejected"
|
||||||
@@ -307,7 +466,7 @@ export default function Inbox() {
|
|||||||
borderColor: "var(--tt-amber)",
|
borderColor: "var(--tt-amber)",
|
||||||
color: "var(--tt-ink)",
|
color: "var(--tt-ink)",
|
||||||
boxShadow:
|
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">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
@@ -404,3 +563,87 @@ export default function Inbox() {
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+892
-78
File diff suppressed because it is too large
Load Diff
+822
-125
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,58 @@ export interface Provider {
|
|||||||
phone: string;
|
phone: string;
|
||||||
claimCount: number;
|
claimCount: number;
|
||||||
outstandingAr: 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 {
|
export interface Remittance {
|
||||||
|
|||||||
Reference in New Issue
Block a user