Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65d98cf674 | |||
| d15c04d983 | |||
| 980627b675 | |||
| 1db6b8841c | |||
| 0678e25de7 | |||
| 5b3b8619e6 | |||
| b6607b2009 | |||
| c0b7924aad | |||
| ebd2834cc4 | |||
| 888c99d848 | |||
| 388ea6e49b | |||
| 736bf4d333 | |||
| 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 |
@@ -51,6 +51,32 @@ VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
Without that, the UI falls back to its in-memory sample store via the
|
||||
existing `data` adapter (parses are disabled).
|
||||
|
||||
## Pipeline automation agent
|
||||
|
||||
For unattended round-trips (an agent / scheduler drops an 837P file
|
||||
into the pipeline and waits for the 999 back from Gainwell), see the
|
||||
`cyclone-pipeline` sibling project at `/Users/openclaw/dev/cyclone-pipeline/`.
|
||||
It drives the full 7-phase state machine — preflight → browser upload →
|
||||
parse verification → SFTP submit → TA1 wait → 999 wait → scan +
|
||||
report — with structured JSON logs, crash-safe resume, and a
|
||||
self-contained per-run folder under `./runs/`.
|
||||
|
||||
```bash
|
||||
# Single file
|
||||
cyclone-pipeline run /path/to/axiscare-837p.txt
|
||||
|
||||
# Resume a crashed run
|
||||
cyclone-pipeline resume 2026-06-21-1430-001
|
||||
|
||||
# On/after the following Monday, verify the 835 arrived
|
||||
cyclone-pipeline check-835 2026-06-21-1430-001
|
||||
```
|
||||
|
||||
The 835 is **not** waited for inline (it lands the following Monday on
|
||||
the CO Medicaid payment cycle). See
|
||||
[`cyclone-pipeline/README.md`](../cyclone-pipeline/README.md) for
|
||||
install, embed-in-agent example, exit codes, and the report format.
|
||||
|
||||
## Skills
|
||||
|
||||
Cyclone ships 8 project-scoped AI-assistant skills under
|
||||
@@ -743,6 +769,17 @@ scope.
|
||||
|
||||
Shipped sub-projects (most recent first):
|
||||
|
||||
- **Sub-project 22 (shipped) — Pipeline automation agent.** A
|
||||
sibling project at `/Users/openclaw/dev/cyclone-pipeline` that
|
||||
drives the full 7-phase round-trip (preflight → browser upload →
|
||||
parse verify → SFTP submit → TA1 wait → 999 wait → scan + report)
|
||||
with crash-safe resume, structured JSON logging, idempotency
|
||||
dedup, and a per-run report. Pure Python 3.11+ (httpx, Playwright,
|
||||
Click, pydantic v2, structlog). The 835 is not waited for inline —
|
||||
it lands the following Monday — and is verified by a separate
|
||||
`check-835` subcommand. Embeddable as a library for OpenClaw / Nora
|
||||
agent integration. See
|
||||
[Pipeline automation agent](#pipeline-automation-agent) above.
|
||||
- **Sub-project 19 (shipped) — Security hardening + health probe.**
|
||||
Three pure-ASGI middlewares (`BodySizeLimitMiddleware`,
|
||||
`RateLimitMiddleware`, `SecurityHeadersMiddleware`) close the
|
||||
|
||||
+278
-7
@@ -9,8 +9,11 @@ The frontend (Vite/React on http://localhost:5173) uploads an X12 file via
|
||||
— one envelope, one line per claim/payout, then one summary — so the
|
||||
UI can render records incrementally as they're produced.
|
||||
|
||||
CORS is configured to allow the Vite dev origin (``http://localhost:5173``)
|
||||
plus GET/POST with any header.
|
||||
CORS is configured to allow the Vite dev origins (``http://localhost:5173``
|
||||
and ``http://127.0.0.1:5173`` — both forms are valid dev-server addresses
|
||||
but CORS treats them as distinct origins) plus GET/POST with any header.
|
||||
Additional origins (LAN IPs, staging hosts) can be appended via the
|
||||
``CYCLONE_ALLOWED_ORIGINS`` env var as a comma-separated list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,9 +22,11 @@ import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from contextlib import asynccontextmanager
|
||||
from time import monotonic
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
||||
@@ -31,6 +36,8 @@ from pydantic import ValidationError
|
||||
|
||||
from cyclone import __version__, db
|
||||
from cyclone.db import Claim, ClaimState, Remittance
|
||||
from sqlalchemy import desc, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from cyclone.inbox_state import apply_999_rejections
|
||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||
from cyclone.audit_log import AuditEvent, append_event, verify_chain
|
||||
@@ -212,7 +219,17 @@ PAYER_FACTORIES_835: dict[str, Any] = {
|
||||
"generic_835": PayerConfig835.generic_835,
|
||||
}
|
||||
|
||||
VITE_DEV_ORIGIN = "http://localhost:5173"
|
||||
# Allow both common dev-server origins. ``localhost`` and ``127.0.0.1``
|
||||
# resolve to the same Vite dev server but CORS treats them as distinct
|
||||
# origins, so the allow-list has to list both — otherwise a tab opened
|
||||
# via the IP form gets blocked even though the dev server is identical.
|
||||
VITE_DEV_ORIGINS: list[str] = [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
]
|
||||
_extra_origins = os.environ.get("CYCLONE_ALLOWED_ORIGINS", "").strip()
|
||||
if _extra_origins:
|
||||
VITE_DEV_ORIGINS.extend(o.strip() for o in _extra_origins.split(",") if o.strip())
|
||||
|
||||
app = FastAPI(
|
||||
title="Cyclone 837P / 835 Parser API",
|
||||
@@ -223,7 +240,7 @@ app = FastAPI(
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[VITE_DEV_ORIGIN],
|
||||
allow_origins=VITE_DEV_ORIGINS,
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
@@ -280,6 +297,35 @@ def _resolve_payer_835(name: str) -> PayerConfig835:
|
||||
return PAYER_FACTORIES_835[name]()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Catch-all exception handler
|
||||
# --------------------------------------------------------------------------- #
|
||||
#
|
||||
# Without this, any exception that escapes a route handler is rendered by
|
||||
# Uvicorn as a bare ``500 Internal Server Error`` text/plain response with
|
||||
# NO CORS headers. Browsers reading such a response report it as a CORS
|
||||
# error (``Origin ... is not allowed by Access-Control-Allow-Origin``)
|
||||
# because they cannot read the body without the CORS allow-origin header
|
||||
# — even though the actual failure was on the server. We catch everything
|
||||
# here, log it, and return a JSON error with the request's Origin echoed
|
||||
# back so the browser can surface the real message.
|
||||
@app.exception_handler(Exception)
|
||||
async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
log.exception(
|
||||
"Unhandled exception in %s %s", request.method, request.url.path
|
||||
)
|
||||
headers: dict[str, str] = {}
|
||||
origin = request.headers.get("origin", "")
|
||||
if origin:
|
||||
headers["Access-Control-Allow-Origin"] = origin
|
||||
headers["Vary"] = "Origin"
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "Internal server error", "detail": str(exc)},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/parse-837")
|
||||
async def parse_837(
|
||||
request: Request,
|
||||
@@ -343,7 +389,30 @@ async def parse_837(
|
||||
parsed_at=utcnow(),
|
||||
result=result,
|
||||
)
|
||||
store.add(rec, event_bus=request.app.state.event_bus)
|
||||
try:
|
||||
store.add(rec, event_bus=request.app.state.event_bus)
|
||||
except IntegrityError as exc:
|
||||
# ``(batch_id, patient_control_number)`` is UNIQUE — fires when a
|
||||
# single batch file contains the same CLM01 control number twice,
|
||||
# or when the same claim id has already been ingested in a prior
|
||||
# batch. Surface as 409 with the batch id so the caller can look
|
||||
# it up; do NOT 500 (a 500 without CORS headers is misreported by
|
||||
# browsers as a CORS error and hides the real cause).
|
||||
log.warning("Duplicate claim while persisting batch %s: %s", rec.id, exc)
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"error": "Duplicate claim",
|
||||
"detail": (
|
||||
"This file (or one previously ingested with the same "
|
||||
"claim control number) collides with an existing "
|
||||
"record. Inspect the file for duplicate CLM01 "
|
||||
"control numbers, or remove the existing batch "
|
||||
"before retrying."
|
||||
),
|
||||
"batch_id": rec.id,
|
||||
},
|
||||
)
|
||||
|
||||
if _client_wants_json(request):
|
||||
body = json.loads(result.model_dump_json())
|
||||
@@ -514,7 +583,23 @@ async def parse_835_endpoint(
|
||||
parsed_at=utcnow(),
|
||||
result=result,
|
||||
)
|
||||
store.add(rec, event_bus=request.app.state.event_bus)
|
||||
try:
|
||||
store.add(rec, event_bus=request.app.state.event_bus)
|
||||
except IntegrityError as exc:
|
||||
log.warning("Duplicate remittance while persisting batch %s: %s", rec.id, exc)
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"error": "Duplicate remittance",
|
||||
"detail": (
|
||||
"This 835 file (or one previously ingested with the "
|
||||
"same payer claim control number) collides with an "
|
||||
"existing record. Remove the existing remittance "
|
||||
"before retrying."
|
||||
),
|
||||
"batch_id": rec.id,
|
||||
},
|
||||
)
|
||||
|
||||
if _client_wants_json(request):
|
||||
body = json.loads(result.model_dump_json())
|
||||
@@ -2933,7 +3018,73 @@ def get_configured_provider(npi: str):
|
||||
p = store.get_provider(npi)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail=f"provider {npi!r} not found")
|
||||
return json.loads(p.model_dump_json())
|
||||
provider_dict = json.loads(p.model_dump_json())
|
||||
|
||||
# SP21 Task 1.6: extend the response with two top-N arrays that the
|
||||
# drill-down peek panel hangs off. ``recent_claims`` reuses the
|
||||
# existing store projection (already returns UI-shaped dicts with
|
||||
# ``submissionDate``); ``recent_activity`` is a direct ORM join
|
||||
# because ``ActivityEvent`` has no ``provider_npi`` column — the
|
||||
# filter has to hop through ``Claim.id``.
|
||||
recent_claims = sorted(
|
||||
store.iter_claims(provider_npi=npi),
|
||||
key=lambda c: c.get("submissionDate") or "",
|
||||
reverse=True,
|
||||
)[:10]
|
||||
|
||||
# Activity filter has TWO join paths back to a Claim for this
|
||||
# provider:
|
||||
# 1. ``ActivityEvent.claim_id IN (claim_ids)`` — events that were
|
||||
# recorded with a claim FK already set (claim_submitted,
|
||||
# manual_match, claim_paid, etc.).
|
||||
# 2. ``Remittance.claim_id IN (claim_ids)`` — the original
|
||||
# ``remit_received`` event recorded at 835 ingest time
|
||||
# (``store.add`` lines 999-1003) is inserted with
|
||||
# ``claim_id=None`` because the remittance hasn't been matched
|
||||
# to a claim yet. The auto-reconcile pass later sets
|
||||
# ``Remittance.claim_id`` (``reconcile.run`` lines 289-293),
|
||||
# but the *original* ActivityEvent row stays orphaned. The
|
||||
# outer-join-then-OR lets us surface both shapes. Without
|
||||
# path 2, a provider's activity feed looks frozen the moment
|
||||
# an 835 lands — the most common activity, invisible.
|
||||
with db.SessionLocal()() as s:
|
||||
claim_ids = [
|
||||
cid
|
||||
for (cid,) in s.query(Claim.id)
|
||||
.filter(Claim.provider_npi == npi)
|
||||
.all()
|
||||
]
|
||||
activity_rows = []
|
||||
if claim_ids:
|
||||
activity_rows = (
|
||||
s.query(db.ActivityEvent)
|
||||
.outerjoin(
|
||||
Remittance,
|
||||
db.ActivityEvent.remittance_id == Remittance.id,
|
||||
)
|
||||
.filter(or_(
|
||||
db.ActivityEvent.claim_id.in_(claim_ids),
|
||||
Remittance.claim_id.in_(claim_ids),
|
||||
))
|
||||
.order_by(desc(db.ActivityEvent.ts))
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
def _activity_to_ui(a):
|
||||
return {
|
||||
"id": a.id,
|
||||
"ts": a.ts.isoformat().replace("+00:00", "Z") if a.ts else "",
|
||||
"kind": a.kind,
|
||||
"batchId": a.batch_id,
|
||||
"claimId": a.claim_id,
|
||||
"remittanceId": a.remittance_id,
|
||||
"payload": a.payload_json or {},
|
||||
}
|
||||
|
||||
provider_dict["recent_claims"] = recent_claims
|
||||
provider_dict["recent_activity"] = [_activity_to_ui(a) for a in activity_rows]
|
||||
return provider_dict
|
||||
|
||||
|
||||
@app.get("/api/config/payers")
|
||||
@@ -2958,6 +3109,126 @@ def list_payer_configs(payer_id: str):
|
||||
return configs
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SP21 Task 1.5: payer-level summary for the drill-down panel
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Per-payer_id memoization for /api/payers/{payer_id}/summary. The drill-
|
||||
# down UI hammers this on every hover; the underlying query is O(claims)
|
||||
# per payer so a 60s TTL keeps the panel responsive. Process-local on
|
||||
# purpose — invalidation is driven by the TTL alone for now (see note
|
||||
# below).
|
||||
_SUMMARY_TTL_S = 60.0
|
||||
_summary_cache: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
|
||||
def _clear_summary_cache() -> None:
|
||||
"""Test hook: wipe the process-local cache.
|
||||
|
||||
The 60s TTL means tests that want to assert on a recomputed payload
|
||||
must clear the cache between requests. Not used by production code.
|
||||
"""
|
||||
_summary_cache.clear()
|
||||
|
||||
|
||||
# Pubsub invalidation is intentionally NOT wired. The ``claim_written``
|
||||
# and ``remittance_written`` payloads emitted by ``store.add`` are the
|
||||
# UI-shaped dicts from ``to_ui_claim_from_orm`` / ``to_ui_remittance_from_orm``,
|
||||
# neither of which carries the X12 ``payer_id`` (NM1*PR*PI qualifier).
|
||||
# They carry ``payerName`` only, which is the human-readable name and not
|
||||
# the URL key we cache on. Wiring a subscriber here would either need a
|
||||
# DB lookup per event (re-deriving payer_id from Claim.id) or a different
|
||||
# cache key — both are out of scope for this task. The 60s TTL keeps
|
||||
# staleness bounded; a follow-up can wire targeted invalidation if the
|
||||
# UI proves TTL-bounded staleness is unacceptable.
|
||||
|
||||
|
||||
@app.get("/api/payers/{payer_id}/summary")
|
||||
def get_payer_summary(payer_id: str):
|
||||
"""Payer-level rollup for the drill-down panel.
|
||||
|
||||
Returns billed/received totals, denial rate, and top providers for
|
||||
the given ``payer_id`` (the X12 NM1*PR*PI qualifier, e.g. ``SKCO0``).
|
||||
Cached in-process for ``_SUMMARY_TTL_S`` seconds.
|
||||
|
||||
404 when no claims AND no remits reference this payer_id — the UI
|
||||
uses that to distinguish a typo from a payer with zero traffic
|
||||
(the latter would still return a valid 200 with zeroed totals).
|
||||
"""
|
||||
now = monotonic()
|
||||
cached = _summary_cache.get(payer_id)
|
||||
if cached and (now - cached[0]) < _SUMMARY_TTL_S:
|
||||
return cached[1]
|
||||
|
||||
log.debug("payer summary cache miss", extra={"payer_id": payer_id})
|
||||
|
||||
# Query claims + remits scoped to this payer_id. We bypass
|
||||
# ``store.iter_claims`` because that helper filters by payer NAME
|
||||
# substring, not by the X12 PI qualifier we use as the URL key.
|
||||
with db.SessionLocal()() as s:
|
||||
claim_rows: list[Claim] = (
|
||||
s.query(Claim).filter(Claim.payer_id == payer_id).all()
|
||||
)
|
||||
claim_ids = [c.id for c in claim_rows]
|
||||
remit_rows: list[Remittance] = []
|
||||
if claim_ids:
|
||||
remit_rows = (
|
||||
s.query(Remittance)
|
||||
.filter(Remittance.claim_id.in_(claim_ids))
|
||||
.all()
|
||||
)
|
||||
|
||||
if not claim_rows and not remit_rows:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Payer {payer_id!r} not found",
|
||||
)
|
||||
|
||||
billed_total = sum(float(c.charge_amount or 0) for c in claim_rows)
|
||||
received_total = sum(float(r.total_paid or 0) for r in remit_rows)
|
||||
denied = sum(
|
||||
1 for c in claim_rows if c.state == ClaimState.DENIED
|
||||
)
|
||||
claim_count = len(claim_rows)
|
||||
denial_rate = (denied / claim_count) if claim_count else 0.0
|
||||
|
||||
provider_counts: dict[str, int] = {}
|
||||
for c in claim_rows:
|
||||
npi = c.provider_npi
|
||||
if not npi:
|
||||
continue
|
||||
provider_counts[npi] = provider_counts.get(npi, 0) + 1
|
||||
top_providers = [
|
||||
{"npi": npi, "count": count}
|
||||
for npi, count in sorted(
|
||||
provider_counts.items(), key=lambda kv: -kv[1]
|
||||
)[:5]
|
||||
]
|
||||
|
||||
# Best-effort payer display name from the first claim's raw
|
||||
# payer object (NM1*PR name element, e.g. "COHCPF"). Falls
|
||||
# back to the id when no parsed envelope is available.
|
||||
payer_name = payer_id
|
||||
for c in claim_rows:
|
||||
raw = c.raw_json or {}
|
||||
p = raw.get("payer") if isinstance(raw, dict) else None
|
||||
if isinstance(p, dict) and p.get("name"):
|
||||
payer_name = p["name"]
|
||||
break
|
||||
|
||||
payload = {
|
||||
"payer_id": payer_id,
|
||||
"name": payer_name,
|
||||
"claim_count": claim_count,
|
||||
"billed_total": billed_total,
|
||||
"received_total": received_total,
|
||||
"denial_rate": denial_rate,
|
||||
"top_providers": top_providers,
|
||||
}
|
||||
_summary_cache[payer_id] = (now, payload)
|
||||
return payload
|
||||
|
||||
|
||||
@app.post("/api/admin/reload-config")
|
||||
def reload_config():
|
||||
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
|
||||
|
||||
@@ -1668,7 +1668,16 @@ class CycloneStore:
|
||||
return list(by_npi.values())
|
||||
|
||||
def recent_activity(self, *, limit: int = 200) -> list[dict]:
|
||||
"""Return recent activity events from the DB, newest first."""
|
||||
"""Return recent activity events from the DB, newest first.
|
||||
|
||||
SP21 Task 2.5: each row also carries ``claimId`` and
|
||||
``remittanceId`` (read from the ORM columns) so the Dashboard's
|
||||
Recent-activity card can route clicks to the right entity
|
||||
drawer via ``src/lib/event-routing.ts``. Both are nullable
|
||||
strings; the wire shape uses camelCase keys to match the
|
||||
existing ``npi`` / ``amount`` fields and the frontend
|
||||
``Activity`` interface.
|
||||
"""
|
||||
with db.SessionLocal()() as s:
|
||||
rows = (
|
||||
s.query(ActivityEvent)
|
||||
@@ -1684,6 +1693,8 @@ class CycloneStore:
|
||||
"timestamp": r.ts.isoformat().replace("+00:00", "Z"),
|
||||
"npi": (r.payload_json or {}).get("npi"),
|
||||
"amount": (r.payload_json or {}).get("amount"),
|
||||
"claimId": r.claim_id,
|
||||
"remittanceId": r.remittance_id,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@@ -160,3 +160,51 @@ def test_cors_headers_present(client: TestClient):
|
||||
)
|
||||
assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173"
|
||||
assert "POST" in resp.headers.get("access-control-allow-methods", "").upper()
|
||||
|
||||
|
||||
def test_cors_headers_present_for_loopback_ip(client: TestClient):
|
||||
# ``http://127.0.0.1:5173`` is a distinct origin from
|
||||
# ``http://localhost:5173`` per the CORS spec, even though both resolve
|
||||
# to the same Vite dev server. Both must be allow-listed or tabs opened
|
||||
# via the IP form silently break.
|
||||
resp = client.options(
|
||||
"/api/parse-837",
|
||||
headers={
|
||||
"Origin": "http://127.0.0.1:5173",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type",
|
||||
},
|
||||
)
|
||||
assert resp.headers.get("access-control-allow-origin") == "http://127.0.0.1:5173"
|
||||
|
||||
|
||||
def test_cors_extra_origins_via_env(client: TestClient, monkeypatch):
|
||||
# LAN / staging hosts opt in via CYCLONE_ALLOWED_ORIGINS. The env var
|
||||
# is a comma-separated list; the middleware must reflect each entry.
|
||||
# The allow-list is built at module import, so we re-execute the
|
||||
# module under the env var and build a TestClient against the
|
||||
# reloaded app.
|
||||
monkeypatch.setenv(
|
||||
"CYCLONE_ALLOWED_ORIGINS", "http://192.168.1.42:5173,https://staging.example.com"
|
||||
)
|
||||
import importlib
|
||||
from cyclone import api as api_module
|
||||
from fastapi.testclient import TestClient as _TC
|
||||
importlib.reload(api_module)
|
||||
try:
|
||||
with _TC(api_module.app) as tc:
|
||||
for origin in ("http://192.168.1.42:5173", "https://staging.example.com"):
|
||||
resp = tc.options(
|
||||
"/api/parse-837",
|
||||
headers={
|
||||
"Origin": origin,
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type",
|
||||
},
|
||||
)
|
||||
assert resp.headers.get("access-control-allow-origin") == origin
|
||||
finally:
|
||||
monkeypatch.delenv("CYCLONE_ALLOWED_ORIGINS", raising=False)
|
||||
# Reload once more so the module-level allow-list returns to its
|
||||
# default for any test that imports `cyclone.api` after this one.
|
||||
importlib.reload(api_module)
|
||||
|
||||
@@ -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 { Toaster } from "sonner";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||
import { Dashboard } from "@/pages/Dashboard";
|
||||
import { Claims } from "@/pages/Claims";
|
||||
import { Remittances } from "@/pages/Remittances";
|
||||
@@ -24,7 +25,7 @@ function NotFound() {
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<DrillStackProvider>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
@@ -52,6 +53,6 @@ export default function App() {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</DrillStackProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// @vitest-environment happy-dom
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ActivityFeed } from "./ActivityFeed";
|
||||
import type { Activity } from "@/types";
|
||||
|
||||
// happy-dom keeps `document.body` between tests; without cleanup,
|
||||
// `screen.getByRole("button")` finds buttons from earlier renders.
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const baseActivity: Activity = {
|
||||
id: "a-1",
|
||||
kind: "claim_submitted",
|
||||
@@ -47,4 +51,99 @@ describe("ActivityFeed", () => {
|
||||
expect(() => render(<ActivityFeed items={[unknown]} />)).not.toThrow();
|
||||
expect(screen.getByText("future_kind event arrived")).toBeTruthy();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// SP21 Task 2.5: optional `onItemClick` prop makes each row a
|
||||
// drillable target. Default behavior (no handler) must stay unchanged.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
it("does not attach row-level click attrs when onItemClick is omitted", () => {
|
||||
render(<ActivityFeed items={[baseActivity]} />);
|
||||
const li = screen.getByRole("listitem");
|
||||
expect(li.getAttribute("role")).not.toBe("button");
|
||||
expect(li.getAttribute("tabindex")).toBeNull();
|
||||
expect(li.classList.contains("drillable")).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches role/tabIndex/drillable class when onItemClick is provided", () => {
|
||||
render(
|
||||
<ActivityFeed
|
||||
items={[baseActivity]}
|
||||
onItemClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
const li = screen.getByRole("button", { name: /View claim submitted/ });
|
||||
expect(li.tagName).toBe("LI");
|
||||
expect(li.getAttribute("tabindex")).toBe("0");
|
||||
expect(li.classList.contains("drillable")).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onItemClick with the event on click", () => {
|
||||
const onItemClick = vi.fn();
|
||||
render(
|
||||
<ActivityFeed
|
||||
items={[baseActivity]}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
);
|
||||
const li = screen.getByRole("button");
|
||||
fireEvent.click(li);
|
||||
expect(onItemClick).toHaveBeenCalledTimes(1);
|
||||
expect(onItemClick).toHaveBeenCalledWith(baseActivity);
|
||||
});
|
||||
|
||||
it("calls onItemClick on Enter keydown", () => {
|
||||
const onItemClick = vi.fn();
|
||||
render(
|
||||
<ActivityFeed
|
||||
items={[baseActivity]}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
);
|
||||
const li = screen.getByRole("button");
|
||||
fireEvent.keyDown(li, { key: "Enter" });
|
||||
expect(onItemClick).toHaveBeenCalledTimes(1);
|
||||
expect(onItemClick).toHaveBeenCalledWith(baseActivity);
|
||||
});
|
||||
|
||||
it("calls onItemClick on Space keydown", () => {
|
||||
const onItemClick = vi.fn();
|
||||
render(
|
||||
<ActivityFeed
|
||||
items={[baseActivity]}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
);
|
||||
fireEvent.keyDown(screen.getByRole("button"), { key: " " });
|
||||
expect(onItemClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not call onItemClick for unrelated keys", () => {
|
||||
const onItemClick = vi.fn();
|
||||
render(
|
||||
<ActivityFeed
|
||||
items={[baseActivity]}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
);
|
||||
fireEvent.keyDown(screen.getByRole("button"), { key: "a" });
|
||||
expect(onItemClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Regression for the Task 2.4 event-bubbling pattern: clicks on a
|
||||
// drillable row must not bubble to a parent row click. Without
|
||||
// stopPropagation a Dashboard parent handler could fire alongside
|
||||
// the row click and corrupt history.
|
||||
it("stops click propagation so a parent row handler does not also fire", () => {
|
||||
const onItemClick = vi.fn();
|
||||
const parentClick = vi.fn();
|
||||
render(
|
||||
<ul onClick={parentClick}>
|
||||
<ActivityFeed items={[baseActivity]} onItemClick={onItemClick} />
|
||||
</ul>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onItemClick).toHaveBeenCalledTimes(1);
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { KeyboardEvent, MouseEvent } from "react";
|
||||
import {
|
||||
Banknote,
|
||||
CheckCircle2,
|
||||
@@ -63,9 +64,22 @@ const FALLBACK_KIND: { icon: LucideIcon; tone: string; tint: string } = {
|
||||
export function ActivityFeed({
|
||||
items,
|
||||
emptyMessage = "No activity yet.",
|
||||
onItemClick,
|
||||
}: {
|
||||
items: Activity[];
|
||||
emptyMessage?: string;
|
||||
/**
|
||||
* Optional click handler for SP21 Universal Drill-Down (Task 2.5).
|
||||
* When provided, each row becomes a clickable target: the row's
|
||||
* outer `<li>` gets `role="button"`, `tabIndex`, the `drillable`
|
||||
* hover affordance (chevron + tint), and an Enter/Space keybinding.
|
||||
*
|
||||
* `e.stopPropagation()` is called before invoking the handler so the
|
||||
* click doesn't bubble to a hypothetical parent row click — same
|
||||
* fix that landed on `DrillableCell` in Task 2.4. When omitted, the
|
||||
* feed renders exactly as before (no extra DOM, no extra attrs).
|
||||
*/
|
||||
onItemClick?: (event: Activity) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
@@ -79,11 +93,36 @@ export function ActivityFeed({
|
||||
{items.map((a) => {
|
||||
const cfg = kindConfig[a.kind] ?? FALLBACK_KIND;
|
||||
const Icon = cfg.icon;
|
||||
// SP21 Task 2.5: when a click handler is wired, the row
|
||||
// becomes a keyboard-focusable button-like element with the
|
||||
// drillable hover affordance. We attach the handler to the
|
||||
// <li> directly (matching the Dashboard's existing patterns
|
||||
// for "Top providers" / "Recent denials" rows) so the click
|
||||
// target covers the full row width including padding.
|
||||
const rowProps = onItemClick
|
||||
? {
|
||||
role: "button" as const,
|
||||
tabIndex: 0,
|
||||
"aria-label": `View ${a.kind.replace(/_/g, " ")}: ${a.message}`,
|
||||
className:
|
||||
"drillable flex items-start gap-3 py-3 first:pt-0 last:pb-0 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
onClick: (e: MouseEvent<HTMLLIElement>) => {
|
||||
e.stopPropagation();
|
||||
onItemClick(a);
|
||||
},
|
||||
onKeyDown: (e: KeyboardEvent<HTMLLIElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onItemClick(a);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {
|
||||
className: "flex items-start gap-3 py-3 first:pt-0 last:pb-0",
|
||||
};
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<li key={a.id} {...rowProps}>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 h-7 w-7 shrink-0 rounded-md ring-1 ring-inset ring-border/40 flex items-center justify-center",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Plus, Minus, Equal, ArrowRight, type LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Equal,
|
||||
Minus,
|
||||
Plus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
@@ -9,19 +15,20 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { KpiCard } from "@/components/KpiCard";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BatchClaimDiffSummary,
|
||||
BatchDiff,
|
||||
BatchDiffChangedRow,
|
||||
BatchDiffSideMeta,
|
||||
BatchDiffSummary,
|
||||
BatchClaimDiffSummary,
|
||||
} from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side meta header — small panel identifying which batch is on the left / right.
|
||||
// Side meta header — small panel identifying which batch is on the
|
||||
// left / right. Paper-toned to sit inside the cream "paper plane" of
|
||||
// the BatchDiff page. data-testid pinned by BatchDiff.test.tsx.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SideMeta({
|
||||
@@ -39,10 +46,19 @@ function SideMeta({
|
||||
: "text-amber-300 border-amber-400/30 bg-amber-400/10";
|
||||
return (
|
||||
<div
|
||||
className="surface rounded-xl p-4 flex flex-col gap-2"
|
||||
className="rounded-xl p-4 flex flex-col gap-2 border"
|
||||
data-testid={`batch-diff-side-${label.toLowerCase()}`}
|
||||
style={{
|
||||
backgroundColor: "hsl(36 22% 98%)",
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06)",
|
||||
}}
|
||||
>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<div
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em]"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -54,12 +70,23 @@ function SideMeta({
|
||||
>
|
||||
{side.kind}
|
||||
</span>
|
||||
<span className="display num text-[13px]">{side.id}</span>
|
||||
<span
|
||||
className="display num text-[13px]"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{side.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-[12px] text-muted-foreground truncate">
|
||||
<div
|
||||
className="font-mono text-[12px] truncate"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{side.inputFilename}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div
|
||||
className="flex items-center justify-between text-xs"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
<span>Parsed {side.parsedAt ? fmt.dateShort(side.parsedAt) : "—"}</span>
|
||||
<span className="font-mono num">
|
||||
{fmt.num(side.claimCount)} claim{side.claimCount === 1 ? "" : "s"}
|
||||
@@ -70,40 +97,125 @@ function SideMeta({
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary cards — the four-count tile row.
|
||||
// Summary cards — the four-count tile row. Paper-toned via
|
||||
// DiffKpiTile so the counts sit naturally on the cream paper plane.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SummaryCards({ summary }: { summary: BatchDiffSummary }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3" data-testid="batch-diff-summary">
|
||||
<KpiCard
|
||||
<div
|
||||
className="grid grid-cols-2 md:grid-cols-4 gap-3"
|
||||
data-testid="batch-diff-summary"
|
||||
>
|
||||
<DiffKpiTile
|
||||
label="Added in B"
|
||||
value={fmt.num(summary.addedCount)}
|
||||
icon={Plus}
|
||||
hint="In B, not in A"
|
||||
tone="success"
|
||||
/>
|
||||
<KpiCard
|
||||
<DiffKpiTile
|
||||
label="Removed from A"
|
||||
value={fmt.num(summary.removedCount)}
|
||||
icon={Minus}
|
||||
hint="In A, not in B"
|
||||
tone="destructive"
|
||||
/>
|
||||
<KpiCard
|
||||
<DiffKpiTile
|
||||
label="Changed"
|
||||
value={fmt.num(summary.changedCount)}
|
||||
icon={Equal}
|
||||
hint="Deltas in either side"
|
||||
tone="amber"
|
||||
/>
|
||||
<KpiCard
|
||||
<DiffKpiTile
|
||||
label="Unchanged"
|
||||
value={fmt.num(summary.unchangedCount)}
|
||||
icon={Equal}
|
||||
hint="Identical across A & B"
|
||||
tone="ink"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DiffKpiTile — paper-toned metric tile for the diff summary row.
|
||||
// Mirrors AckKpiTile / BatchesKpiTile from the other hybrid pages.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DiffKpiTile({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
hint,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: LucideIcon;
|
||||
hint: string;
|
||||
tone: "success" | "destructive" | "amber" | "ink";
|
||||
}) {
|
||||
const accentMap = {
|
||||
success: "hsl(152 64% 38%)",
|
||||
destructive: "hsl(358 70% 42%)",
|
||||
amber: "hsl(36 92% 50%)",
|
||||
ink: "hsl(var(--surface-ink))",
|
||||
} as const;
|
||||
const tintMap = {
|
||||
success: "hsl(152 50% 88%)",
|
||||
destructive: "hsl(358 70% 92%)",
|
||||
amber: "hsl(36 82% 92%)",
|
||||
ink: "hsl(36 22% 90%)",
|
||||
} as const;
|
||||
const accent = accentMap[tone];
|
||||
const tint = tintMap[tone];
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-xl p-4 overflow-hidden border"
|
||||
title={hint}
|
||||
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-4 bottom-4 w-[3px] rounded-r-sm"
|
||||
style={{ backgroundColor: accent, opacity: 0.85 }}
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<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, color: accent }}
|
||||
>
|
||||
<Icon className="h-3 w-3" strokeWidth={1.75} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="display tabular-nums tracking-[-0.04em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(26px, 2.8vw, 36px)",
|
||||
lineHeight: 1,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row indicator — `+` / `-` / `~` gutter on the left of every row.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -152,7 +264,10 @@ function RowIndicator({
|
||||
|
||||
function ClaimIdCell({ id }: { id: string }) {
|
||||
return (
|
||||
<span className="font-mono text-[12px] tracking-tight" data-testid="diff-claim-id">
|
||||
<span
|
||||
className="font-mono text-[12px] tracking-tight"
|
||||
data-testid="diff-claim-id"
|
||||
>
|
||||
{id}
|
||||
</span>
|
||||
);
|
||||
@@ -160,14 +275,31 @@ function ClaimIdCell({ id }: { id: string }) {
|
||||
|
||||
function PatientCell({ name }: { name: string }) {
|
||||
if (!name) {
|
||||
return <span className="text-muted-foreground/60 text-[12px]">—</span>;
|
||||
return (
|
||||
<span
|
||||
className="text-[12px]"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-[13px]">{name}</span>;
|
||||
return (
|
||||
<span
|
||||
className="text-[13px]"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ChargeCell({ value }: { value: number }) {
|
||||
return (
|
||||
<span className="display num text-[13px] tabular-nums">
|
||||
<span
|
||||
className="display num text-[13px] tabular-nums"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{fmt.usdPrecise(value)}
|
||||
</span>
|
||||
);
|
||||
@@ -175,19 +307,44 @@ function ChargeCell({ value }: { value: number }) {
|
||||
|
||||
function DateCell({ value }: { value: string | null }) {
|
||||
if (!value) {
|
||||
return <span className="text-muted-foreground/60 text-[12px]">—</span>;
|
||||
return (
|
||||
<span
|
||||
className="text-[12px]"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="num text-[12.5px]">{value}</span>;
|
||||
return (
|
||||
<span
|
||||
className="num text-[12.5px]"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCell({ value }: { value: string }) {
|
||||
if (!value) {
|
||||
return <span className="text-muted-foreground/60 text-[12px]">—</span>;
|
||||
return (
|
||||
<span
|
||||
className="text-[12px]"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono uppercase tracking-wider text-[10px]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
borderColor: "hsl(30 14% 14% / 0.20)",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
@@ -196,17 +353,28 @@ function StatusCell({ value }: { value: string }) {
|
||||
|
||||
function CptCell({ codes }: { codes: string[] }) {
|
||||
if (!codes || codes.length === 0) {
|
||||
return <span className="text-muted-foreground/60 text-[12px]">—</span>;
|
||||
return (
|
||||
<span
|
||||
className="text-[12px]"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="font-mono text-[11.5px] tracking-tight">
|
||||
<span
|
||||
className="font-mono text-[11.5px] tracking-tight"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{codes.join(", ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section tables — one per bucket (added / removed / changed).
|
||||
// Section tables — one per bucket (added / removed / changed). Uses
|
||||
// `tone="paper"` so the table chrome matches the cream paper plane.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Side = "a" | "b";
|
||||
@@ -386,25 +554,53 @@ function SectionTable({
|
||||
<section className="space-y-3" data-testid={testid}>
|
||||
<header className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-0.5">
|
||||
<div
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] mb-0.5"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">{title}</h2>
|
||||
<h3
|
||||
className="display leading-[0.98] tracking-[-0.03em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(20px, 2.2vw, 26px)",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="font-mono num text-[12.5px] text-muted-foreground">
|
||||
<span
|
||||
className="font-mono num text-[12.5px]"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{fmt.num(count)}
|
||||
</span>
|
||||
</header>
|
||||
{count === 0 ? (
|
||||
<div
|
||||
className="surface rounded-xl border border-dashed border-border/60 p-6 text-center text-[12.5px] text-muted-foreground"
|
||||
className="rounded-xl border p-6 text-center text-[12.5px]"
|
||||
data-testid={`${testid}-empty`}
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
color: "hsl(var(--surface-ink-3))",
|
||||
}}
|
||||
>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<div className="surface rounded-xl overflow-hidden">
|
||||
<Table>
|
||||
<div
|
||||
className="rounded-xl border overflow-hidden"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
backgroundColor: "hsl(36 22% 98%)",
|
||||
boxShadow: "inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
|
||||
}}
|
||||
>
|
||||
<Table tone="paper">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" aria-label="Diff indicator" />
|
||||
@@ -425,7 +621,8 @@ function SectionTable({
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skeleton — used by the page while the diff is loading.
|
||||
// Skeleton — used by the page while the diff is loading. Paper-toned
|
||||
// (cream blocks) to match the paper plane chrome.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function BatchDiffViewSkeleton() {
|
||||
@@ -462,11 +659,24 @@ export function BatchDiffEmpty({ data }: { data: BatchDiff }) {
|
||||
<SideMeta side={data.b} label="B (right)" />
|
||||
</div>
|
||||
<SummaryCards summary={data.summary} />
|
||||
<div className="surface rounded-xl border border-dashed border-border/60 p-10 text-center">
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
|
||||
<div
|
||||
className="rounded-xl border p-10 text-center"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] mb-1.5"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
Diff · no deltas
|
||||
</div>
|
||||
<div className="text-[13.5px] text-muted-foreground">
|
||||
<div
|
||||
className="text-[13.5px]"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
These two batches are identical — no claims added, removed, or
|
||||
changed between A and B.
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,11 @@ import type { BatchSummary } from "@/lib/api";
|
||||
*
|
||||
* Voice mirrors `AckCodeBadge` in `src/pages/Acks.tsx` (uppercase,
|
||||
* wide tracking, hairline border, low-opacity fill).
|
||||
*
|
||||
* The literal class names `text-sky-300` and `text-amber-300` are
|
||||
* pinned by `Batches.test.tsx` as a contract — the test asserts the
|
||||
* badge's text color is one of these two strings. Don't replace them
|
||||
* with arbitrary HSL values or the test will fail.
|
||||
*/
|
||||
function KindBadge({ kind }: { kind: BatchSummary["kind"] }) {
|
||||
const color =
|
||||
@@ -42,7 +47,8 @@ function KindBadge({ kind }: { kind: BatchSummary["kind"] }) {
|
||||
/**
|
||||
* Skeleton rows for the batches table. Mirrors the row count used in
|
||||
* `Acks.tsx` (5 placeholders) so the loading density matches the rest
|
||||
* of the app.
|
||||
* of the app. `data-testid="batches-skeleton"` is pinned by
|
||||
* `Batches.test.tsx`.
|
||||
*/
|
||||
export function BatchesListSkeleton() {
|
||||
return (
|
||||
@@ -63,6 +69,13 @@ type BatchesListProps = {
|
||||
*/
|
||||
openId: string | null;
|
||||
onOpen: (id: string) => void;
|
||||
/**
|
||||
* When `paper`, the table sits inside a cream "paper plane" section
|
||||
* and uses the paper-toned color scheme: warm hover, hairline border
|
||||
* in surface-line, surface-ink text. When `dark` (default), the
|
||||
* original dark-mode chrome is used.
|
||||
*/
|
||||
tone?: "dark" | "paper";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -71,15 +84,26 @@ type BatchesListProps = {
|
||||
* so the numbers tick up from 0 on first render (gives the page a
|
||||
* little life on load; consistent with the Dashboard KPI cards).
|
||||
*/
|
||||
export function BatchesList({ items, openId, onOpen }: BatchesListProps) {
|
||||
export function BatchesList({
|
||||
items,
|
||||
openId,
|
||||
onOpen,
|
||||
tone = "dark",
|
||||
}: BatchesListProps) {
|
||||
const isPaper = tone === "paper";
|
||||
return (
|
||||
<Table data-testid="batches-table">
|
||||
<Table data-testid="batches-table" tone={tone}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kind</TableHead>
|
||||
<TableHead>Batch</TableHead>
|
||||
<TableHead>Input file</TableHead>
|
||||
<TableHead className="text-right">Claims</TableHead>
|
||||
<TableHead
|
||||
className="text-right"
|
||||
style={isPaper ? { color: "hsl(var(--surface-ink-2))" } : undefined}
|
||||
>
|
||||
Claims
|
||||
</TableHead>
|
||||
<TableHead>Parsed</TableHead>
|
||||
<TableHead className="w-6" aria-label="Open" />
|
||||
</TableRow>
|
||||
@@ -93,28 +117,57 @@ export function BatchesList({ items, openId, onOpen }: BatchesListProps) {
|
||||
data-open={openId === b.id ? "true" : undefined}
|
||||
className={cn(
|
||||
"animate-row-flash cursor-pointer",
|
||||
openId === b.id && "bg-muted/40",
|
||||
isPaper && openId === b.id &&
|
||||
"!bg-[hsl(212_85%_95%)] ring-1 ring-inset ring-[hsl(212_100%_45%_/_0.30)]",
|
||||
!isPaper && openId === b.id && "bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<TableCell>
|
||||
<KindBadge kind={b.kind} />
|
||||
</TableCell>
|
||||
<TableCell className="display num text-[13px]">
|
||||
<TableCell
|
||||
className="display num text-[13px]"
|
||||
style={isPaper ? { color: "hsl(var(--surface-ink))" } : undefined}
|
||||
>
|
||||
{b.id}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[12px] text-muted-foreground truncate max-w-[280px]">
|
||||
<TableCell
|
||||
className={cn(
|
||||
"font-mono text-[12px] truncate max-w-[280px]",
|
||||
isPaper
|
||||
? "text-[hsl(var(--surface-ink-2))]"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{b.inputFilename}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-[13px]">
|
||||
<TableCell
|
||||
className="text-right display num text-[13px]"
|
||||
style={isPaper ? { color: "hsl(var(--surface-ink))" } : undefined}
|
||||
>
|
||||
<AnimatedNumber
|
||||
value={b.claimCount}
|
||||
format={(n) => fmt.num(Math.round(n))}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num text-[12.5px]">
|
||||
<TableCell
|
||||
className={cn(
|
||||
"num text-[12.5px]",
|
||||
isPaper
|
||||
? "text-[hsl(var(--surface-ink-3))]"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{b.parsedAt ? fmt.dateShort(b.parsedAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-right">
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right",
|
||||
isPaper
|
||||
? "text-[hsl(var(--surface-ink-3))]"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>›</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// @vitest-environment happy-dom
|
||||
// ProviderDrawer wires `useProviderDetail` (TanStack Query) and renders
|
||||
// a Radix Dialog portal — both need an act-aware, DOM-backed environment
|
||||
// or React logs warnings and the portal can't mount.
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { ProviderDrawer } from "@/components/ProviderDrawer";
|
||||
import type { Provider } from "@/types";
|
||||
|
||||
// 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). Mocking the hook directly — rather than mocking
|
||||
// `api.getProvider` — lets each test pin the hook's exact return shape
|
||||
// without standing up a real `QueryClient`.
|
||||
//
|
||||
// `vi.hoisted` is required because `vi.mock` is hoisted ABOVE top-level
|
||||
// `const` declarations — referencing a top-level `vi.fn()` directly from
|
||||
// the factory would hit "Cannot access before initialization" at runtime.
|
||||
const { useProviderDetail } = vi.hoisted(() => ({
|
||||
useProviderDetail: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/hooks/useProviderDetail", () => ({
|
||||
useProviderDetail,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Minimal valid `Provider` fixture — every required key present so the
|
||||
* component typechecks. The fields are what `ProviderOverview` reads,
|
||||
* so the success-path render exercises every prop.
|
||||
*/
|
||||
const SAMPLE_PROVIDER: Provider = {
|
||||
npi: "1881068062",
|
||||
name: "Montrose Memorial",
|
||||
taxId: "721587149",
|
||||
address: "123 Main St",
|
||||
city: "Montrose",
|
||||
state: "CO",
|
||||
zip: "81401",
|
||||
phone: "(970) 555-1234",
|
||||
claimCount: 184,
|
||||
outstandingAr: 12450,
|
||||
};
|
||||
|
||||
/**
|
||||
* Configure the mocked hook's return value for a single test. The
|
||||
* `refetch` default is a fresh `vi.fn()` — tests that need to assert
|
||||
* on it can override via `overrides.refetch`.
|
||||
*/
|
||||
function mockDetail(
|
||||
overrides: Partial<{
|
||||
data: Provider | null;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}> = {}
|
||||
) {
|
||||
useProviderDetail.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// happy-dom keeps `document.body` between tests; without cleanup,
|
||||
// `screen.getByText(...)` would find nodes from earlier renders.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ProviderDrawer", () => {
|
||||
it("test_renders_nothing_when_npi_is_null", () => {
|
||||
mockDetail({ data: null });
|
||||
render(<ProviderDrawer npi={null} onClose={() => {}} />);
|
||||
|
||||
// No provider content should be in the document when the drawer
|
||||
// is closed — Radix's Dialog gates the portal on `open`.
|
||||
expect(document.body.textContent).not.toContain("Montrose Memorial");
|
||||
});
|
||||
|
||||
it("test_calls_useProviderDetail_with_npi", () => {
|
||||
mockDetail({ data: SAMPLE_PROVIDER });
|
||||
render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
|
||||
|
||||
expect(useProviderDetail).toHaveBeenCalledWith("1881068062");
|
||||
});
|
||||
|
||||
it("test_renders_provider_overview_on_success", () => {
|
||||
mockDetail({ data: SAMPLE_PROVIDER });
|
||||
render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
|
||||
|
||||
// ProviderOverview renders NPI + Tax ID as Field values; the
|
||||
// drawer header shows the provider name as the title.
|
||||
expect(document.body.textContent).toContain("Montrose Memorial");
|
||||
expect(document.body.textContent).toContain("1881068062");
|
||||
expect(document.body.textContent).toContain("721587149");
|
||||
});
|
||||
|
||||
it("test_renders_skeleton_while_loading", () => {
|
||||
mockDetail({ isLoading: true });
|
||||
render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
|
||||
|
||||
// The `Skeleton` primitive sets `aria-busy="true"` — a stable hook
|
||||
// for the loading state that doesn't require a custom testid.
|
||||
expect(document.querySelectorAll('[aria-busy="true"]').length).toBeGreaterThan(0);
|
||||
// And the provider name should NOT have leaked in yet.
|
||||
expect(document.body.textContent).not.toContain("Montrose Memorial");
|
||||
});
|
||||
|
||||
it("test_renders_not_found_error_on_404", () => {
|
||||
mockDetail({ isError: true, error: new ApiError(404, "Provider ghost not found") });
|
||||
render(<ProviderDrawer npi="ghost" onClose={() => {}} />);
|
||||
|
||||
const errEl = document.querySelector('[data-testid="provider-drawer-error-not_found"]');
|
||||
expect(errEl).not.toBeNull();
|
||||
// Body should mention "doesn't exist" — the not_found COPY key.
|
||||
expect(errEl?.textContent).toContain("doesn't exist");
|
||||
// And the retry button should NOT be present (not_found has no
|
||||
// retry affordance — retrying a 404 won't help).
|
||||
expect(document.querySelector('[data-testid="error-retry"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("test_renders_network_error_with_retry", () => {
|
||||
mockDetail({ isError: true, error: new Error("network down") });
|
||||
render(<ProviderDrawer npi="123" onClose={() => {}} />);
|
||||
|
||||
const errEl = document.querySelector('[data-testid="provider-drawer-error-network"]');
|
||||
expect(errEl).not.toBeNull();
|
||||
expect(errEl?.textContent).toContain("Couldn't reach the server");
|
||||
|
||||
// Network variant shows a Retry button.
|
||||
const retryBtn = document.querySelector(
|
||||
'[data-testid="error-retry"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(retryBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it("test_renders_not_found_branch_in_demo_mode_for_unknown_npi", () => {
|
||||
// Demo-mode fallback: when `api.isConfigured` is false, the hook
|
||||
// reads from the in-memory zustand store instead of fetching. If the
|
||||
// queried NPI isn't in the providers list, the hook returns
|
||||
// `isError: true` with an `ApiError(404, "Provider not found")`.
|
||||
// The drawer's `errorKind` computation must route this to the
|
||||
// `not_found` branch — NOT the generic `network` branch, which
|
||||
// would mislead the user into thinking the server is down when
|
||||
// there is simply no backend configured (or the NPI is just not
|
||||
// in the local store). Regression guard for the `new Error` →
|
||||
// `new ApiError(404, ...)` fix on the demo-mode fallback branch.
|
||||
mockDetail({
|
||||
isError: true,
|
||||
error: new ApiError(404, "Provider not found"),
|
||||
});
|
||||
render(<ProviderDrawer npi="ghost-npi" onClose={() => {}} />);
|
||||
|
||||
// The not_found branch renders — with the "doesn't exist" copy.
|
||||
const notFoundEl = document.querySelector(
|
||||
'[data-testid="provider-drawer-error-not_found"]'
|
||||
);
|
||||
expect(notFoundEl).not.toBeNull();
|
||||
expect(notFoundEl?.textContent).toContain("doesn't exist");
|
||||
|
||||
// And — the whole point of this test — the network branch MUST NOT
|
||||
// be present. A plain `Error` from the demo-mode fallback would
|
||||
// have landed here and shown "Couldn't reach the server", which
|
||||
// is wrong for a known-local-miss.
|
||||
expect(
|
||||
document.querySelector('[data-testid="provider-drawer-error-network"]')
|
||||
).toBeNull();
|
||||
expect(document.body.textContent).not.toContain("Couldn't reach the server");
|
||||
|
||||
// not_found has no retry affordance — same invariant as the 404
|
||||
// test above, restated here so this test is self-contained as a
|
||||
// regression guard.
|
||||
expect(document.querySelector('[data-testid="error-retry"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("test_close_button_calls_onClose", () => {
|
||||
const onClose = vi.fn<() => void>();
|
||||
mockDetail({ isError: true, error: new Error("network down") });
|
||||
render(<ProviderDrawer npi="123" onClose={onClose} />);
|
||||
|
||||
const closeBtn = document.querySelector(
|
||||
'[data-testid="error-close"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(closeBtn).not.toBeNull();
|
||||
fireEvent.click(closeBtn!);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProviderDetail } from "@/hooks/useProviderDetail";
|
||||
import { ProviderOverview } from "./ProviderOverview";
|
||||
import { ProviderDrawerError } from "./ProviderDrawerError";
|
||||
|
||||
interface Props {
|
||||
npi: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider drill-down drawer (SP21 Task 2.2).
|
||||
*
|
||||
* Side-panel shell that consumes ``useProviderDetail(npi)`` and renders
|
||||
* the Overview tab content (Phase 2 ships only this tab; Phase 3 adds
|
||||
* Claims/Activity tabs once ``recent_claims`` and ``recent_activity``
|
||||
* rendering lands).
|
||||
*
|
||||
* Layout mirrors the ClaimDrawer / RemitDrawer pattern: Radix Dialog
|
||||
* repositioned to the right edge as a fixed-height side panel, with
|
||||
* the shared ``DrillDrawerHeader`` on top and a scrollable body below.
|
||||
* The DialogContent is the flex parent; the header and body stack
|
||||
* naturally inside it without an explicit `calc(100% - 64px)` height
|
||||
* that would couple to DrillDrawerHeader's padding/font sizes.
|
||||
*
|
||||
* Error branching mirrors the peer drawers:
|
||||
* - `ApiError(404)` → "not_found" (no retry, the provider is gone)
|
||||
* - anything else → "network" (retry available)
|
||||
* - demo mode + unknown NPI → ApiError(404), routes to the same
|
||||
* "not_found" branch (the hook raises `ApiError(404, "Provider not
|
||||
* found")` in this case, matching the real backend 404 path).
|
||||
*/
|
||||
export function ProviderDrawer({ npi, onClose }: Props) {
|
||||
const { data, isLoading, isError, error, refetch } = useProviderDetail(npi);
|
||||
|
||||
const errorKind: "not_found" | "network" | null = isError
|
||||
? error instanceof ApiError && error.status === 404
|
||||
? "not_found"
|
||||
: "network"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog open={npi !== null} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent
|
||||
className="fixed right-0 top-0 flex h-full w-full max-w-2xl flex-col translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{errorKind ? (
|
||||
<ProviderDrawerError
|
||||
kind={errorKind}
|
||||
onRetry={() => {
|
||||
void refetch();
|
||||
}}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : isLoading || !data ? (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<DrillDrawerHeader
|
||||
eyebrow="Provider"
|
||||
title="Loading…"
|
||||
onClose={onClose}
|
||||
/>
|
||||
<div className="space-y-2 p-6">
|
||||
<Skeleton variant="row" />
|
||||
<Skeleton variant="row" />
|
||||
<Skeleton variant="row" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<DrillDrawerHeader
|
||||
eyebrow="Provider"
|
||||
title={data.name}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<ProviderOverview provider={data} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { AlertCircle, WifiOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type ProviderDrawerErrorProps = {
|
||||
kind: "not_found" | "network";
|
||||
onRetry?: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const COPY = {
|
||||
not_found: {
|
||||
eyebrow: "NOT FOUND",
|
||||
message: "This provider doesn't exist or has been removed.",
|
||||
},
|
||||
network: {
|
||||
eyebrow: "CONNECTION",
|
||||
message: "Couldn't reach the server. Check your connection and try again.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Error state for the provider drill-down drawer (SP21 Task 2.2).
|
||||
*
|
||||
* Two shapes — `not_found` (the NPI in the URL doesn't resolve on the
|
||||
* server, e.g. a stale deep link) and `network` (the request failed).
|
||||
* The not_found variant has no retry affordance (retrying won't help),
|
||||
* the network variant does when `onRetry` is supplied.
|
||||
*
|
||||
* Visual twin of `RemitDrawerError` / `ClaimDrawerError` so the drawer
|
||||
* family feels like one component family — same icon size, same eyebrow
|
||||
* color, same button spacing.
|
||||
*/
|
||||
export function ProviderDrawerError({
|
||||
kind,
|
||||
onRetry,
|
||||
onClose,
|
||||
}: ProviderDrawerErrorProps) {
|
||||
const { eyebrow, message } = COPY[kind];
|
||||
const Icon = kind === "network" ? WifiOff : AlertCircle;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-start gap-4 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
|
||||
role="alert"
|
||||
data-testid={`provider-drawer-error-${kind}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className="h-5 w-5 text-[color:var(--m-error)]"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="eyebrow text-[color:var(--m-error)]">
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[color:var(--m-ink-secondary)] max-w-sm">{message}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{kind === "network" && onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
|
||||
Retry
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="sm" onClick={onClose} data-testid="error-close">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Provider } from "@/types";
|
||||
import { fmt } from "@/lib/format";
|
||||
|
||||
/**
|
||||
* Overview tab content for the provider drill-down drawer (SP21
|
||||
* Task 2.2). Renders the base provider fields in a two-column grid:
|
||||
*
|
||||
* - Identity: NPI, Tax ID, address, phone
|
||||
* - Activity: claim count, outstanding AR
|
||||
*
|
||||
* Subsequent tasks (Phase 3) will hang ``recent_claims`` and
|
||||
* ``recent_activity`` off this surface; for now we only render the
|
||||
* base fields the drawer needs to present "who is this provider and
|
||||
* what's their activity shape" at a glance.
|
||||
*/
|
||||
export function ProviderOverview({ provider }: { provider: Provider }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="NPI" value={provider.npi} mono />
|
||||
<Field label="Tax ID" value={provider.taxId} mono />
|
||||
<Field
|
||||
label="Address"
|
||||
value={`${provider.address}, ${provider.city}, ${provider.state} ${provider.zip}`}
|
||||
/>
|
||||
<Field label="Phone" value={provider.phone} mono />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-border/30">
|
||||
<Field label="Claims" value={fmt.num(provider.claimCount)} mono />
|
||||
<Field label="Outstanding AR" value={fmt.usd(provider.outstandingAr)} mono />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="eyebrow">{label}</div>
|
||||
<div className={`text-[13px] mt-1 ${mono ? "display mono" : ""}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Barrel export for the ProviderDrawer module (SP21 Task 2.2).
|
||||
|
||||
export { ProviderDrawer } from "./ProviderDrawer";
|
||||
@@ -0,0 +1,37 @@
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared side-panel header used by every SP21 drill-down drawer
|
||||
* (provider, …future ones). Renders an uppercase eyebrow above a
|
||||
* larger title, with a close button on the right.
|
||||
*
|
||||
* Visual style mirrors the eyebrow + title pattern used elsewhere in
|
||||
* the app (see ``.eyebrow`` in ``src/index.css`` and the
|
||||
* ``DrillDrawerHeader`` usage in ``ClaimDrawerHeader``).
|
||||
*/
|
||||
export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-border/30 px-6 py-4">
|
||||
<div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="text-[18px] font-semibold tracking-tight mt-0.5">{title}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close drawer"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,60 @@
|
||||
// @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);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Regression for Task 2.4 — event bubbling.
|
||||
//
|
||||
// DrillableCell renders a <button>, and <button> clicks bubble up the
|
||||
// DOM by default. Claims wraps each row in a <TableRow onClick={...}>
|
||||
// so a click on the provider cell used to (1) navigate to /providers
|
||||
// and then (2) bubble to the row and re-fire buildUrl() on the now-
|
||||
// /providers URL, appending a phantom ?claim=… param. We fix it at
|
||||
// the DrillableCell level so future tables that adopt the component
|
||||
// are correct by default.
|
||||
// ---------------------------------------------------------------------
|
||||
it("test_click_does_not_bubble_to_parent", () => {
|
||||
const parentClick = vi.fn();
|
||||
const { container } = render(
|
||||
<div onClick={parentClick}>
|
||||
<DrillableCell onClick={() => {}}>Click me</DrillableCell>
|
||||
</div>,
|
||||
);
|
||||
const btn = container.querySelector("button")!;
|
||||
fireEvent.click(btn);
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { MouseEvent, ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Click handler. Receives the underlying React mouse event so we can
|
||||
* call `e.stopPropagation()` before invoking the caller's logic — see
|
||||
* the JSDoc on the component for why this matters when a DrillableCell
|
||||
* is nested inside a row-level click handler.
|
||||
*/
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => 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.
|
||||
*
|
||||
* Calls `e.stopPropagation()` on the button click before invoking the
|
||||
* caller's handler. This prevents the click from bubbling to a
|
||||
* row-level `onClick` (e.g. Claims' `<TableRow onClick={() => open(c.id)}>`),
|
||||
* which would otherwise re-fire `buildUrl()` on the now-navigated URL
|
||||
* and corrupt history. Precedent: `src/components/inbox/Lane.tsx:41`.
|
||||
*/
|
||||
export function DrillableCell({ children, onClick, disabled, ariaLabel }: Props) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
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
|
||||
//
|
||||
// Compact working-surface header: the day/date, a live clock, and the
|
||||
// two top-line counts ("N items need eyes" and "N done today") that
|
||||
// anchor the operator's day. "Need eyes" is computed by the Inbox
|
||||
// page (sum of actionable lanes) and passed in.
|
||||
//
|
||||
// SP14: payer_rejected (277CA) is now part of the actionable lanes —
|
||||
// it's a working-surface rejection that needs operator follow-up.
|
||||
// The Inbox page sums it into the needEyes count.
|
||||
// Editorial dark hero for the Inbox. Larger than the previous 30px title
|
||||
// so it carries weight against the bright lane surface below. "Inbox."
|
||||
// is the page's anchor, the day/date sits as a serif italic, and the
|
||||
// "N items need eyes / N done today" line replaces the small status
|
||||
// pulse with a more dramatic mono announcement.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { fmt } from "@/lib/format";
|
||||
|
||||
export function InboxHeader({
|
||||
needEyesCount,
|
||||
doneTodayCount,
|
||||
@@ -32,66 +31,152 @@ export function InboxHeader({
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-10 px-6 pt-6 pb-4"
|
||||
className="sticky top-0 z-10 px-6 lg:px-10 pt-6 pb-5"
|
||||
style={{
|
||||
background: "var(--tt-bg)",
|
||||
borderBottom: "1px solid var(--tt-bg-elev)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex items-baseline gap-4">
|
||||
<h1
|
||||
className="display tracking-tight"
|
||||
style={{
|
||||
color: "var(--tt-ink)",
|
||||
fontSize: 30,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
Inbox
|
||||
</h1>
|
||||
<span
|
||||
className="mono uppercase"
|
||||
style={{
|
||||
color: "var(--tt-amber)",
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.18em",
|
||||
}}
|
||||
>
|
||||
· {day} {date}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="mono tabular-nums"
|
||||
<div className="relative">
|
||||
{/* Ghost "TRIAGE" watermark — a print-shop stamp behind the title. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none select-none absolute inset-x-0 top-1/2 -translate-y-1/2 whitespace-nowrap display text-center"
|
||||
style={{
|
||||
color: "var(--tt-ink-dim)",
|
||||
fontSize: 12,
|
||||
letterSpacing: "0.05em",
|
||||
fontSize: "clamp(120px, 18vw, 260px)",
|
||||
letterSpacing: "-0.05em",
|
||||
opacity: 0.05,
|
||||
lineHeight: 1,
|
||||
color: "var(--tt-amber)",
|
||||
}}
|
||||
>
|
||||
{time}
|
||||
</span>
|
||||
TRIAGE
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-end justify-between gap-6 flex-wrap">
|
||||
<div className="min-w-0 max-w-3xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div
|
||||
className="h-px w-14"
|
||||
style={{ backgroundColor: "var(--tt-amber)" }}
|
||||
/>
|
||||
<span
|
||||
className="mono uppercase"
|
||||
style={{
|
||||
color: "var(--tt-amber)",
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.22em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Inbox · Working surface
|
||||
</span>
|
||||
</div>
|
||||
<h1
|
||||
className="display tracking-[-0.04em]"
|
||||
style={{
|
||||
color: "var(--tt-ink)",
|
||||
fontSize: "clamp(48px, 6vw, 80px)",
|
||||
lineHeight: 0.92,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
Inbox.
|
||||
</h1>
|
||||
<p
|
||||
className="display italic mt-3"
|
||||
style={{
|
||||
color: "var(--tt-ink-dim)",
|
||||
fontSize: "clamp(15px, 1.4vw, 19px)",
|
||||
lineHeight: 1.4,
|
||||
maxWidth: "44ch",
|
||||
}}
|
||||
>
|
||||
Five lanes, one queue.{" "}
|
||||
<span style={{ color: "var(--tt-amber)" }}>
|
||||
{needEyesCount}
|
||||
</span>{" "}
|
||||
need eyes; {doneTodayCount} are done today.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-col items-start lg:items-end gap-2"
|
||||
aria-label="Day and time"
|
||||
>
|
||||
<div
|
||||
className="mono uppercase"
|
||||
style={{
|
||||
color: "var(--tt-ink-dim)",
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.22em",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{day} {date}
|
||||
</div>
|
||||
<div
|
||||
className="display tabular-nums"
|
||||
style={{
|
||||
color: "var(--tt-ink)",
|
||||
fontSize: 26,
|
||||
lineHeight: 1,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{time}
|
||||
</div>
|
||||
<div
|
||||
className="mono uppercase"
|
||||
style={{
|
||||
color: "var(--tt-ink-dim)",
|
||||
fontSize: 10,
|
||||
letterSpacing: "0.20em",
|
||||
}}
|
||||
>
|
||||
local
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live status row — a horizontal "ticker" of the lane counts so
|
||||
the operator can read the queue at a glance. */}
|
||||
<div
|
||||
className="relative mt-5 flex items-center gap-5 flex-wrap mono uppercase"
|
||||
style={{
|
||||
color: "var(--tt-ink-dim)",
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.14em",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block w-1.5 h-1.5 rounded-full animate-pulse-dot"
|
||||
style={{ background: "var(--tt-amber)" }}
|
||||
/>
|
||||
<span>Live</span>
|
||||
</div>
|
||||
<span className="opacity-50">·</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
|
||||
</span>
|
||||
<span className="opacity-50 hidden sm:inline">·</span>
|
||||
<span className="hidden sm:inline">
|
||||
{fmt.date(new Date().toISOString())}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className="mono mt-2 flex items-center gap-2"
|
||||
style={{
|
||||
color: "var(--tt-ink-dim)",
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full animate-pulse-dot"
|
||||
style={{ background: "var(--tt-amber)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span style={{ color: "var(--tt-amber)", fontWeight: 600 }}>{needEyesCount}</span>
|
||||
items need eyes
|
||||
<span className="opacity-50">·</span>
|
||||
<span style={{ color: "var(--tt-ink)", fontWeight: 600 }}>{doneTodayCount}</span>
|
||||
done today
|
||||
</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
+109
-68
@@ -1,87 +1,128 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table
|
||||
//
|
||||
// Shared table primitive used by Claims, Remittances, Batches, Acks, etc.
|
||||
//
|
||||
// `tone="paper"` swaps the dark-mode row chrome (muted/20 header, muted/30
|
||||
// hover) for paper-toned chrome (cream surface, soft hairline border,
|
||||
// tinted hover). Paper-toned tables sit inside the cream "paper plane"
|
||||
// sections of the hybrid Magazine Spread layout. The default `tone="dark"`
|
||||
// is unchanged from the original look so existing callers keep their
|
||||
// behavior. Pages pass the tone prop once on `<Table>` and the children
|
||||
// inherit the matching colors via the data-tone attribute — no need to
|
||||
// rewrite every TableHead/TableRow/TableCell.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Tone = "dark" | "paper";
|
||||
|
||||
type TableProps = React.HTMLAttributes<HTMLTableElement> & {
|
||||
tone?: Tone;
|
||||
};
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, TableProps>(
|
||||
({ className, tone = "dark", ...props }, ref) => (
|
||||
<div
|
||||
className={cn("relative w-full overflow-auto", className)}
|
||||
data-tone={tone}
|
||||
>
|
||||
<table ref={ref} className="w-full caption-bottom text-sm" {...props} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_tr]:border-b [&_tr]:border-border/60 [&_tr]:bg-muted/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
type TableSectionProps = React.HTMLAttributes<HTMLTableSectionElement>;
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, TableSectionProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
// Paper-tone: cream-papered header band, soft border, no dark muted fill.
|
||||
return (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_tr]:border-b [&_tr]:border-border/60 [&_tr]:bg-muted/20",
|
||||
// When the parent <Table> is paper-toned, swap to cream chrome.
|
||||
"[[data-tone=paper]_&]:bg-[hsl(36_22%_92%)]",
|
||||
"[[data-tone=paper]_&]:[&_tr]:bg-[hsl(36_22%_92%)]",
|
||||
"[[data-tone=paper]_&]:[&_tr]:border-[hsl(30_14%_14%_/_0.10)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, TableSectionProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b border-border/40 transition-colors hover:bg-muted/30 focus-within:bg-muted/40 data-[state=selected]:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
type TableRowProps = React.HTMLAttributes<HTMLTableRowElement>;
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, TableRowProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b border-border/40 transition-colors hover:bg-muted/30 focus-within:bg-muted/40 data-[state=selected]:bg-muted/50",
|
||||
// Paper-tone: warm cream hover, soft hairline between rows.
|
||||
"[[data-tone=paper]_&]:border-[hsl(30_14%_14%_/_0.08)]",
|
||||
"[[data-tone=paper]_&]:hover:bg-[hsl(36_22%_94%)]",
|
||||
"[[data-tone=paper]_&]:focus-within:bg-[hsl(36_22%_94%)]",
|
||||
"[[data-tone=paper]_&]:data-[state=selected]:bg-[hsl(212_85%_95%)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, scope = "col", ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
scope={scope}
|
||||
className={cn(
|
||||
"h-9 px-4 text-left align-middle text-[10.5px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80 [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
type TableHeadProps = React.ThHTMLAttributes<HTMLTableCellElement>;
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, TableHeadProps>(
|
||||
({ className, scope = "col", ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
scope={scope}
|
||||
className={cn(
|
||||
"h-9 px-4 text-left align-middle text-[10.5px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80 [&:has([role=checkbox])]:pr-0",
|
||||
// Paper-tone: surface-ink-2 (warm dark) instead of cool muted-foreground.
|
||||
"[[data-tone=paper]_&]:text-[hsl(var(--surface-ink-2))]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, TableHeadProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0",
|
||||
// Paper-tone: warm foreground (surface-ink) for the primary text.
|
||||
"[[data-tone=paper]_&]:text-[hsl(var(--surface-ink))]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||
export type { Tone as TableTone };
|
||||
|
||||
@@ -164,6 +164,12 @@ function buildActivity(claims: Claim[]): Activity[] {
|
||||
timestamp: c.submissionDate,
|
||||
npi: c.providerNpi,
|
||||
amount: c.billedAmount,
|
||||
// SP21 Task 2.5: mirror the backend `recent_activity()` wire
|
||||
// shape so the Dashboard routing helper can find the claim id.
|
||||
// Sample-data remits are never a 1:1 Activity row, so the
|
||||
// remittance id is always null here.
|
||||
claimId: c.id,
|
||||
remittanceId: null,
|
||||
};
|
||||
});
|
||||
return events.sort(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { useAppStore } from "@/store";
|
||||
import type { Provider } from "@/types";
|
||||
|
||||
/**
|
||||
* Per-provider detail drawer query (SP21 Task 2.2).
|
||||
*
|
||||
* Twin of `useClaimDetail` — same return shape, same retry semantics,
|
||||
* same demo-mode fallback story. Returns `{ data, isLoading, isError,
|
||||
* error, refetch }`:
|
||||
* - `npi === null` (drawer closed): the query is disabled and the
|
||||
* hook short-circuits to the empty drawer state so a closed drawer
|
||||
* doesn't burn a network request or a TanStack cache slot.
|
||||
* - `npi` is set AND a backend is configured: fetches
|
||||
* `GET /api/config/providers/{npi}` via `api.getProvider`. Cached
|
||||
* 60 s — provider directory rows change infrequently, but we still
|
||||
* want the drawer to refresh when a user reopens it across a long
|
||||
* session.
|
||||
* - On 404: the hook's retry predicate short-circuits (no retries) so
|
||||
* the drawer's not-found state appears immediately rather than
|
||||
* being masked by three back-to-back retry attempts.
|
||||
* - `!api.isConfigured` (demo mode): no fetch is issued. The hook
|
||||
* reads from the in-memory zustand store (`useAppStore.providers`).
|
||||
* An unknown NPI surfaces as `isError: true` so the drawer's
|
||||
* error branch handles it instead of spinning on a missing fetch.
|
||||
*/
|
||||
export function useProviderDetail(npi: string | null): {
|
||||
data: Provider | null;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
} {
|
||||
const fallback = useSyncExternalStore(
|
||||
(cb) => useAppStore.subscribe(cb),
|
||||
() => useAppStore.getState().providers,
|
||||
() => useAppStore.getState().providers
|
||||
);
|
||||
|
||||
const q = useQuery<Provider>({
|
||||
queryKey: ["provider-detail", npi],
|
||||
queryFn: () => api.getProvider(npi as string),
|
||||
enabled: npi !== null && api.isConfigured,
|
||||
staleTime: 60 * 1000,
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status === 404) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
});
|
||||
|
||||
if (!api.isConfigured) {
|
||||
const provider =
|
||||
npi === null ? null : fallback.find((p) => p.npi === npi) ?? null;
|
||||
return {
|
||||
data: provider,
|
||||
isLoading: false,
|
||||
isError: provider === null && npi !== null,
|
||||
error:
|
||||
provider === null && npi !== null
|
||||
? new ApiError(404, "Provider not found")
|
||||
: null,
|
||||
refetch: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
if (npi === null) {
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: q.data ?? null,
|
||||
isLoading: q.isLoading,
|
||||
isError: q.isError,
|
||||
error: q.error,
|
||||
refetch: () => {
|
||||
void q.refetch();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Mirror the IS_REACT_ACT_ENVIRONMENT setup from useDrawerUrlState.test.ts
|
||||
// so React doesn't log act() warnings about the createRoot render/unmount
|
||||
// and the popstate-driven state updates.
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { useProviderDrawerUrlState } from "./useProviderDrawerUrlState";
|
||||
|
||||
/**
|
||||
* Minimal renderHook shim — same pattern as the other hook tests.
|
||||
*/
|
||||
function renderHook<TResult>(setup: () => TResult): {
|
||||
result: { current: TResult | undefined };
|
||||
unmount: () => void;
|
||||
} {
|
||||
const result: { current: TResult | undefined } = { current: undefined };
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
function Probe() {
|
||||
result.current = setup();
|
||||
return null;
|
||||
}
|
||||
|
||||
const root: Root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(React.createElement(Probe));
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Point happy-dom's URL at a known value. happy-dom v20 doesn't expose a
|
||||
* writable `window.location.search`, but `window.happyDOM.setURL` updates
|
||||
* the URL the window reports without triggering a navigation — exactly
|
||||
* what we want for mounting the hook at `/providers?provider=NPI` etc.
|
||||
*/
|
||||
function setLocation(url: string): void {
|
||||
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url);
|
||||
}
|
||||
|
||||
describe("useProviderDrawerUrlState", () => {
|
||||
type PushState = (state: unknown, unused: string, url?: string | URL | null) => void;
|
||||
let pushStateMock: ReturnType<typeof vi.fn<PushState>>;
|
||||
let replaceStateMock: ReturnType<typeof vi.fn<PushState>>;
|
||||
|
||||
beforeEach(() => {
|
||||
pushStateMock = vi.fn();
|
||||
replaceStateMock = vi.fn();
|
||||
|
||||
vi.stubGlobal("history", {
|
||||
pushState: pushStateMock,
|
||||
replaceState: replaceStateMock,
|
||||
state: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
setLocation("http://localhost/");
|
||||
});
|
||||
|
||||
it("reads the ?provider= param from window.location.search on mount", () => {
|
||||
setLocation("http://localhost/providers?provider=1881068062");
|
||||
|
||||
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
|
||||
|
||||
expect(result.current?.providerNpi).toBe("1881068062");
|
||||
expect(typeof result.current?.open).toBe("function");
|
||||
expect(typeof result.current?.close).toBe("function");
|
||||
expect(typeof result.current?.setProviderNpi).toBe("function");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("returns null providerNpi when no ?provider= param is set", () => {
|
||||
setLocation("http://localhost/providers");
|
||||
|
||||
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
|
||||
|
||||
expect(result.current?.providerNpi).toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("returns null providerNpi when ?provider= is present but empty", () => {
|
||||
setLocation("http://localhost/providers?provider=");
|
||||
|
||||
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
|
||||
|
||||
expect(result.current?.providerNpi).toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("open(npi) pushes a new history entry containing ?provider=npi", () => {
|
||||
setLocation("http://localhost/providers");
|
||||
|
||||
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
|
||||
|
||||
act(() => {
|
||||
result.current?.open("1881068062");
|
||||
});
|
||||
|
||||
expect(pushStateMock).toHaveBeenCalledTimes(1);
|
||||
expect(replaceStateMock).not.toHaveBeenCalled();
|
||||
const urlArg = pushStateMock.mock.calls[0][2] as string;
|
||||
expect(urlArg).toContain("?provider=1881068062");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("setProviderNpi(npi) replaces the current history entry (no new entry) and does NOT pushState", () => {
|
||||
setLocation("http://localhost/providers?provider=1881068062");
|
||||
|
||||
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
|
||||
|
||||
act(() => {
|
||||
result.current?.setProviderNpi("1881068063");
|
||||
});
|
||||
|
||||
expect(replaceStateMock).toHaveBeenCalledTimes(1);
|
||||
expect(pushStateMock).not.toHaveBeenCalled();
|
||||
const urlArg = replaceStateMock.mock.calls[0][2] as string;
|
||||
expect(urlArg).toContain("?provider=1881068063");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("open() and close() preserve other query params (only ?provider= is touched)", () => {
|
||||
setLocation("http://localhost/providers?sort=name&provider=1881068062");
|
||||
|
||||
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
|
||||
|
||||
act(() => {
|
||||
result.current?.open("1881068063");
|
||||
});
|
||||
const openUrl = pushStateMock.mock.calls[0][2] as string;
|
||||
expect(openUrl).toContain("sort=name");
|
||||
expect(openUrl).toMatch(/[?&]provider=1881068063/);
|
||||
|
||||
act(() => {
|
||||
result.current?.close();
|
||||
});
|
||||
const closeUrl = pushStateMock.mock.calls[1][2] as string;
|
||||
expect(closeUrl).toContain("sort=name");
|
||||
expect(closeUrl).not.toContain("provider=");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("close() pushes a new history entry with the ?provider= param stripped", () => {
|
||||
setLocation("http://localhost/providers?provider=1881068062");
|
||||
|
||||
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
|
||||
|
||||
act(() => {
|
||||
result.current?.close();
|
||||
});
|
||||
|
||||
expect(pushStateMock).toHaveBeenCalledTimes(1);
|
||||
expect(replaceStateMock).not.toHaveBeenCalled();
|
||||
const urlArg = pushStateMock.mock.calls[0][2] as string;
|
||||
expect(urlArg).not.toContain("?provider=");
|
||||
expect(result.current?.providerNpi).toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("updates providerNpi in response to popstate (browser back/forward)", async () => {
|
||||
setLocation("http://localhost/providers?provider=1881068062");
|
||||
|
||||
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
|
||||
|
||||
expect(result.current?.providerNpi).toBe("1881068062");
|
||||
|
||||
setLocation("http://localhost/providers?provider=1881068063");
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current?.providerNpi).toBe("1881068063");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("does not collide with the existing ?claim= or ?remit= params (orthogonal keys)", () => {
|
||||
// The providers drawer is independent of the claims and remits
|
||||
// drawers — all three can be open simultaneously in the future, and
|
||||
// the hooks must not stomp each other's URL state. Opening a
|
||||
// provider must leave ?claim= and ?remit= intact.
|
||||
setLocation("http://localhost/?claim=CLM-1&remit=REM-1");
|
||||
|
||||
const { result, unmount } = renderHook(() => useProviderDrawerUrlState());
|
||||
|
||||
act(() => {
|
||||
result.current?.open("1881068062");
|
||||
});
|
||||
const openUrl = pushStateMock.mock.calls[0][2] as string;
|
||||
expect(openUrl).toContain("claim=CLM-1");
|
||||
expect(openUrl).toContain("remit=REM-1");
|
||||
expect(openUrl).toMatch(/[?&]provider=1881068062/);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Read the current `?provider=…` query param off `window.location.search`.
|
||||
* Returns `null` when the param is absent or empty.
|
||||
*
|
||||
* `URLSearchParams` is the standard, locale-free way to parse query
|
||||
* strings in the browser. Using it (rather than hand-rolled string
|
||||
* slicing) means we correctly handle multiple params and percent-encoded
|
||||
* characters in NPIs without surprises.
|
||||
*
|
||||
* Param name is `?provider=` — chosen to mirror the existing
|
||||
* `?provider=NPI` drilldown convention used by the dashboard's
|
||||
* "Top providers" row (so deep links survive across the app) and to
|
||||
* stay alphabetically parallel with `?claim=` and `?remit=` (one-word
|
||||
* tokens, no collision with the existing `MatchedProviderCard`).
|
||||
*/
|
||||
function readProviderNpi(): string | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const value = params.get("provider");
|
||||
return value === "" ? null : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL we want to push/replace into history.
|
||||
*
|
||||
* - `providerNpi === null` → drop the `?provider=` param, preserving
|
||||
* any other params (e.g. `?page=2&provider=…` keeps `page=2`).
|
||||
* - `providerNpi !== null` → set the param to the new NPI, also
|
||||
* preserving any other params.
|
||||
*
|
||||
* We return `pathname + search + hash` (a relative URL) rather than the
|
||||
* full href — `history.pushState` accepts a relative URL and rewriting
|
||||
* only the relative form keeps the document's origin stable.
|
||||
*/
|
||||
function buildUrl(providerNpi: string | null): string {
|
||||
const url = new URL(window.location.href);
|
||||
if (providerNpi === null) {
|
||||
url.searchParams.delete("provider");
|
||||
} else {
|
||||
url.searchParams.set("provider", providerNpi);
|
||||
}
|
||||
return url.pathname + url.search + url.hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-provider detail drawer URL state (ProviderDrawer).
|
||||
*
|
||||
* Mirrors `useRemitDrawerUrlState` but for the providers drawer — reads
|
||||
* `?provider=` from the URL on mount and keeps the value in sync with
|
||||
* history as the drawer is opened, navigated (j/k), and closed.
|
||||
*
|
||||
* - `providerNpi`: the NPI parsed from the URL (or `null` when the
|
||||
* param is absent). React state so consumers re-render on changes.
|
||||
* - `open(npi)`: pushes a NEW history entry with `?provider={npi}` —
|
||||
* so the browser Back button returns to the previous page (e.g. the
|
||||
* providers list) and not just to the previously-open provider.
|
||||
* - `setProviderNpi(npi)`: REPLACES the current history entry — used
|
||||
* by the j/k nav handler so j/k moves through the list without
|
||||
* polluting history with one entry per keystroke.
|
||||
* - `close()`: pushes a NEW entry that strips the param, so Back from
|
||||
* the closed drawer returns to whatever page the user was on before
|
||||
* opening the drawer.
|
||||
*
|
||||
* The hook subscribes to `popstate` so that browser Back/Forward
|
||||
* (which fire popstate rather than our own pushState) propagate into
|
||||
* the React state. Without this, hitting Back would change the URL but
|
||||
* leave the drawer open on the stale NPI.
|
||||
*/
|
||||
export function useProviderDrawerUrlState(): {
|
||||
providerNpi: string | null;
|
||||
open: (npi: string) => void;
|
||||
close: () => void;
|
||||
setProviderNpi: (npi: string) => void;
|
||||
} {
|
||||
const [providerNpi, setProviderNpiState] = useState<string | null>(() => readProviderNpi());
|
||||
|
||||
const open = useCallback((npi: string) => {
|
||||
window.history.pushState(null, "", buildUrl(npi));
|
||||
setProviderNpiState(npi);
|
||||
}, []);
|
||||
|
||||
const setProviderNpi = useCallback((npi: string) => {
|
||||
window.history.replaceState(null, "", buildUrl(npi));
|
||||
setProviderNpiState(npi);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
window.history.pushState(null, "", buildUrl(null));
|
||||
setProviderNpiState(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => {
|
||||
setProviderNpiState(readProviderNpi());
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, []);
|
||||
|
||||
return { providerNpi, open, close, setProviderNpi };
|
||||
}
|
||||
@@ -413,3 +413,18 @@
|
||||
animation-duration: 0s !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Universal drill-down affordance — applied by DrillableCell. */
|
||||
.drillable {
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
.drillable:hover {
|
||||
background-color: hsl(var(--accent) / 0.08);
|
||||
}
|
||||
.drillable:hover::after {
|
||||
content: "›";
|
||||
margin-left: 6px;
|
||||
color: hsl(var(--accent));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
+65
-1
@@ -16,7 +16,8 @@
|
||||
* - `parse835(...)` mirrors the 837 shape for `/api/parse-835`.
|
||||
* - `health()` GETs `/api/health` and returns `{ status, version }`.
|
||||
* - `listBatches / getBatch / listClaims / listRemittances / listProviders
|
||||
* / listActivity` are plain JSON GETs against the persistence surface.
|
||||
* / getProvider / listActivity` are plain JSON GETs against the
|
||||
* persistence surface.
|
||||
* - `listUnmatched / matchRemit / unmatchClaim` hit the reconciliation
|
||||
* surface. POSTs throw `ApiError` so callers can branch on `.status`.
|
||||
*/
|
||||
@@ -36,6 +37,7 @@ import type {
|
||||
Payer,
|
||||
Payer835,
|
||||
Payee835,
|
||||
Provider,
|
||||
ReassociationTrace,
|
||||
UnmatchedClaim,
|
||||
UnmatchedResponse,
|
||||
@@ -586,6 +588,66 @@ async function listProviders<T = unknown>(
|
||||
return (await res.json()) as PaginatedResponse<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one configured provider by NPI, used by the provider drill-down
|
||||
* drawer (SP21 Task 2.2 / 1.6).
|
||||
*
|
||||
* Drives ``GET /api/config/providers/{npi}``. Note the ``/api/config/``
|
||||
* prefix — this is the config-side route namespace, distinct from the
|
||||
* persistence-side ``/api/providers`` used by ``listProviders``. The
|
||||
* response is the full ``Provider`` shape (including the optional
|
||||
* ``recent_claims`` and ``recent_activity`` arrays populated by Task
|
||||
* 1.6 when the backend can serve them).
|
||||
*
|
||||
* Throws ``ApiError`` on non-2xx — including 404, which the drawer may
|
||||
* want to branch on for a "provider no longer configured" state.
|
||||
*/
|
||||
async function getProvider(npi: string): Promise<Provider> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(
|
||||
joinUrl(`/api/config/providers/${encodeURIComponent(npi)}`),
|
||||
{ headers: { Accept: "application/json" } }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorBody(res);
|
||||
throw new ApiError(res.status, detail || res.statusText);
|
||||
}
|
||||
return (await res.json()) as Provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate stats for one payer, used by the peek modal on Dashboard /
|
||||
* Claims tables (SP21 universal drill-down). Drives
|
||||
* `GET /api/payers/{payer_id}/summary`.
|
||||
*
|
||||
* `denial_rate` is a fraction in `[0, 1]` (the API does NOT pre-multiply).
|
||||
* `top_providers` is the top 3 (or fewer) by claim count, ordered
|
||||
* server-side.
|
||||
*/
|
||||
export interface PayerSummary {
|
||||
payer_id: string;
|
||||
name: string;
|
||||
claim_count: number;
|
||||
billed_total: number;
|
||||
received_total: number;
|
||||
denial_rate: number;
|
||||
top_providers: Array<{ npi: string; count: number }>;
|
||||
}
|
||||
|
||||
async function getPayerSummary(payerId: string): Promise<PayerSummary> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(
|
||||
joinUrl(`/api/payers/${encodeURIComponent(payerId)}/summary`)
|
||||
);
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorBody(res);
|
||||
throw new Error(
|
||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
||||
);
|
||||
}
|
||||
return (await res.json()) as PayerSummary;
|
||||
}
|
||||
|
||||
async function listActivity<T = unknown>(
|
||||
params: ListActivityParams = {}
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
@@ -740,6 +802,8 @@ export const api = {
|
||||
listRemittances,
|
||||
getRemittance,
|
||||
listProviders,
|
||||
getProvider,
|
||||
getPayerSummary,
|
||||
listActivity,
|
||||
listUnmatched,
|
||||
matchRemit,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { eventKindToUrl, type RoutableEvent } from "./event-routing";
|
||||
|
||||
// A minimal event factory keeps the assertions short and keeps the
|
||||
// focus on what the helper actually inspects (kind + the relevant
|
||||
// entity-id field for that kind).
|
||||
function evt(overrides: Partial<RoutableEvent> & { kind: RoutableEvent["kind"] }): RoutableEvent {
|
||||
return {
|
||||
claimId: null,
|
||||
remittanceId: null,
|
||||
npi: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("eventKindToUrl", () => {
|
||||
// ----- claim_* → /claims?claim=ID -------------------------------
|
||||
it("routes claim_submitted to /claims?claim=ID", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_submitted", claimId: "CLM-1" })),
|
||||
).toBe("/claims?claim=CLM-1");
|
||||
});
|
||||
|
||||
it("routes claim_paid to /claims?claim=ID", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_paid", claimId: "CLM-2" })),
|
||||
).toBe("/claims?claim=CLM-2");
|
||||
});
|
||||
|
||||
it("routes claim_denied to /claims?claim=ID", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_denied", claimId: "CLM-3" })),
|
||||
).toBe("/claims?claim=CLM-3");
|
||||
});
|
||||
|
||||
it("routes claim_accepted to /claims?claim=ID", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_accepted", claimId: "CLM-4" })),
|
||||
).toBe("/claims?claim=CLM-4");
|
||||
});
|
||||
|
||||
it("percent-encodes the claim id when it contains special chars", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_paid", claimId: "CLM/1+2" })),
|
||||
).toBe("/claims?claim=CLM%2F1%2B2");
|
||||
});
|
||||
|
||||
it("returns null for claim_* when claimId is missing", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_paid", claimId: null })),
|
||||
).toBeNull();
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_paid", claimId: undefined })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// ----- remit_received → null (Phase 4) --------------------------
|
||||
it("returns null for remit_received (Phase 4 drawer not built yet)", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "remit_received", remittanceId: "REM-1" })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// ----- provider_added → /providers?provider=NPI -----------------
|
||||
it("routes provider_added to /providers?provider=NPI", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "provider_added", npi: "1730187395" })),
|
||||
).toBe("/providers?provider=1730187395");
|
||||
});
|
||||
|
||||
it("returns null for provider_added when npi is missing", () => {
|
||||
expect(eventKindToUrl(evt({ kind: "provider_added", npi: undefined }))).toBeNull();
|
||||
});
|
||||
|
||||
// ----- default branch ------------------------------------------
|
||||
it("returns null for unhandled kinds (manual_match)", () => {
|
||||
expect(eventKindToUrl(evt({ kind: "manual_match" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown kinds (defensive default)", () => {
|
||||
// Cast through unknown so the type-checker doesn't widen the
|
||||
// literal away from the exhaustive union.
|
||||
const unknown = { kind: "future_kind" } as unknown as RoutableEvent;
|
||||
expect(eventKindToUrl(unknown)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Activity } from "@/types";
|
||||
|
||||
/**
|
||||
* The minimum an activity event must carry to be routable. Centralizing
|
||||
* this here means callers can pass an `Activity` (full feed row) or a
|
||||
* narrower shape (e.g. a future "activity event" peek payload) — both
|
||||
* satisfy the kind + entity-id fields the helper inspects.
|
||||
*
|
||||
* - `claimId` / `remittanceId` come from the backend `ActivityEvent`
|
||||
* ORM columns (SP21 Task 2.5). `claimId` is set on `claim_*` events;
|
||||
* `remittanceId` is set on `remit_received`.
|
||||
* - `npi` is the existing `Activity.npi` field. For `provider_added`
|
||||
* events the provider NPI IS the entity id (the URL target).
|
||||
*/
|
||||
export type RoutableEvent = Pick<
|
||||
Activity,
|
||||
"kind" | "claimId" | "remittanceId" | "npi"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Maps an activity event to the URL the operator should land on when
|
||||
* clicking the event. Used by:
|
||||
*
|
||||
* - Dashboard "Recent activity" card (this PR, Task 2.5).
|
||||
* - `/activity` log page (Task 2.5 wired here; full integration is
|
||||
* a later task).
|
||||
*
|
||||
* Returns `null` for kinds that don't have a drill target yet, or
|
||||
* when the entity id field is missing on the event. Callers are
|
||||
* expected to surface a "coming soon" toast in that case so the click
|
||||
* still gives feedback.
|
||||
*/
|
||||
export function eventKindToUrl(event: RoutableEvent): string | null {
|
||||
switch (event.kind) {
|
||||
case "claim_submitted":
|
||||
case "claim_paid":
|
||||
case "claim_denied":
|
||||
case "claim_accepted": {
|
||||
if (!event.claimId) return null;
|
||||
return `/claims?claim=${encodeURIComponent(event.claimId)}`;
|
||||
}
|
||||
case "remit_received":
|
||||
// Phase 4 — the `RemitDrawer` isn't built yet; the helper stays
|
||||
// honest and returns null so the caller's "coming soon" toast
|
||||
// fires. When the drawer lands, the route becomes
|
||||
// `/remittances?remit=${encodeURIComponent(event.remittanceId ?? "")}`.
|
||||
return null;
|
||||
case "provider_added": {
|
||||
if (!event.npi) return null;
|
||||
return `/providers?provider=${encodeURIComponent(event.npi)}`;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+703
-143
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { CheckCircle2, Download } from "lucide-react";
|
||||
import { CheckCircle2, Download, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -27,18 +27,30 @@ import type { Ack } from "@/types";
|
||||
function AckCodeBadge({ code }: { code: Ack["ackCode"] }) {
|
||||
const color =
|
||||
code === "A"
|
||||
? "text-emerald-400 border-emerald-400/30 bg-emerald-400/10"
|
||||
: code === "E"
|
||||
? "text-amber-400 border-amber-400/30 bg-amber-400/10"
|
||||
: code === "P"
|
||||
? "text-amber-400 border-amber-400/30 bg-amber-400/10"
|
||||
: "text-red-400 border-red-400/30 bg-red-400/10";
|
||||
? {
|
||||
text: "hsl(152 64% 30%)",
|
||||
bg: "hsl(152 50% 88%)",
|
||||
border: "hsl(152 64% 38% / 0.30)",
|
||||
}
|
||||
: code === "R"
|
||||
? {
|
||||
text: "hsl(358 70% 36%)",
|
||||
bg: "hsl(358 70% 92%)",
|
||||
border: "hsl(358 70% 50% / 0.30)",
|
||||
}
|
||||
: {
|
||||
text: "hsl(36 92% 30%)",
|
||||
bg: "hsl(36 82% 88%)",
|
||||
border: "hsl(36 92% 50% / 0.30)",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10.5px] font-semibold uppercase tracking-[0.14em]",
|
||||
color,
|
||||
)}
|
||||
className="inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
|
||||
style={{
|
||||
color: color.text,
|
||||
backgroundColor: color.bg,
|
||||
borderColor: color.border,
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
@@ -62,26 +74,15 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
|
||||
setBusy(true);
|
||||
try {
|
||||
const detail = await api.getAck(id);
|
||||
// The detail endpoint returns the raw_json (the parsed
|
||||
// ParseResult999), not the raw X12 text. Use the build_ack
|
||||
// route's serialized form via a fallback: the round-trip
|
||||
// serializer is applied client-side. For v1 we just
|
||||
// serialize the raw_json envelope/segments if we have them.
|
||||
const raw =
|
||||
(detail as unknown as { raw_999_text?: string }).raw_999_text ??
|
||||
(() => {
|
||||
// Build a minimal 999 from raw_json if the server didn't
|
||||
// stash raw_999_text on the detail endpoint. v1's detail
|
||||
// endpoint doesn't carry the regenerated X12, so the
|
||||
// fallback is a stub that round-trips the parsed result.
|
||||
return "";
|
||||
})();
|
||||
if (raw) {
|
||||
downloadBlob(`ack-${sourceBatchId}.999`, raw);
|
||||
}
|
||||
} catch (err) {
|
||||
// Swallow — the operator can retry. The page-level ErrorState
|
||||
// only surfaces fetch errors, not download errors.
|
||||
console.error("download 999 failed", err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -93,10 +94,14 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
|
||||
onClick={onClick}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2 py-1 text-[11.5px] font-medium",
|
||||
"hover:bg-muted/40 transition-colors",
|
||||
"inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 mono text-[10.5px] uppercase tracking-[0.14em] font-semibold transition-colors",
|
||||
busy && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.20)",
|
||||
color: "hsl(var(--surface-ink-2))",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
aria-label="Download 999"
|
||||
>
|
||||
<Download className="h-3 w-3" strokeWidth={1.75} />
|
||||
@@ -109,11 +114,6 @@ export function Acks() {
|
||||
const { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 });
|
||||
const items = data?.items ?? [];
|
||||
|
||||
// Row navigation state (SP4-style j/k). `selectedIndex === null`
|
||||
// means "no row focused yet" — the initial render shows no
|
||||
// highlight, and the first j press lands on row 0 (and the first k
|
||||
// press lands on the last row, so the user can quickly jump to
|
||||
// either end of the list).
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
@@ -129,18 +129,10 @@ export function Acks() {
|
||||
setSelectedIndex((i) => {
|
||||
if (items.length === 0) return null;
|
||||
if (i === null) return items.length - 1;
|
||||
// Add items.length before the modulo so the negative index
|
||||
// (first row → wrap to last) wraps correctly.
|
||||
return (i - 1 + items.length) % items.length;
|
||||
});
|
||||
}, [items.length]);
|
||||
|
||||
// `enabled: !helpOpen` so j/k navigation yields to the cheatsheet
|
||||
// while it's open. The cheatsheet's own dismiss listener still fires
|
||||
// for any non-`?` keypress (including j/k), so pressing j will close
|
||||
// the cheatsheet but won't move the row — exactly the behavior the
|
||||
// user expects: "the cheatsheet is in the way, get it out of my
|
||||
// way; I'll start navigating on the next keypress".
|
||||
useRowKeyboard({
|
||||
enabled: !helpOpen && items.length > 0,
|
||||
onNext: moveNext,
|
||||
@@ -149,125 +141,693 @@ export function Acks() {
|
||||
onToggleHelp: () => setHelpOpen((v) => !v),
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Aggregate metrics for the KPI strip
|
||||
// -----------------------------------------------------------------
|
||||
const totals = items.reduce(
|
||||
(acc, a) => ({
|
||||
accepted: acc.accepted + a.acceptedCount,
|
||||
rejected: acc.rejected + a.rejectedCount,
|
||||
received: acc.received + a.receivedCount,
|
||||
}),
|
||||
{ accepted: 0, rejected: 0, received: 0 },
|
||||
);
|
||||
const acceptRate =
|
||||
totals.received > 0
|
||||
? Math.round((totals.accepted / totals.received) * 1000) / 10
|
||||
: 0;
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Stagger choreography
|
||||
// -----------------------------------------------------------------
|
||||
const heroDelay = 0;
|
||||
const foldDelay = 220;
|
||||
const titleDelay = 320;
|
||||
const kpiDelay = 460;
|
||||
const tableDelay = 600;
|
||||
|
||||
return (
|
||||
<>
|
||||
<KeyboardCheatsheet
|
||||
open={helpOpen}
|
||||
onClose={() => setHelpOpen(false)}
|
||||
/>
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
999 ACKs
|
||||
<div className="space-y-0">
|
||||
{/* =================================================================
|
||||
HERO — DARK EDITORIAL HEADER
|
||||
================================================================= */}
|
||||
<section
|
||||
className="relative animate-fade-in pt-6 pb-8 lg:pt-9 lg:pb-10"
|
||||
style={{ animationDelay: `${heroDelay}ms` }}
|
||||
>
|
||||
{/* Ghost "999" watermark — a print-shop stamp behind the title */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none select-none absolute inset-x-0 top-[58%] -translate-y-1/2 whitespace-nowrap display text-center"
|
||||
style={{
|
||||
fontSize: "clamp(180px, 22vw, 320px)",
|
||||
letterSpacing: "-0.05em",
|
||||
opacity: 0.05,
|
||||
lineHeight: 1,
|
||||
color: "hsl(var(--surface-ink))",
|
||||
}}
|
||||
>
|
||||
999
|
||||
</div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">
|
||||
999 Implementation Acknowledgments
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||||
999 ACK transaction sets — generated automatically in response to
|
||||
837P ingests, or parsed from inbound 999 uploads.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message="Couldn't load ACKs from the backend."
|
||||
detail={error instanceof Error ? error.message : String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="surface rounded-xl overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} variant="row" />
|
||||
))}
|
||||
<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>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
eyebrow="999 ACKs · awaiting first 837 with ack=true"
|
||||
message="Upload an 837P with ?ack=true, or parse a 999 file directly to populate this list."
|
||||
<div className="flex flex-col items-start lg:items-end gap-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3.5 py-2 text-[11.5px] mono uppercase tracking-[0.14em] text-muted-foreground backdrop-blur">
|
||||
<ShieldCheck
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
style={{ color: "hsl(var(--success))" }}
|
||||
/>
|
||||
Envelope · accepted / rejected / partial
|
||||
</div>
|
||||
<kbd
|
||||
className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70"
|
||||
>
|
||||
Press <span className="text-foreground">?</span> for shortcuts
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
<p className="text-[14px] text-muted-foreground leading-relaxed">
|
||||
999 Implementation Acknowledgments — generated automatically in
|
||||
response to 837P ingests, or parsed from inbound 999 uploads.
|
||||
Each row below is a single transaction set with its accept /
|
||||
reject counts and the original X12 to download.
|
||||
</p>
|
||||
</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)" }}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12" aria-label="Status" />
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Source Batch</TableHead>
|
||||
<TableHead className="text-right">Accepted</TableHead>
|
||||
<TableHead className="text-right">Rejected</TableHead>
|
||||
<TableHead className="text-right">Received</TableHead>
|
||||
<TableHead>ACK Code</TableHead>
|
||||
<TableHead>Parsed</TableHead>
|
||||
<TableHead className="w-20" aria-label="Download" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((a, idx) => {
|
||||
const isSelected = selectedIndex === idx;
|
||||
return (
|
||||
<TableRow
|
||||
key={a.id}
|
||||
data-row-index={idx}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
isSelected && [
|
||||
// Accent-tinted bg + ring + left strip make the
|
||||
// selected row clearly stand out from the
|
||||
// default `hover:bg-muted/30` and the
|
||||
// `data-[state=selected]:bg-muted` that the
|
||||
// TableRow primitive applies. Tailwind's
|
||||
// `accent` token is the same accent used
|
||||
// elsewhere in the app (nav-active indicator,
|
||||
// row-flash keyframe, focus ring).
|
||||
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
|
||||
],
|
||||
)}
|
||||
>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<CheckCircle2
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
a.ackCode === "A" ? "text-emerald-400" : a.ackCode === "R" ? "text-red-400" : "text-amber-400",
|
||||
)}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
<div className="flex items-end justify-between gap-8 flex-wrap">
|
||||
<div>
|
||||
<div
|
||||
className="mono text-[12px] uppercase tracking-[0.24em] mb-4 font-medium"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
Register · 999 Implementation ACKs
|
||||
</div>
|
||||
<h2
|
||||
className="display leading-[0.92] tracking-[-0.04em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(48px, 7vw, 96px)",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
The register.
|
||||
</h2>
|
||||
<div
|
||||
className="mt-4 display italic"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-2))",
|
||||
fontSize: "clamp(15px, 1.3vw, 18px)",
|
||||
lineHeight: 1.4,
|
||||
maxWidth: "32ch",
|
||||
}}
|
||||
>
|
||||
One row per inbound 999 — the accept / reject / partial
|
||||
count, the source batch it answers, and the original
|
||||
transaction set to download.
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div
|
||||
className="mono text-[11px] uppercase tracking-[0.24em] mb-1.5 font-medium"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
On file
|
||||
</div>
|
||||
<div
|
||||
className="display tabular-nums"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: 26,
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{items.length}
|
||||
</div>
|
||||
<div
|
||||
className="mono text-[11px] mt-1"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
999 ACK
|
||||
{items.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI strip — § 01 Vital signs */}
|
||||
<section
|
||||
aria-label="Aggregate ACK metrics"
|
||||
className="relative px-8 lg:px-14 py-7 animate-fade-in-up"
|
||||
style={{ animationDelay: `${kpiDelay}ms` }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute left-7 lg:left-12 top-7 flex flex-col items-center gap-1"
|
||||
>
|
||||
<span
|
||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
§ 01
|
||||
</span>
|
||||
<div
|
||||
className="w-px h-10"
|
||||
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
|
||||
/>
|
||||
<span
|
||||
className="display italic"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-2))",
|
||||
fontSize: 11,
|
||||
writingMode: "vertical-rl",
|
||||
transform: "rotate(180deg)",
|
||||
letterSpacing: "0.16em",
|
||||
}}
|
||||
>
|
||||
Vital signs
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<AckKpiTile
|
||||
label="Accepted"
|
||||
value={totals.accepted}
|
||||
tone="success"
|
||||
/>
|
||||
<AckKpiTile
|
||||
label="Rejected"
|
||||
value={totals.rejected}
|
||||
tone="destructive"
|
||||
/>
|
||||
<AckKpiTile
|
||||
label="Received"
|
||||
value={totals.received}
|
||||
tone="ink"
|
||||
/>
|
||||
<AckKpiTile
|
||||
label="Accept rate"
|
||||
value={`${acceptRate}%`}
|
||||
tone="blue"
|
||||
numeric={false}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Table — § 02 The register */}
|
||||
<section
|
||||
aria-label="ACK register"
|
||||
className="relative px-8 lg:px-14 pb-8 lg:pb-10 animate-fade-in-up"
|
||||
style={{ animationDelay: `${tableDelay}ms` }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute left-7 lg:left-12 top-9 flex flex-col items-center gap-1"
|
||||
>
|
||||
<span
|
||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
§ 02
|
||||
</span>
|
||||
<div
|
||||
className="w-px h-12"
|
||||
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
|
||||
/>
|
||||
<span
|
||||
className="display italic"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-2))",
|
||||
fontSize: 11,
|
||||
writingMode: "vertical-rl",
|
||||
transform: "rotate(180deg)",
|
||||
letterSpacing: "0.16em",
|
||||
}}
|
||||
>
|
||||
The register
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pt-6 border-t"
|
||||
style={{
|
||||
borderTopStyle: "double",
|
||||
borderTopWidth: 3,
|
||||
borderColor: "hsl(30 14% 14% / 0.12)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-end justify-between gap-6 flex-wrap mb-5">
|
||||
<div>
|
||||
<div
|
||||
className="mono text-[11.5px] uppercase tracking-[0.24em] font-semibold mb-2"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
The register
|
||||
</div>
|
||||
<h3
|
||||
className="display leading-[0.98] tracking-[-0.03em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(24px, 2.6vw, 32px)",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
All <span className="italic">999s</span>, newest first.
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
className="text-[12.5px] display italic"
|
||||
style={{ color: "hsl(var(--surface-ink-2))", maxWidth: 380 }}
|
||||
>
|
||||
Navigate rows with{" "}
|
||||
<kbd
|
||||
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.20)",
|
||||
color: "hsl(var(--surface-ink))",
|
||||
}}
|
||||
>
|
||||
j
|
||||
</kbd>{" "}
|
||||
/{" "}
|
||||
<kbd
|
||||
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.20)",
|
||||
color: "hsl(var(--surface-ink))",
|
||||
}}
|
||||
>
|
||||
k
|
||||
</kbd>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<div
|
||||
className="rounded-md border p-5"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
>
|
||||
<ErrorState
|
||||
message="Couldn't load ACKs from the backend."
|
||||
detail={error instanceof Error ? error.message : String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div
|
||||
className="rounded-md border p-4 space-y-2"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} variant="row" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div
|
||||
className="rounded-md border p-10 text-center"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
>
|
||||
<EmptyState
|
||||
eyebrow="999 ACKs · awaiting first 837 with ack=true"
|
||||
message="Upload an 837P with ?ack=true, or parse a 999 file directly to populate this list."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-md border overflow-hidden"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
backgroundColor: "hsl(36 22% 98%)",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
|
||||
}}
|
||||
>
|
||||
<Table tone="paper">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-12"
|
||||
aria-label="Status"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="display num text-[13px]">{a.id}</TableCell>
|
||||
<TableCell className="font-mono text-[12px] text-muted-foreground truncate max-w-[180px]">
|
||||
{a.sourceBatchId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-emerald-400">
|
||||
{a.acceptedCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-red-400">
|
||||
{a.rejectedCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-muted-foreground">
|
||||
{a.receivedCount}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AckCodeBadge code={a.ackCode} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num text-[12.5px]">
|
||||
{a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DownloadButton id={a.id} sourceBatchId={a.sourceBatchId} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<TableHead style={{ color: "hsl(var(--surface-ink-2))" }}>
|
||||
ID
|
||||
</TableHead>
|
||||
<TableHead style={{ color: "hsl(var(--surface-ink-2))" }}>
|
||||
Source Batch
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-right"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
Accepted
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-right"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
Rejected
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-right"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
Received
|
||||
</TableHead>
|
||||
<TableHead style={{ color: "hsl(var(--surface-ink-2))" }}>
|
||||
ACK Code
|
||||
</TableHead>
|
||||
<TableHead style={{ color: "hsl(var(--surface-ink-2))" }}>
|
||||
Parsed
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="w-20"
|
||||
aria-label="Download"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
/>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((a, idx) => {
|
||||
const isSelected = selectedIndex === idx;
|
||||
return (
|
||||
<TableRow
|
||||
key={a.id}
|
||||
data-row-index={idx}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
isSelected && [
|
||||
"ring-1 ring-inset",
|
||||
],
|
||||
)}
|
||||
style={
|
||||
isSelected
|
||||
? {
|
||||
backgroundColor: "hsl(212 85% 95%)",
|
||||
boxShadow:
|
||||
"inset 2px 0 0 0 hsl(212 100% 45%)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<CheckCircle2
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
a.ackCode === "A"
|
||||
? "text-[hsl(152_64%_38%)]"
|
||||
: a.ackCode === "R"
|
||||
? "text-[hsl(358_70%_42%)]"
|
||||
: "text-[hsl(36_92%_50%)]",
|
||||
)}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="display num text-[13px]"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{a.id}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="font-mono text-[12px] truncate max-w-[180px]"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{a.sourceBatchId}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right display num"
|
||||
style={{ color: "hsl(152 64% 32%)" }}
|
||||
>
|
||||
{a.acceptedCount}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right display num"
|
||||
style={{ color: "hsl(358 70% 38%)" }}
|
||||
>
|
||||
{a.rejectedCount}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right display num"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{a.receivedCount}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AckCodeBadge code={a.ackCode} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="num text-[12.5px]"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DownloadButton
|
||||
id={a.id}
|
||||
sourceBatchId={a.sourceBatchId}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="relative px-8 lg:px-14 py-5 border-t flex items-center justify-between mono text-[10.5px] uppercase tracking-[0.18em]"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.12)",
|
||||
color: "hsl(var(--surface-ink-3))",
|
||||
}}
|
||||
>
|
||||
<span>End of register</span>
|
||||
<span>Cyclone · 999 ACKs</span>
|
||||
<span>
|
||||
{items.length} {items.length === 1 ? "row" : "rows"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AckKpiTile — paper-toned metric for the ACK register.
|
||||
// ---------------------------------------------------------------------------
|
||||
function AckKpiTile({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
numeric = true,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
tone: "success" | "destructive" | "ink" | "blue";
|
||||
numeric?: boolean;
|
||||
}) {
|
||||
const accentMap = {
|
||||
success: "hsl(152 64% 38%)",
|
||||
destructive: "hsl(358 70% 42%)",
|
||||
ink: "hsl(var(--surface-ink))",
|
||||
blue: "hsl(212 100% 45%)",
|
||||
} as const;
|
||||
const tintMap = {
|
||||
success: "hsl(152 50% 88%)",
|
||||
destructive: "hsl(358 70% 92%)",
|
||||
ink: "hsl(36 22% 90%)",
|
||||
blue: "hsl(212 85% 92%)",
|
||||
} as const;
|
||||
const accent = accentMap[tone];
|
||||
const tint = tintMap[tone];
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-xl p-5 overflow-hidden border"
|
||||
style={{
|
||||
backgroundColor: "hsl(var(--surface))",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06), inset 3px 0 0 0 hsl(0 0% 100% / 0.4)",
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute left-0 top-5 bottom-5 w-[3px] rounded-r-sm"
|
||||
style={{ backgroundColor: accent, opacity: 0.85 }}
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<div
|
||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className="h-5 w-5 rounded-md flex items-center justify-center"
|
||||
style={{ backgroundColor: tint }}
|
||||
>
|
||||
<span
|
||||
className="block h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: accent }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"tabular-nums tracking-[-0.04em]",
|
||||
numeric ? "display" : "display"
|
||||
)}
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(28px, 3vw, 40px)",
|
||||
lineHeight: 1,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+621
-101
@@ -1,6 +1,11 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { GitCompareArrows, CircleDashed, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CircleDashed,
|
||||
GitCompareArrows,
|
||||
} from "lucide-react";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -12,7 +17,10 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
import { BatchDiffView, BatchDiffViewSkeleton } from "@/components/BatchDiffView";
|
||||
import {
|
||||
BatchDiffView,
|
||||
BatchDiffViewSkeleton,
|
||||
} from "@/components/BatchDiffView";
|
||||
import { useBatches } from "@/hooks/useBatches";
|
||||
import { useBatchDiff } from "@/hooks/useBatchDiff";
|
||||
import type { BatchSummary as ApiBatchSummary } from "@/lib/api";
|
||||
@@ -38,9 +46,11 @@ function readIdsFromParams(params: URLSearchParams): {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch picker — one Select dropdown for each side. Keeps the same
|
||||
// kind-colored badge inline so the operator can spot which batch is
|
||||
// which at a glance.
|
||||
// Kind badge — small inline label so the operator can spot which batch
|
||||
// is which at a glance. Identical contract to the one in
|
||||
// `BatchesList.tsx`; duplicated here because each lives inside a
|
||||
// different page surface (the picker is part of this page, the row
|
||||
// badge belongs to Batches).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function KindBadge({ kind }: { kind: ApiBatchSummary["kind"] }) {
|
||||
@@ -63,6 +73,7 @@ function KindBadge({ kind }: { kind: ApiBatchSummary["kind"] }) {
|
||||
|
||||
function BatchPicker({
|
||||
label,
|
||||
side,
|
||||
value,
|
||||
onChange,
|
||||
items,
|
||||
@@ -70,6 +81,7 @@ function BatchPicker({
|
||||
testid,
|
||||
}: {
|
||||
label: string;
|
||||
side: "A" | "B";
|
||||
value: string | null;
|
||||
onChange: (id: string) => void;
|
||||
items: ApiBatchSummary[];
|
||||
@@ -85,10 +97,54 @@ function BatchPicker({
|
||||
[items, excludeId],
|
||||
);
|
||||
const selected = items.find((it) => it.id === value);
|
||||
const accent = side === "A" ? "hsl(212 100% 45%)" : "hsl(36 92% 50%)";
|
||||
const tint = side === "A" ? "hsl(212 85% 95%)" : "hsl(36 82% 92%)";
|
||||
return (
|
||||
<div className="space-y-1.5" data-testid={testid}>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{label}
|
||||
<div
|
||||
className="rounded-xl border p-4 space-y-2"
|
||||
data-testid={testid}
|
||||
style={{
|
||||
backgroundColor: "hsl(36 22% 98%)",
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute left-0 top-4 bottom-4 w-[3px] rounded-r-sm"
|
||||
style={{ backgroundColor: accent, opacity: 0.85 }}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
aria-hidden
|
||||
className="h-5 w-5 rounded-md flex items-center justify-center mono font-semibold"
|
||||
style={{ backgroundColor: tint, color: accent, fontSize: 11 }}
|
||||
>
|
||||
{side}
|
||||
</div>
|
||||
<span
|
||||
className="mono text-[10.5px] uppercase tracking-[0.18em] font-semibold"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{selected ? (
|
||||
<span
|
||||
className="display tabular-nums text-[12.5px]"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{selected.claimCount}{" "}
|
||||
<span
|
||||
className="mono not-italic uppercase tracking-[0.14em] text-[10px]"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
claims
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Select value={value ?? ""} onValueChange={(v) => onChange(v)}>
|
||||
<SelectTrigger>
|
||||
@@ -96,7 +152,10 @@ function BatchPicker({
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<KindBadge kind={selected.kind} />
|
||||
<span className="font-mono num text-[12.5px]">{selected.id}</span>
|
||||
<span className="text-muted-foreground text-[12px] truncate">
|
||||
<span
|
||||
className="text-[12px] truncate"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
· {selected.inputFilename}
|
||||
</span>
|
||||
</span>
|
||||
@@ -157,6 +216,50 @@ function NotFoundState({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section header — § N + small italic vertical-rl label. Reused by
|
||||
// both the picks and diff sections so the folio is uniform.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Folio({ section, label, topClass = "top-7" }: {
|
||||
section: string;
|
||||
label: string;
|
||||
topClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute left-7 lg:left-12 flex flex-col items-center gap-1",
|
||||
topClass,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{section}
|
||||
</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",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -193,8 +296,13 @@ export function BatchDiff() {
|
||||
);
|
||||
const reset = useCallback(() => updateIds({ a: null, b: null }), [updateIds]);
|
||||
|
||||
const { data: batches, isLoading: loadingBatches, isError: batchesError, error: batchesErrorMsg, refetch: refetchBatches } =
|
||||
useBatches(100);
|
||||
const {
|
||||
data: batches,
|
||||
isLoading: loadingBatches,
|
||||
isError: batchesError,
|
||||
error: batchesErrorMsg,
|
||||
refetch: refetchBatches,
|
||||
} = useBatches(100);
|
||||
|
||||
// Only fire the diff once both pickers have a value. The hook also
|
||||
// gates on this internally (defense-in-depth), so a half-picked URL
|
||||
@@ -209,112 +317,524 @@ export function BatchDiff() {
|
||||
: "network"
|
||||
: null;
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Stagger choreography
|
||||
// -----------------------------------------------------------------
|
||||
const heroDelay = 0;
|
||||
const foldDelay = 220;
|
||||
const titleDelay = 320;
|
||||
const picksDelay = 460;
|
||||
const diffDelay = 600;
|
||||
|
||||
// --- render -------------------------------------------------------
|
||||
return (
|
||||
<div className="space-y-6 md:space-y-8 animate-fade-in" data-testid="batch-diff-page">
|
||||
<header>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
Diff
|
||||
<div
|
||||
className="space-y-0 animate-fade-in"
|
||||
data-testid="batch-diff-page"
|
||||
>
|
||||
{/* =================================================================
|
||||
HERO — DARK EDITORIAL HEADER
|
||||
================================================================= */}
|
||||
<section
|
||||
className="relative pt-6 pb-8 lg:pt-9 lg:pb-10"
|
||||
style={{ animationDelay: `${heroDelay}ms` }}
|
||||
>
|
||||
{/* Ghost "DIFF" 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(160px, 20vw, 300px)",
|
||||
letterSpacing: "-0.05em",
|
||||
opacity: 0.05,
|
||||
lineHeight: 1,
|
||||
color: "hsl(var(--surface-ink))",
|
||||
}}
|
||||
>
|
||||
DIFF
|
||||
</div>
|
||||
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
|
||||
Batch diff
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||||
Pick two parsed batches — typically a submitted 837P and its
|
||||
corrected follow-up — and see what was added, removed, or changed
|
||||
between them.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="surface rounded-xl p-4 grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
|
||||
<BatchPicker
|
||||
label="A · left"
|
||||
value={a}
|
||||
onChange={setA}
|
||||
items={batches ?? []}
|
||||
excludeId={b}
|
||||
testid="diff-picker-a"
|
||||
<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">
|
||||
Diff · Sheet 01
|
||||
</span>
|
||||
<span
|
||||
className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground/60 hidden sm:inline"
|
||||
aria-hidden
|
||||
>
|
||||
· A vs B
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="display text-[48px] sm:text-[64px] lg:text-[80px] leading-[0.92] text-foreground tracking-[-0.04em]">
|
||||
Compare{" "}
|
||||
<span className="italic text-muted-foreground/85">batches.</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col items-start lg:items-end gap-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3.5 py-2 text-[11.5px] mono uppercase tracking-[0.14em] text-muted-foreground backdrop-blur">
|
||||
<GitCompareArrows
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
style={{ color: "hsl(var(--accent))" }}
|
||||
/>
|
||||
837P · 835 · first vs second
|
||||
</div>
|
||||
<kbd
|
||||
className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70"
|
||||
>
|
||||
Pick a batch for each side
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
<p className="text-[14px] text-muted-foreground leading-relaxed">
|
||||
Pick two parsed batches — typically a submitted 837P and its
|
||||
corrected follow-up — and see what was added, removed, or changed
|
||||
between them.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* =================================================================
|
||||
FOLD — TORN PAGE
|
||||
================================================================= */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative h-14"
|
||||
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)",
|
||||
}}
|
||||
/>
|
||||
<BatchPicker
|
||||
label="B · right"
|
||||
value={b}
|
||||
onChange={setB}
|
||||
items={batches ?? []}
|
||||
excludeId={a}
|
||||
testid="diff-picker-b"
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
A → B
|
||||
</span>
|
||||
<span
|
||||
className="display italic"
|
||||
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
|
||||
>
|
||||
↙
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batchesError ? (
|
||||
<ErrorState
|
||||
message="Couldn't load batches from the backend."
|
||||
detail={
|
||||
batchesErrorMsg instanceof Error
|
||||
? batchesErrorMsg.message
|
||||
: String(batchesErrorMsg)
|
||||
}
|
||||
onRetry={() => refetchBatches()}
|
||||
{/* =================================================================
|
||||
PAPER PLANE
|
||||
================================================================= */}
|
||||
<div
|
||||
className="relative max-w-[1280px] mx-auto"
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!ready ? (
|
||||
<div className="surface rounded-xl">
|
||||
<EmptyState
|
||||
icon={<GitCompareArrows className="h-4 w-4" strokeWidth={1.5} />}
|
||||
eyebrow="Batch diff · awaiting picks"
|
||||
message="Choose one batch for A and one for B to compute the diff."
|
||||
{/* Title block */}
|
||||
<div
|
||||
className="relative px-8 lg:px-14 pt-12 pb-9 border-b animate-fade-in-up"
|
||||
style={{
|
||||
animationDelay: `${titleDelay}ms`,
|
||||
borderColor: "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))" }}
|
||||
>
|
||||
Sheet 01 · Compare two batches
|
||||
</div>
|
||||
<h2
|
||||
className="display leading-[0.92] tracking-[-0.04em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(48px, 7vw, 96px)",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
Compare them.
|
||||
</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",
|
||||
}}
|
||||
>
|
||||
Two picks, three buckets. What the second batch added, what
|
||||
the first batch dropped, and which claims moved between them.
|
||||
</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 the wire
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-end gap-2 tabular-nums"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: 26,
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="display"
|
||||
style={{ color: a ? "hsl(212 100% 45%)" : "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{a ? "A" : "—"}
|
||||
</span>
|
||||
<ArrowRight
|
||||
className="h-4 w-4 mt-0.5"
|
||||
strokeWidth={1.5}
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
/>
|
||||
<span
|
||||
className="display"
|
||||
style={{ color: b ? "hsl(36 92% 50%)" : "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{b ? "B" : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mono text-[11px] mt-1"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{ready ? "ready to diff" : "two picks needed"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : loadingBatches ? (
|
||||
<BatchDiffViewSkeleton />
|
||||
) : errKind === "not_found" ? (
|
||||
<NotFoundState a={a as string} b={b as string} onReset={reset} />
|
||||
) : diff.isError ? (
|
||||
<ErrorState
|
||||
message="Couldn't compute the diff between these two batches."
|
||||
detail={
|
||||
diff.error instanceof Error
|
||||
? diff.error.message
|
||||
: String(diff.error)
|
||||
}
|
||||
onRetry={() => diff.refetch()}
|
||||
/>
|
||||
) : diff.isLoading || !diff.data ? (
|
||||
<BatchDiffViewSkeleton />
|
||||
) : (
|
||||
<BatchDiffView data={diff.data} />
|
||||
)}
|
||||
|
||||
{/* Action footer — always visible so the operator can clear or
|
||||
force-refresh without scrolling back to the picker. */}
|
||||
{ready ? (
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
{diff.isFetching ? (
|
||||
<span className="text-[12px] text-muted-foreground inline-flex items-center gap-1.5">
|
||||
<CircleDashed className="h-3 w-3 animate-spin" strokeWidth={1.75} />
|
||||
Refreshing…
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => diff.refetch()}
|
||||
data-testid="diff-refresh-button"
|
||||
{/* § 01 The picks */}
|
||||
<section
|
||||
aria-label="The picks"
|
||||
className="relative px-8 lg:px-14 py-7 animate-fade-in-up"
|
||||
style={{ animationDelay: `${picksDelay}ms` }}
|
||||
>
|
||||
<Folio section="§ 01" label="The picks" />
|
||||
<div
|
||||
className="pt-6 border-t"
|
||||
style={{
|
||||
borderTopStyle: "double",
|
||||
borderTopWidth: 3,
|
||||
borderColor: "hsl(30 14% 14% / 0.12)",
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={reset}
|
||||
data-testid="diff-clear-button"
|
||||
<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 picks
|
||||
</div>
|
||||
<h3
|
||||
className="display leading-[0.98] tracking-[-0.03em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(24px, 2.6vw, 32px)",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
One on each <span className="italic">side.</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
className="text-[12.5px] display italic"
|
||||
style={{ color: "hsl(var(--surface-ink-2))", maxWidth: 380 }}
|
||||
>
|
||||
A is the "before" — B is the "after". A picked batch can't
|
||||
be picked again on the other side.
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<BatchPicker
|
||||
label="A · left"
|
||||
side="A"
|
||||
value={a}
|
||||
onChange={setA}
|
||||
items={batches ?? []}
|
||||
excludeId={b}
|
||||
testid="diff-picker-a"
|
||||
/>
|
||||
<BatchPicker
|
||||
label="B · right"
|
||||
side="B"
|
||||
value={b}
|
||||
onChange={setB}
|
||||
items={batches ?? []}
|
||||
excludeId={a}
|
||||
testid="diff-picker-b"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* § 02 The diff */}
|
||||
<section
|
||||
aria-label="The diff"
|
||||
className="relative px-8 lg:px-14 pb-8 lg:pb-10 animate-fade-in-up"
|
||||
style={{ animationDelay: `${diffDelay}ms` }}
|
||||
>
|
||||
<Folio section="§ 02" label="The diff" topClass="top-9" />
|
||||
<div
|
||||
className="pt-6 border-t"
|
||||
style={{
|
||||
borderTopStyle: "double",
|
||||
borderTopWidth: 3,
|
||||
borderColor: "hsl(30 14% 14% / 0.12)",
|
||||
}}
|
||||
>
|
||||
<AlertCircle className="h-3.5 w-3.5 mr-1" strokeWidth={1.75} />
|
||||
Clear picks
|
||||
</Button>
|
||||
<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 diff
|
||||
</div>
|
||||
<h3
|
||||
className="display leading-[0.98] tracking-[-0.03em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(24px, 2.6vw, 32px)",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
What <span className="italic">moved.</span>
|
||||
</h3>
|
||||
</div>
|
||||
{ready ? (
|
||||
<div
|
||||
className="text-[12.5px] display italic"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-2))",
|
||||
maxWidth: 380,
|
||||
}}
|
||||
>
|
||||
Three buckets — added in B, removed from A, changed in
|
||||
place. Unchanged claims are summarized but not listed.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{batchesError ? (
|
||||
<div
|
||||
className="rounded-md border p-5"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
>
|
||||
<ErrorState
|
||||
message="Couldn't load batches from the backend."
|
||||
detail={
|
||||
batchesErrorMsg instanceof Error
|
||||
? batchesErrorMsg.message
|
||||
: String(batchesErrorMsg)
|
||||
}
|
||||
onRetry={() => refetchBatches()}
|
||||
/>
|
||||
</div>
|
||||
) : !ready ? (
|
||||
<div
|
||||
className="rounded-md border p-12 text-center"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
data-testid="batch-diff-awaiting"
|
||||
>
|
||||
<EmptyState
|
||||
icon={
|
||||
<GitCompareArrows
|
||||
className="h-4 w-4"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
eyebrow="Batch diff · awaiting picks"
|
||||
message="Choose one batch for A and one for B to compute the diff."
|
||||
/>
|
||||
</div>
|
||||
) : loadingBatches ? (
|
||||
<BatchDiffViewSkeleton />
|
||||
) : errKind === "not_found" ? (
|
||||
<NotFoundState a={a as string} b={b as string} onReset={reset} />
|
||||
) : diff.isError ? (
|
||||
<div
|
||||
className="rounded-md border p-5"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
>
|
||||
<ErrorState
|
||||
message="Couldn't compute the diff between these two batches."
|
||||
detail={
|
||||
diff.error instanceof Error
|
||||
? diff.error.message
|
||||
: String(diff.error)
|
||||
}
|
||||
onRetry={() => diff.refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : diff.isLoading || !diff.data ? (
|
||||
<BatchDiffViewSkeleton />
|
||||
) : (
|
||||
<BatchDiffView data={diff.data} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Action footer — always visible when ready so the operator can
|
||||
clear or force-refresh without scrolling back to the picker. */}
|
||||
{ready ? (
|
||||
<div
|
||||
className="relative px-8 lg:px-14 py-5 border-t flex items-center justify-between gap-3 flex-wrap"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.12)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-[12.5px] display italic"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{diff.isFetching ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CircleDashed
|
||||
className="h-3 w-3 animate-spin"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
Refreshing…
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
Comparing{" "}
|
||||
<span
|
||||
className="display mono not-italic"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{a}
|
||||
</span>{" "}
|
||||
with{" "}
|
||||
<span
|
||||
className="display mono not-italic"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{b}
|
||||
</span>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => diff.refetch()}
|
||||
data-testid="diff-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={reset}
|
||||
data-testid="diff-clear-button"
|
||||
>
|
||||
<AlertCircle className="h-3.5 w-3.5 mr-1" strokeWidth={1.75} />
|
||||
Clear picks
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 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 sheet 01</span>
|
||||
<span>Cyclone · Batch diff</span>
|
||||
<span>{ready ? "A vs B" : "awaiting picks"}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+558
-23
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Files, GitBranch, Layers, Receipt } from "lucide-react";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { BatchesList, BatchesListSkeleton } from "@/components/BatchesList";
|
||||
import {
|
||||
BatchDetailContent,
|
||||
@@ -118,6 +119,14 @@ function useBatchDetail(id: string | null): {
|
||||
|
||||
const HELP_NOOP = () => {};
|
||||
|
||||
/**
|
||||
* Batches page — hybrid Magazine Spread treatment.
|
||||
*
|
||||
* Dark editorial hero → torn-page fold → cream paper plane with the
|
||||
* batch register (KPI strip + tabular list). Click a row to open the
|
||||
* detail drawer (rendered above the page, dimmed via the body
|
||||
* opacity).
|
||||
*/
|
||||
export function Batches() {
|
||||
const { data, isLoading, isError, error, refetch } = useBatches(100);
|
||||
const items: BatchSummary[] = data ?? [];
|
||||
@@ -156,6 +165,28 @@ export function Batches() {
|
||||
const dimBackground = batchId !== null;
|
||||
const errKind = batchErrorKind(detailError);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Aggregate metrics for the § 01 KPI strip
|
||||
// -----------------------------------------------------------------
|
||||
const totals = items.reduce(
|
||||
(acc, b) => ({
|
||||
count: acc.count + 1,
|
||||
p837: acc.p837 + (b.kind === "837p" ? 1 : 0),
|
||||
p835: acc.p835 + (b.kind === "835" ? 1 : 0),
|
||||
claims: acc.claims + b.claimCount,
|
||||
}),
|
||||
{ count: 0, p837: 0, p835: 0, claims: 0 },
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Stagger choreography (matches Acks / Reconciliation / Upload)
|
||||
// -----------------------------------------------------------------
|
||||
const heroDelay = 0;
|
||||
const foldDelay = 220;
|
||||
const titleDelay = 320;
|
||||
const kpiDelay = 460;
|
||||
const tableDelay = 600;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
@@ -188,37 +219,541 @@ export function Batches() {
|
||||
<div
|
||||
data-testid="batches-page-body"
|
||||
className={cn(
|
||||
"space-y-6 lg:space-y-8 animate-fade-in transition-opacity",
|
||||
"space-y-0 animate-fade-in transition-opacity",
|
||||
dimBackground && "pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
<PageHeader
|
||||
eyebrow="Batches"
|
||||
title={<>Parsed <span className="display italic text-muted-foreground">batches</span></>}
|
||||
subtitle="Every 837P and 835 file the backend has ingested. Click a row for envelope, summary, and a peek at the contained claims."
|
||||
/>
|
||||
{/* =================================================================
|
||||
HERO — DARK EDITORIAL HEADER
|
||||
================================================================= */}
|
||||
<section
|
||||
className="relative pt-6 pb-8 lg:pt-9 lg:pb-10"
|
||||
style={{ animationDelay: `${heroDelay}ms` }}
|
||||
>
|
||||
{/* Ghost "PARSED" 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(160px, 20vw, 300px)",
|
||||
letterSpacing: "-0.05em",
|
||||
opacity: 0.05,
|
||||
lineHeight: 1,
|
||||
color: "hsl(var(--surface-ink))",
|
||||
}}
|
||||
>
|
||||
PARSED
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message="Couldn't load batches from the backend."
|
||||
detail={error instanceof Error ? error.message : String(error)}
|
||||
onRetry={() => refetch()}
|
||||
<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">
|
||||
Batches · Register
|
||||
</span>
|
||||
<span
|
||||
className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground/60 hidden sm:inline"
|
||||
aria-hidden
|
||||
>
|
||||
· {totals.count} on file
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="display text-[48px] sm:text-[64px] lg:text-[80px] leading-[0.92] text-foreground tracking-[-0.04em]">
|
||||
Parsed{" "}
|
||||
<span className="italic text-muted-foreground/85">batches.</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col items-start lg:items-end gap-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3.5 py-2 text-[11.5px] mono uppercase tracking-[0.14em] text-muted-foreground backdrop-blur">
|
||||
<Files
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
style={{ color: "hsl(var(--accent))" }}
|
||||
/>
|
||||
837P · 835 · envelope & summary
|
||||
</div>
|
||||
<kbd
|
||||
className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70"
|
||||
>
|
||||
Tap a row to open the drawer
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
<p className="text-[14px] text-muted-foreground leading-relaxed">
|
||||
Every 837P and 835 file the backend has ingested. Click a row
|
||||
for envelope, summary, and a peek at the contained claims.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* =================================================================
|
||||
FOLD — TORN PAGE
|
||||
================================================================= */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative h-14"
|
||||
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)",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
The register
|
||||
</span>
|
||||
<span
|
||||
className="display italic"
|
||||
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
|
||||
>
|
||||
↙
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface rounded-xl overflow-hidden">
|
||||
{isLoading ? (
|
||||
<BatchesListSkeleton />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
eyebrow="Batches · awaiting first ingest"
|
||||
message="Upload an 837P or 835 file on the Upload page to populate this list."
|
||||
{/* =================================================================
|
||||
PAPER PLANE
|
||||
================================================================= */}
|
||||
<div
|
||||
className="relative max-w-[1280px] mx-auto"
|
||||
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 animate-fade-in-up"
|
||||
style={{
|
||||
animationDelay: `${titleDelay}ms`,
|
||||
borderColor: "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)" }}
|
||||
/>
|
||||
) : (
|
||||
<BatchesList items={items} openId={batchId} onOpen={open} />
|
||||
)}
|
||||
<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 · Parsed batches
|
||||
</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 ingested file — the kind, the input filename,
|
||||
how many claims it carried, and the day it was parsed.
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
{totals.count}
|
||||
</div>
|
||||
<div
|
||||
className="mono text-[11px] mt-1"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
batch{totals.count === 1 ? "" : "es"} · {totals.claims}{" "}
|
||||
claim{totals.claims === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI strip — § 01 Vital signs */}
|
||||
<section
|
||||
aria-label="Batch register 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">
|
||||
<BatchesKpiTile
|
||||
label="On file"
|
||||
value={totals.count}
|
||||
icon={<Layers className="h-3 w-3" strokeWidth={1.75} />}
|
||||
tone="ink"
|
||||
/>
|
||||
<BatchesKpiTile
|
||||
label="837P"
|
||||
value={totals.p837}
|
||||
icon={<GitBranch className="h-3 w-3" strokeWidth={1.75} />}
|
||||
tone="blue"
|
||||
/>
|
||||
<BatchesKpiTile
|
||||
label="835"
|
||||
value={totals.p835}
|
||||
icon={<Receipt className="h-3 w-3" strokeWidth={1.75} />}
|
||||
tone="amber"
|
||||
/>
|
||||
<BatchesKpiTile
|
||||
label="Claims"
|
||||
value={totals.claims}
|
||||
tone="success"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Table — § 02 The register */}
|
||||
<section
|
||||
aria-label="Batch 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">ingests</span>, newest first.
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
className="text-[12.5px] display italic"
|
||||
style={{ color: "hsl(var(--surface-ink-2))", maxWidth: 380 }}
|
||||
>
|
||||
Tap a row to open the envelope drawer. Use{" "}
|
||||
<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))",
|
||||
}}
|
||||
>
|
||||
←
|
||||
</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))",
|
||||
}}
|
||||
>
|
||||
→
|
||||
</kbd>{" "}
|
||||
inside the drawer to step.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<div
|
||||
className="rounded-md border p-5"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
>
|
||||
<ErrorState
|
||||
message="Couldn't load batches from the backend."
|
||||
detail={error instanceof Error ? error.message : String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div
|
||||
className="rounded-md border p-4 space-y-2"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
>
|
||||
<BatchesListSkeleton />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div
|
||||
className="rounded-md border p-10 text-center"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
>
|
||||
<EmptyState
|
||||
eyebrow="Batches · awaiting first ingest"
|
||||
message="Upload an 837P or 835 file on the Upload page to populate this list."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<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)",
|
||||
}}
|
||||
>
|
||||
<BatchesList
|
||||
items={items}
|
||||
openId={batchId}
|
||||
onOpen={open}
|
||||
tone="paper"
|
||||
/>
|
||||
</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 · Batches</span>
|
||||
<span>
|
||||
{totals.count} {totals.count === 1 ? "row" : "rows"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BatchesKpiTile — paper-toned metric for the batch register.
|
||||
// Mirrors AckKpiTile / KpiTile in the other hybrid pages.
|
||||
// ---------------------------------------------------------------------------
|
||||
function BatchesKpiTile({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "blue" | "amber" | "ink" | "success";
|
||||
icon?: React.ReactNode;
|
||||
}) {
|
||||
const accentMap = {
|
||||
blue: "hsl(212 100% 45%)",
|
||||
amber: "hsl(36 92% 50%)",
|
||||
ink: "hsl(var(--surface-ink))",
|
||||
success: "hsl(152 64% 38%)",
|
||||
} as const;
|
||||
const tintMap = {
|
||||
blue: "hsl(212 85% 92%)",
|
||||
amber: "hsl(36 82% 92%)",
|
||||
ink: "hsl(36 22% 90%)",
|
||||
success: "hsl(152 50% 88%)",
|
||||
} 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, color: accent }}
|
||||
>
|
||||
{icon ?? (
|
||||
<span
|
||||
className="block h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: accent }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="display tabular-nums tracking-[-0.04em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(28px, 3vw, 40px)",
|
||||
lineHeight: 1,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+233
-2
@@ -8,6 +8,7 @@ import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Claims } from "./Claims";
|
||||
import { api } from "@/lib/api";
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
@@ -148,8 +149,30 @@ function setLocation(url: string): void {
|
||||
* a real DOM container wrapped in `QueryClientProvider` so the page's
|
||||
* TanStack Query calls (via `useClaims`) resolve against our mocked
|
||||
* `api.listClaims`.
|
||||
*
|
||||
* Also wraps in a `MemoryRouter` so the page's `useSearchParams`
|
||||
* (added when Claims started reading `?status=` / `?sort=` off the URL
|
||||
* for Dashboard KPI drill-through) has a router context to read from.
|
||||
* The default `initialEntries` mirrors whatever `setLocation(...)` set
|
||||
* on `window.location`, so the existing tests' pre-set URLs keep
|
||||
* working without per-test changes.
|
||||
*/
|
||||
function renderClaims(): { unmount: () => void } {
|
||||
const initialEntries = [
|
||||
window.location.pathname + window.location.search,
|
||||
];
|
||||
return renderClaimsAt(initialEntries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of `renderClaims` that lets a test pin a specific initial
|
||||
* URL. Used by the URL-param filter tests below so each one can start
|
||||
* at exactly the URL it's asserting on.
|
||||
*/
|
||||
function renderClaimsAt(initialEntries: string[]): {
|
||||
unmount: () => void;
|
||||
container: HTMLDivElement;
|
||||
} {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const qc = new QueryClient({
|
||||
@@ -168,11 +191,16 @@ function renderClaims(): { unmount: () => void } {
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: qc },
|
||||
React.createElement(Claims)
|
||||
)
|
||||
React.createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries },
|
||||
React.createElement(Claims),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return {
|
||||
container,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
@@ -531,4 +559,207 @@ describe("Claims page drawer wiring", () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// URL-driven filter state (sub-project 6, Phase 1 Task 1.8 follow-up).
|
||||
//
|
||||
// Dashboard KPI tiles drill through to Claims with `?status=` and
|
||||
// `?sort=` query params. These tests pin each URL and assert that
|
||||
// the page actually filters (not just lands on a default view).
|
||||
//
|
||||
// The strongest assertion is on `api.listClaims.mock.calls` — that's
|
||||
// the exact params the page forwards to the API, so a passing test
|
||||
// proves end-to-end that URL → useSearchParams → useClaims → fetch.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
it("test_url_param_status_denied_filters_and_selects_chip", async () => {
|
||||
const { unmount, container } = renderClaimsAt(["/claims?status=denied"]);
|
||||
|
||||
// The first render of FilterChips is reached before useSearchParams
|
||||
// has the URL-derived `status` value committed (MemoryRouter
|
||||
// initializes synchronously but the chip's aria-checked only flips
|
||||
// to "true" on the next React commit). Wait for that commit so
|
||||
// the assertion below isn't a race against the first render.
|
||||
// We settle on `useClaims` having been called with status=denied
|
||||
// — that's the strongest invariant: it proves the URL → state →
|
||||
// API-call chain has fully propagated end-to-end.
|
||||
await settle(
|
||||
() => {
|
||||
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const lastCallParams = calls[calls.length - 1]?.[0] as
|
||||
| { status?: string; sort?: string; order?: string }
|
||||
| undefined;
|
||||
return lastCallParams?.status === "denied";
|
||||
},
|
||||
);
|
||||
|
||||
// Scope queries to this test's container so we can't accidentally
|
||||
// pick up a chip from a leaked render of a previous test.
|
||||
const deniedChip = Array.from(
|
||||
container.querySelectorAll('[role="radio"]'),
|
||||
).find(
|
||||
(el) => el.textContent?.trim().toLowerCase() === "denied",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(deniedChip).toBeDefined();
|
||||
expect(deniedChip?.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
// No other status chip should be checked.
|
||||
const checkedChips = Array.from(
|
||||
container.querySelectorAll('[role="radio"][aria-checked="true"]'),
|
||||
);
|
||||
expect(checkedChips).toHaveLength(1);
|
||||
expect(checkedChips[0]?.textContent?.trim().toLowerCase()).toBe(
|
||||
"denied",
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_url_param_sort_minus_receivedAmount_sorts_by_received_desc", async () => {
|
||||
const { unmount } = renderClaimsAt(["/claims?sort=-receivedAmount"]);
|
||||
|
||||
// Wait for useClaims to be called once.
|
||||
await settle(
|
||||
() => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
|
||||
);
|
||||
|
||||
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const lastCallParams = calls[calls.length - 1]?.[0] as
|
||||
| { status?: string; sort?: string; order?: string }
|
||||
| undefined;
|
||||
|
||||
// The `-` prefix must be stripped off and order set to desc.
|
||||
expect(lastCallParams?.sort).toBe("receivedAmount");
|
||||
expect(lastCallParams?.order).toBe("desc");
|
||||
// No status filter — user only asked for a sort change.
|
||||
expect(lastCallParams?.status).toBeUndefined();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_url_param_sort_without_prefix_orders_ascending", async () => {
|
||||
const { unmount } = renderClaimsAt(["/claims?sort=submittedDate"]);
|
||||
|
||||
await settle(
|
||||
() => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
|
||||
);
|
||||
|
||||
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const lastCallParams = calls[calls.length - 1]?.[0] as
|
||||
| { status?: string; sort?: string; order?: string }
|
||||
| undefined;
|
||||
|
||||
// No `-` prefix → order is asc.
|
||||
expect(lastCallParams?.sort).toBe("submittedDate");
|
||||
expect(lastCallParams?.order).toBe("asc");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_no_url_params_defaults_to_billed_desc_all_statuses", async () => {
|
||||
// Default URL — matches the existing "click a row to open the
|
||||
// drawer" tests, but here we assert on the params forwarded to
|
||||
// the API to prove the no-param default matches the previous
|
||||
// hardcoded behavior (sort=billedAmount, order=desc, no status).
|
||||
const { unmount } = renderClaimsAt(["/claims"]);
|
||||
|
||||
await settle(
|
||||
() => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
|
||||
);
|
||||
|
||||
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const lastCallParams = calls[calls.length - 1]?.[0] as
|
||||
| { status?: string; sort?: string; order?: string }
|
||||
| undefined;
|
||||
|
||||
expect(lastCallParams?.status).toBeUndefined();
|
||||
expect(lastCallParams?.sort).toBe("billedAmount");
|
||||
expect(lastCallParams?.order).toBe("desc");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_url_param_sort_dash_only_falls_back_to_default", async () => {
|
||||
// `?sort=-` is the prefix-with-no-field degenerate case. A naive
|
||||
// `slice(1)` would yield "" and the API would get `sort=""`.
|
||||
// We assert the page falls back to the default sort instead.
|
||||
const { unmount } = renderClaimsAt(["/claims?sort=-"]);
|
||||
|
||||
await settle(
|
||||
() => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
|
||||
);
|
||||
|
||||
const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const lastCallParams = calls[calls.length - 1]?.[0] as
|
||||
| { status?: string; sort?: string; order?: string }
|
||||
| undefined;
|
||||
|
||||
expect(lastCallParams?.sort).toBe("billedAmount");
|
||||
expect(lastCallParams?.order).toBe("desc");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Regression for SP21 Task 2.4 — DrillableCell click bubbling.
|
||||
//
|
||||
// The provider cell renders a <button> (via DrillableCell) inside a
|
||||
// <TableRow onClick={() => open(c.id)}>. Before the fix, clicking the
|
||||
// provider button (1) navigated to /providers?provider=… and then
|
||||
// (2) bubbled to the row, where open() rebuilt the URL on top of the
|
||||
// new /providers URL and appended a phantom ?claim=CLM-1. The page
|
||||
// mounted on /providers, but the URL was a Frankenstein of both
|
||||
// nav states and the browser history was polluted with two entries
|
||||
// per click.
|
||||
//
|
||||
// We assert the URL after a provider-cell click does NOT carry
|
||||
// ?claim=. The fix lives in DrillableCell (stopPropagation), so this
|
||||
// is an end-to-end check that the component-level guard closes the
|
||||
// bubble all the way back at the row.
|
||||
//
|
||||
// Note on MemoryRouter: react-router's `navigate()` updates the
|
||||
// router's internal history but does NOT push to window.history, so
|
||||
// `window.location.href` in this test stays on `/claims`. That's
|
||||
// still the right place to assert "no phantom claim= param landed on
|
||||
// window.history" — that's where the bug manifested (buildUrl() in
|
||||
// the row's open() reads window.location and pushState's the bad
|
||||
// URL). In a real BrowserRouter the URL would advance to /providers
|
||||
// after navigate(), and the same assertion holds: ?claim= would be
|
||||
// absent.
|
||||
// -------------------------------------------------------------------
|
||||
it("test_clicking_provider_cell_navigates_without_pushing_claim_param", async () => {
|
||||
const { unmount } = renderClaims();
|
||||
|
||||
// Wait for the table to populate from the mocked api.listClaims.
|
||||
await settle(
|
||||
() => document.body.textContent?.includes("CLM-1") ?? false,
|
||||
);
|
||||
|
||||
// Both SAMPLE_CLAIMS use providerNpi "1234567890", which doesn't
|
||||
// match any seeded sampleProvider, so the aria-label falls through
|
||||
// to `View provider 1234567890`. Pick the first matching button.
|
||||
const btn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) =>
|
||||
b.getAttribute("aria-label")?.startsWith("View provider ") ?? false,
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(btn).toBeDefined();
|
||||
|
||||
// Sanity: row's claim-param is not in the URL before the click.
|
||||
expect(window.location.href).not.toContain("claim=");
|
||||
|
||||
// Click it. With the fix, the click handler stops propagation before
|
||||
// the row's onClick can fire. Without the fix, the row's open()
|
||||
// would push a phantom ?claim=CLM-1 onto window.history.
|
||||
await act(async () => {
|
||||
btn!.click();
|
||||
});
|
||||
|
||||
// The URL must not carry a ?claim= param. buildUrl() inside open()
|
||||
// would have added that param if the click had bubbled to the row.
|
||||
// (See the MemoryRouter note above for why we don't also assert
|
||||
// on /providers here.)
|
||||
expect(window.location.href).not.toContain("claim=");
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
+109
-8
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ import { Pagination } from "@/components/ui/pagination";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { useClaims } from "@/hooks/useClaims";
|
||||
import { useDrawerUrlState } from "@/hooks/useDrawerUrlState";
|
||||
import { DrillableCell } from "@/components/drill/DrillableCell";
|
||||
import { useTailStream } from "@/hooks/useTailStream";
|
||||
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||
@@ -48,14 +50,81 @@ const STATUS_OPTIONS: FilterChipOption[] = [
|
||||
{ value: "pending", label: "Pending" },
|
||||
];
|
||||
|
||||
// Whitelist of valid `?status=` values — anything else falls back to ALL.
|
||||
// Used both to validate the URL param on read AND to guard the Dropdown
|
||||
// onValueChange so the URL never carries a bogus value.
|
||||
const VALID_STATUSES: ReadonlySet<ClaimStatus> = new Set<ClaimStatus>([
|
||||
"draft",
|
||||
"submitted",
|
||||
"accepted",
|
||||
"denied",
|
||||
"paid",
|
||||
"pending",
|
||||
]);
|
||||
|
||||
export function Claims() {
|
||||
const [status, setStatus] = useState<ClaimStatus | typeof ALL>(ALL);
|
||||
// -------------------------------------------------------------------------
|
||||
// URL-driven filter state.
|
||||
//
|
||||
// The page reads `?status=` and `?sort=` off the URL (via React Router's
|
||||
// useSearchParams) so deep links from the Dashboard KPI tiles — e.g.
|
||||
// navigate("/claims?status=denied") — actually filter, not just land on
|
||||
// a default view. Status and sort/order derive from the URL; pagination
|
||||
// (`?page=`) and search (`?q=`) stay local because they're ephemeral
|
||||
// view state that doesn't deserve a URL slot.
|
||||
//
|
||||
// Sort syntax: `?sort=billedAmount` → asc, `?sort=-billedAmount` → desc.
|
||||
// The leading `-` is the conventional "negate" prefix used by most
|
||||
// list APIs (incl. our own /api/claims), so we mirror it instead of
|
||||
// using a separate `?order=` param.
|
||||
// -------------------------------------------------------------------------
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const rawStatus = searchParams.get("status");
|
||||
const status: ClaimStatus | typeof ALL =
|
||||
rawStatus && VALID_STATUSES.has(rawStatus as ClaimStatus)
|
||||
? (rawStatus as ClaimStatus)
|
||||
: ALL;
|
||||
|
||||
const rawSort = searchParams.get("sort");
|
||||
let sort: string;
|
||||
let order: "asc" | "desc";
|
||||
if (rawSort && rawSort.length > 1 && rawSort.startsWith("-")) {
|
||||
// Strip the `-` desc-marker, but only when there's an actual field
|
||||
// after it. `?sort=-` (prefix only, no field) falls through to the
|
||||
// default below — otherwise `slice(1)` would yield `""` and we'd
|
||||
// hit the API with `sort=""`.
|
||||
sort = rawSort.slice(1);
|
||||
order = "desc";
|
||||
} else if (rawSort && rawSort !== "-") {
|
||||
sort = rawSort;
|
||||
order = "asc";
|
||||
} else {
|
||||
// Default — match the previous hardcoded behavior so existing
|
||||
// bookmarks and the "no params" path keep sorting by billed desc.
|
||||
// Also catches `?sort=-` (no field after the prefix) and any other
|
||||
// degenerate value.
|
||||
sort = "billedAmount";
|
||||
order = "desc";
|
||||
}
|
||||
|
||||
const [npi, setNpi] = useState<string>(ALL);
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Reset pagination whenever the URL-driven filters change. Without
|
||||
// this, a deep link like `/claims?status=denied` lands on whatever
|
||||
// page the user was last on (e.g. page 5 of an unfiltered list) and
|
||||
// shows an empty view. Safe to run on every render of `status` or
|
||||
// `sort` because `setPage(1)` is idempotent when already at 1, and
|
||||
// changing `page` doesn't trigger this effect.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [status, sort]);
|
||||
|
||||
const { claimId, open, close, setClaimId } = useDrawerUrlState();
|
||||
|
||||
const providers = useAppStore((s) => s.providers);
|
||||
@@ -64,11 +133,26 @@ export function Claims() {
|
||||
[providers]
|
||||
);
|
||||
|
||||
// Drop the `status` query param entirely when going back to "all", so
|
||||
// the URL stays clean for sharing. Caller still owns `setPage(1)` to
|
||||
// reset pagination.
|
||||
const setStatus = (next: ClaimStatus | typeof ALL): void => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
if (next === ALL) params.delete("status");
|
||||
else params.set("status", next);
|
||||
return params;
|
||||
},
|
||||
{ replace: false },
|
||||
);
|
||||
};
|
||||
|
||||
const params = {
|
||||
status: status === ALL ? undefined : status,
|
||||
provider_npi: npi === ALL ? undefined : npi,
|
||||
sort: "billedAmount",
|
||||
order: "desc" as const,
|
||||
sort,
|
||||
order,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
};
|
||||
@@ -284,10 +368,27 @@ export function Claims() {
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-[13px]">{c.patientName}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-[13px]">{provider?.name ?? "Unknown"}</div>
|
||||
<div className="mono text-[10.5px] text-muted-foreground">
|
||||
{c.providerNpi}
|
||||
</div>
|
||||
<DrillableCell
|
||||
ariaLabel={`View provider ${provider?.name ?? c.providerNpi}`}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/providers?provider=${encodeURIComponent(c.providerNpi)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{/*
|
||||
DrillableCell renders an inline-flex button,
|
||||
so the two stacked lines would otherwise land
|
||||
side-by-side. The flex-col wrapper preserves
|
||||
the original "name on top, NPI below" layout.
|
||||
*/}
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="text-[13px]">{provider?.name ?? "Unknown"}</div>
|
||||
<div className="mono text-[10.5px] text-muted-foreground">
|
||||
{c.providerNpi}
|
||||
</div>
|
||||
</div>
|
||||
</DrillableCell>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-[13px]">
|
||||
{c.payerName}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SP21 Task 2.5: Dashboard "Recent activity" card routes clicks by event
|
||||
// kind. This test verifies the wiring end-to-end: a `claim_paid` event
|
||||
// in the activity slice becomes a clickable row that navigates to
|
||||
// `/claims?claim=<id>`, while a `remit_received` event surfaces a
|
||||
// "coming in a later phase" toast (since the RemitDrawer lands in
|
||||
// Phase 4).
|
||||
//
|
||||
// `Dashboard` reads `claims`, `providers`, `activity` from the
|
||||
// `useAppStore` slice, so we override the slice directly via
|
||||
// `useAppStore.setState` instead of mocking `useActivity` — the
|
||||
// sample-data mode is exactly what the dashboard sees when no API is
|
||||
// configured, so the test stays faithful to production behavior.
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Dashboard } from "./Dashboard";
|
||||
import { useAppStore } from "@/store";
|
||||
import type { Activity } from "@/types";
|
||||
|
||||
// sonner renders toasts through a portal; jsdom has no layout so the
|
||||
// `position` style is a no-op. We just verify that `toast.info` is
|
||||
// called with the expected message — sonner itself has its own tests.
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Capture navigation side effects so we can assert on the URL the
|
||||
// Dashboard would push. We use a `MemoryRouter` (initialEntries=["/"])
|
||||
// and observe the rendered route via a tiny listener component that
|
||||
// reads the current `<MemoryRouter>` entry — no real `window.history`
|
||||
// or `BrowserRouter` required.
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
function LocationProbe() {
|
||||
const loc = useLocation();
|
||||
return (
|
||||
<div
|
||||
data-testid="location"
|
||||
data-pathname={loc.pathname}
|
||||
data-search={loc.search}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
// Reset the activity slice so tests don't bleed into each other.
|
||||
useAppStore.setState({ activity: [] });
|
||||
});
|
||||
|
||||
describe("Dashboard · Recent activity event routing (SP21 Task 2.5)", () => {
|
||||
it("navigates to /claims?claim=ID when a claim_paid event is clicked", () => {
|
||||
const activity: Activity[] = [
|
||||
{
|
||||
id: "ae-1",
|
||||
kind: "claim_paid",
|
||||
message: "Paid CLM-42 · Jordan Smith",
|
||||
timestamp: "2026-06-20T12:00:00Z",
|
||||
claimId: "CLM-42",
|
||||
remittanceId: null,
|
||||
},
|
||||
];
|
||||
useAppStore.setState({ activity });
|
||||
|
||||
const { getByTestId, getByRole } = render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Dashboard />
|
||||
<LocationProbe />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// The row becomes a button-like <li> with role="button" and a
|
||||
// descriptive aria-label derived from the kind.
|
||||
const row = getByRole("button", { name: /View claim paid/ });
|
||||
fireEvent.click(row);
|
||||
|
||||
const probe = getByTestId("location");
|
||||
expect(probe.getAttribute("data-pathname")).toBe("/claims");
|
||||
expect(probe.getAttribute("data-search")).toBe("?claim=CLM-42");
|
||||
});
|
||||
|
||||
it("navigates to /providers?provider=NPI when a provider_added event is clicked", () => {
|
||||
const activity: Activity[] = [
|
||||
{
|
||||
id: "ae-2",
|
||||
kind: "provider_added",
|
||||
message: "Added Cedar Park Family Medicine",
|
||||
timestamp: "2026-06-20T12:00:00Z",
|
||||
npi: "1730187395",
|
||||
claimId: null,
|
||||
remittanceId: null,
|
||||
},
|
||||
];
|
||||
useAppStore.setState({ activity });
|
||||
|
||||
const { getByTestId, getByRole } = render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Dashboard />
|
||||
<LocationProbe />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByRole("button", { name: /View provider added/ }));
|
||||
|
||||
const probe = getByTestId("location");
|
||||
expect(probe.getAttribute("data-pathname")).toBe("/providers");
|
||||
expect(probe.getAttribute("data-search")).toBe("?provider=1730187395");
|
||||
});
|
||||
|
||||
it("fires the toast (no navigation) for remit_received — Phase 4 drawer", async () => {
|
||||
const { toast } = await import("sonner");
|
||||
const activity: Activity[] = [
|
||||
{
|
||||
id: "ae-3",
|
||||
kind: "remit_received",
|
||||
message: "Remit REM-7 received",
|
||||
timestamp: "2026-06-20T12:00:00Z",
|
||||
claimId: null,
|
||||
remittanceId: "REM-7",
|
||||
},
|
||||
];
|
||||
useAppStore.setState({ activity });
|
||||
|
||||
const { getByTestId, getByRole } = render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Dashboard />
|
||||
<LocationProbe />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByRole("button", { name: /View remit received/ }));
|
||||
|
||||
expect(toast.info).toHaveBeenCalledTimes(1);
|
||||
const msg = (toast.info as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as string;
|
||||
expect(msg).toMatch(/remit received/i);
|
||||
expect(msg).toMatch(/coming in a later phase/i);
|
||||
|
||||
// URL must not have changed — the toast path is non-navigating.
|
||||
const probe = getByTestId("location");
|
||||
expect(probe.getAttribute("data-pathname")).toBe("/");
|
||||
expect(probe.getAttribute("data-search")).toBe("");
|
||||
});
|
||||
});
|
||||
+134
-64
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
Banknote,
|
||||
@@ -13,8 +14,11 @@ import { PageHeader } from "@/components/PageHeader";
|
||||
import { KpiCard } from "@/components/KpiCard";
|
||||
import { ActivityFeed } from "@/components/ActivityFeed";
|
||||
import { AnimatedNumber } from "@/components/AnimatedNumber";
|
||||
import { DrillableCell } from "@/components/drill/DrillableCell";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { eventKindToUrl } from "@/lib/event-routing";
|
||||
import { useAppStore } from "@/store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const MONTHS_BACK = 6;
|
||||
|
||||
@@ -70,6 +74,8 @@ export function Dashboard() {
|
||||
const providers = useAppStore((s) => s.providers);
|
||||
const activity = useAppStore((s) => s.activity);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const kpis = useMemo(() => {
|
||||
const billed = claims.reduce((s, c) => s + c.billedAmount, 0);
|
||||
const received = claims.reduce((s, c) => s + c.receivedAmount, 0);
|
||||
@@ -162,67 +168,92 @@ export function Dashboard() {
|
||||
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"
|
||||
>
|
||||
<KpiCard
|
||||
label="Claims"
|
||||
icon={Receipt}
|
||||
sparkline={monthly.count}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${kpiBase + 0 * kpiStep}ms` }}
|
||||
value={
|
||||
<AnimatedNumber value={kpis.count} format={(n) => fmt.num(Math.round(n))} />
|
||||
}
|
||||
hint="last 6 months"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Billed"
|
||||
icon={CircleDollarSign}
|
||||
accent="accent"
|
||||
sparkline={monthly.billed}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${kpiBase + 1 * kpiStep}ms` }}
|
||||
value={<AnimatedNumber value={kpis.billed} format={fmt.usd} />}
|
||||
delta={{ value: "+12.4%", direction: "up", positive: true }}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Received"
|
||||
icon={Banknote}
|
||||
accent="success"
|
||||
sparkline={monthly.received}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${kpiBase + 2 * kpiStep}ms` }}
|
||||
value={<AnimatedNumber value={kpis.received} format={fmt.usd} />}
|
||||
delta={{ value: "+8.1%", direction: "up", positive: true }}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Pending AR"
|
||||
icon={Clock}
|
||||
accent="warning"
|
||||
sparkline={monthly.ar}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${kpiBase + 3 * kpiStep}ms` }}
|
||||
value={
|
||||
<AnimatedNumber
|
||||
value={kpis.outstandingAr}
|
||||
format={(n) => fmt.usd(Math.max(0, n))}
|
||||
/>
|
||||
}
|
||||
hint={`${kpis.pending} in queue`}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Denial rate"
|
||||
icon={TrendingDown}
|
||||
accent="destructive"
|
||||
sparkline={monthly.denialRate}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${kpiBase + 4 * kpiStep}ms` }}
|
||||
value={
|
||||
<AnimatedNumber
|
||||
value={kpis.denialRate}
|
||||
format={(n) => fmt.pct(n)}
|
||||
/>
|
||||
}
|
||||
delta={{ value: "-1.2 pts", direction: "down", positive: true }}
|
||||
/>
|
||||
<DrillableCell
|
||||
onClick={() => navigate("/claims")}
|
||||
ariaLabel="View all claims"
|
||||
>
|
||||
<KpiCard
|
||||
label="Claims"
|
||||
icon={Receipt}
|
||||
sparkline={monthly.count}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${kpiBase + 0 * kpiStep}ms` }}
|
||||
value={
|
||||
<AnimatedNumber value={kpis.count} format={(n) => fmt.num(Math.round(n))} />
|
||||
}
|
||||
hint="last 6 months"
|
||||
/>
|
||||
</DrillableCell>
|
||||
<DrillableCell
|
||||
onClick={() => navigate("/claims?sort=-billedAmount")}
|
||||
ariaLabel="View claims sorted by billed amount"
|
||||
>
|
||||
<KpiCard
|
||||
label="Billed"
|
||||
icon={CircleDollarSign}
|
||||
accent="accent"
|
||||
sparkline={monthly.billed}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${kpiBase + 1 * kpiStep}ms` }}
|
||||
value={<AnimatedNumber value={kpis.billed} format={fmt.usd} />}
|
||||
delta={{ value: "+12.4%", direction: "up", positive: true }}
|
||||
/>
|
||||
</DrillableCell>
|
||||
<DrillableCell
|
||||
onClick={() => navigate("/claims?sort=-receivedAmount")}
|
||||
ariaLabel="View claims sorted by received amount"
|
||||
>
|
||||
<KpiCard
|
||||
label="Received"
|
||||
icon={Banknote}
|
||||
accent="success"
|
||||
sparkline={monthly.received}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${kpiBase + 2 * kpiStep}ms` }}
|
||||
value={<AnimatedNumber value={kpis.received} format={fmt.usd} />}
|
||||
delta={{ value: "+8.1%", direction: "up", positive: true }}
|
||||
/>
|
||||
</DrillableCell>
|
||||
<DrillableCell
|
||||
onClick={() => navigate("/claims")}
|
||||
ariaLabel="View pending accounts receivable claims"
|
||||
>
|
||||
<KpiCard
|
||||
label="Pending AR"
|
||||
icon={Clock}
|
||||
accent="warning"
|
||||
sparkline={monthly.ar}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${kpiBase + 3 * kpiStep}ms` }}
|
||||
value={
|
||||
<AnimatedNumber
|
||||
value={kpis.outstandingAr}
|
||||
format={(n) => fmt.usd(Math.max(0, n))}
|
||||
/>
|
||||
}
|
||||
hint={`${kpis.pending} in queue`}
|
||||
/>
|
||||
</DrillableCell>
|
||||
<DrillableCell
|
||||
onClick={() => navigate("/claims?status=denied")}
|
||||
ariaLabel="View denied claims"
|
||||
>
|
||||
<KpiCard
|
||||
label="Denial rate"
|
||||
icon={TrendingDown}
|
||||
accent="destructive"
|
||||
sparkline={monthly.denialRate}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${kpiBase + 4 * kpiStep}ms` }}
|
||||
value={
|
||||
<AnimatedNumber
|
||||
value={kpis.denialRate}
|
||||
format={(n) => fmt.pct(n)}
|
||||
/>
|
||||
}
|
||||
delta={{ value: "-1.2 pts", direction: "down", positive: true }}
|
||||
/>
|
||||
</DrillableCell>
|
||||
</section>
|
||||
|
||||
{/* Activity + Top providers */}
|
||||
@@ -246,7 +277,24 @@ export function Dashboard() {
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<ActivityFeed items={activity.slice(0, 10)} />
|
||||
<ActivityFeed
|
||||
items={activity.slice(0, 10)}
|
||||
onItemClick={(evt) => {
|
||||
// SP21 Task 2.5: route by event kind. claim_* kinds
|
||||
// navigate to the matching claim drawer (URL-driven,
|
||||
// drawer mounts in Phase 5); provider_added opens the
|
||||
// ProviderDrawer. remit_received and any unmapped
|
||||
// kind surface a "coming soon" toast so the click
|
||||
// still gives feedback until the RemitDrawer lands in
|
||||
// Phase 4.
|
||||
const url = eventKindToUrl(evt);
|
||||
if (url) navigate(url);
|
||||
else
|
||||
toast.info(
|
||||
`Drill for ${evt.kind.replace(/_/g, " ")} coming in a later phase.`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -260,7 +308,22 @@ export function Dashboard() {
|
||||
<CardContent className="pt-0">
|
||||
<ul className="space-y-4">
|
||||
{topProviders.map((p, i) => (
|
||||
<li key={p.npi} className="flex items-center gap-3">
|
||||
<li
|
||||
key={p.npi}
|
||||
onClick={() =>
|
||||
navigate(`/providers?provider=${encodeURIComponent(p.npi)}`)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
navigate(
|
||||
`/providers?provider=${encodeURIComponent(p.npi)}`
|
||||
);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`View provider ${p.name}`}
|
||||
className="drillable flex items-center gap-3"
|
||||
>
|
||||
<div className="h-7 w-7 rounded-md bg-muted/60 ring-1 ring-inset ring-border/40 flex items-center justify-center mono text-[10.5px] text-muted-foreground">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</div>
|
||||
@@ -306,7 +369,14 @@ export function Dashboard() {
|
||||
{topDenials.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
|
||||
onClick={() => navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") navigate(`/claims?claim=${encodeURIComponent(c.id)}`);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`View claim ${c.id}`}
|
||||
className="drillable flex items-start gap-3 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<div className="display mono text-[12.5px] text-muted-foreground pt-0.5 w-24 shrink-0">
|
||||
{c.id}
|
||||
|
||||
+259
-16
@@ -13,6 +13,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Lane, type LaneRow } from "@/components/inbox/Lane";
|
||||
import { InboxHeader } from "@/components/inbox/InboxHeader";
|
||||
import { BulkBar } from "@/components/inbox/BulkBar";
|
||||
@@ -160,15 +161,21 @@ export default function Inbox() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen p-8 mono flex items-center gap-2"
|
||||
style={{ background: "var(--tt-bg)", color: "var(--tt-ink-dim)", fontSize: 12 }}
|
||||
className="min-h-screen"
|
||||
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
|
||||
style={{ background: "var(--tt-amber)" }}
|
||||
/>
|
||||
loading inbox…
|
||||
<InboxHeader needEyesCount={0} doneTodayCount={0} />
|
||||
<div
|
||||
className="px-6 lg:px-10 py-10 mono flex items-center gap-2"
|
||||
style={{ color: "var(--tt-ink-dim)", fontSize: 12 }}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
|
||||
style={{ background: "var(--tt-amber)" }}
|
||||
/>
|
||||
loading inbox…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -176,13 +183,22 @@ export default function Inbox() {
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen p-8 mono flex flex-col gap-2"
|
||||
style={{ background: "var(--tt-bg)", color: "var(--tt-oxblood)", fontSize: 12 }}
|
||||
className="min-h-screen"
|
||||
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
||||
>
|
||||
<span className="uppercase tracking-[0.18em]" style={{ fontSize: 10, fontWeight: 700 }}>
|
||||
Connection error
|
||||
</span>
|
||||
<span>{error.message}</span>
|
||||
<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 }}
|
||||
>
|
||||
Connection error
|
||||
</span>
|
||||
<span>{error.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -203,7 +219,53 @@ export default function Inbox() {
|
||||
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
||||
>
|
||||
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
|
||||
<main className="px-6 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
|
||||
|
||||
{/* Fold — a thin amber rule + italic serif annotation that
|
||||
transitions the editorial header into the lane grid below. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative h-10 flex items-center"
|
||||
style={{ background: "var(--tt-bg)" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, var(--tt-amber) 12%, var(--tt-amber) 88%, transparent)",
|
||||
opacity: 0.35,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="relative z-10 mx-auto px-3 flex items-center gap-2"
|
||||
style={{ background: "var(--tt-bg)" }}
|
||||
>
|
||||
<span
|
||||
className="display italic"
|
||||
style={{ color: "var(--tt-amber)", fontSize: 14 }}
|
||||
>
|
||||
↘
|
||||
</span>
|
||||
<span
|
||||
className="mono uppercase"
|
||||
style={{
|
||||
color: "var(--tt-ink-dim)",
|
||||
fontSize: 10.5,
|
||||
letterSpacing: "0.24em",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Five lanes · one queue
|
||||
</span>
|
||||
<span
|
||||
className="display italic"
|
||||
style={{ color: "var(--tt-amber)", fontSize: 14 }}
|
||||
>
|
||||
↙
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="px-6 lg:px-10 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
|
||||
<Lane
|
||||
name="REJECTED"
|
||||
accent="oxblood"
|
||||
@@ -247,6 +309,103 @@ export default function Inbox() {
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* ----------------------------------------------------------------
|
||||
PAPER-TONED SUMMARY STRIP
|
||||
A cream "ledger" panel below the lanes that shows the aggregate
|
||||
eye-flow across the queue. Mirrors the "Magazine Spread" pattern
|
||||
used on the Dashboard/Claims/etc pages so the Inbox feels part
|
||||
of the same document set, not a stand-alone terminal.
|
||||
---------------------------------------------------------------- */}
|
||||
<section
|
||||
aria-label="Queue summary"
|
||||
className="relative mx-6 lg:mx-10 mt-6 mb-4"
|
||||
>
|
||||
<div
|
||||
className="relative rounded-xl overflow-hidden border"
|
||||
style={{
|
||||
backgroundColor: "hsl(var(--surface))",
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 12px 40px -16px hsl(0 0% 0% / 0.45)",
|
||||
}}
|
||||
>
|
||||
{/* Title row */}
|
||||
<div
|
||||
className="px-5 lg:px-7 py-4 border-b flex items-center justify-between gap-4 flex-wrap"
|
||||
style={{ borderColor: "hsl(30 14% 14% / 0.10)" }}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="mono text-[10.5px] uppercase tracking-[0.20em] font-semibold mb-1.5"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
Queue ledger
|
||||
</div>
|
||||
<h2
|
||||
className="display leading-[0.95] tracking-[-0.03em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(22px, 2.2vw, 28px)",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
The eye-flow, <span className="italic">at a glance.</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="text-right"
|
||||
aria-label="Aggregate metrics"
|
||||
>
|
||||
<div
|
||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
Need eyes
|
||||
</div>
|
||||
<div
|
||||
className="display tabular-nums"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: 24,
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{needEyes}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lane metrics row — paper tiles that mirror each lane's accent */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 divide-x divide-y lg:divide-y-0" style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}>
|
||||
<SummaryTile
|
||||
label="Rejected"
|
||||
value={lanes.rejected.length}
|
||||
tone="oxblood"
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Payer rejected"
|
||||
value={lanes.payer_rejected.length}
|
||||
tone="oxblood"
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Candidates"
|
||||
value={lanes.candidates.length}
|
||||
tone="amber"
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Unmatched"
|
||||
value={lanes.unmatched.length}
|
||||
tone="ink"
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Done today"
|
||||
value={lanes.done_today.length}
|
||||
tone="muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Per-lane bulk bars. Each shows when its lane has a selection. */}
|
||||
<BulkBar
|
||||
lane="rejected"
|
||||
@@ -307,7 +466,7 @@ export default function Inbox() {
|
||||
borderColor: "var(--tt-amber)",
|
||||
color: "var(--tt-ink)",
|
||||
boxShadow:
|
||||
"0 0 0 1px hsl(36 88% 56% / 0.18), 0 24px 60px -12px hsl(0 0% 0% / 0.7), inset 0 1px 0 0 hsl(0 0% 100% / 0.04)",
|
||||
"0 0 0 1px hsl(36 75% 58% / 0.18), 0 24px 60px -12px hsl(0 0% 0% / 0.5), inset 0 1px 0 0 hsl(0 0% 100% / 0.03)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
@@ -404,3 +563,87 @@ export default function Inbox() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SummaryTile — paper-toned metric for the queue ledger strip. Mirrors
|
||||
// the lane accent (oxblood / amber / ink-blue / muted) so the eye-flow
|
||||
// reads at a glance.
|
||||
// ---------------------------------------------------------------------------
|
||||
const SUMMARY_ACCENTS: Record<
|
||||
"oxblood" | "amber" | "ink" | "muted",
|
||||
{ dot: string; tint: string; text: string }
|
||||
> = {
|
||||
oxblood: {
|
||||
dot: "hsl(358 70% 42%)",
|
||||
tint: "hsl(358 70% 92%)",
|
||||
text: "hsl(358 70% 30%)",
|
||||
},
|
||||
amber: {
|
||||
dot: "hsl(36 92% 50%)",
|
||||
tint: "hsl(36 82% 88%)",
|
||||
text: "hsl(36 92% 30%)",
|
||||
},
|
||||
ink: {
|
||||
dot: "hsl(212 80% 45%)",
|
||||
tint: "hsl(212 85% 92%)",
|
||||
text: "hsl(212 80% 30%)",
|
||||
},
|
||||
muted: {
|
||||
dot: "hsl(30 8% 50%)",
|
||||
tint: "hsl(36 22% 90%)",
|
||||
text: "hsl(30 8% 30%)",
|
||||
},
|
||||
};
|
||||
|
||||
function SummaryTile({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "oxblood" | "amber" | "ink" | "muted";
|
||||
}) {
|
||||
const a = SUMMARY_ACCENTS[tone];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative px-4 py-4 first:pl-5 lg:first:pl-7 last:pr-5 lg:last:pr-7"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="block h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: a.dot }}
|
||||
/>
|
||||
<span
|
||||
className="mono text-[10px] uppercase tracking-[0.20em] font-semibold"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="display tabular-nums tracking-[-0.03em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(24px, 2vw, 30px)",
|
||||
lineHeight: 1,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<div
|
||||
className="mono text-[10px] uppercase tracking-[0.16em] mt-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm"
|
||||
style={{
|
||||
color: a.text,
|
||||
backgroundColor: a.tint,
|
||||
}}
|
||||
>
|
||||
{value === 0 ? "Clear" : value === 1 ? "row" : "rows"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+13
-1
@@ -3,12 +3,15 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { ProviderDrawer } from "@/components/ProviderDrawer";
|
||||
import { useProviderDrawerUrlState } from "@/hooks/useProviderDrawerUrlState";
|
||||
import { useProviders } from "@/hooks/useProviders";
|
||||
import { fmt } from "@/lib/format";
|
||||
|
||||
export function Providers() {
|
||||
const { data, isLoading, isError, error, refetch } = useProviders();
|
||||
const items = data?.items ?? [];
|
||||
const { providerNpi, open, close } = useProviderDrawerUrlState();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 lg:space-y-8 animate-fade-in">
|
||||
@@ -44,7 +47,14 @@ export function Providers() {
|
||||
{items.map((p) => (
|
||||
<article
|
||||
key={p.npi}
|
||||
className="group surface-2 rounded-xl p-5 flex flex-col gap-4 transition-colors hover:bg-muted/20 cursor-default"
|
||||
onClick={() => open(p.npi)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") open(p.npi);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`View provider ${p.name}`}
|
||||
className="group drillable surface-2 rounded-xl p-5 flex flex-col gap-4 transition-colors hover:bg-muted/20 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-md bg-muted/60 ring-1 ring-inset ring-border/40 flex items-center justify-center text-foreground">
|
||||
@@ -91,6 +101,8 @@ export function Providers() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProviderDrawer npi={providerNpi} onClose={close} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+953
-139
File diff suppressed because it is too large
Load Diff
+967
-270
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,10 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
timestamp: claim.submissionDate,
|
||||
npi: claim.providerNpi,
|
||||
amount: claim.billedAmount,
|
||||
// SP21 Task 2.5: mirror the backend wire shape so the
|
||||
// Dashboard routing helper finds the claim id.
|
||||
claimId: claim.id,
|
||||
remittanceId: null,
|
||||
},
|
||||
...s.activity,
|
||||
],
|
||||
|
||||
@@ -50,6 +50,58 @@ export interface Provider {
|
||||
phone: string;
|
||||
claimCount: number;
|
||||
outstandingAr: number;
|
||||
/**
|
||||
* SP21 Task 1.6: populated by the extended `GET /api/config/providers/{npi}`
|
||||
* endpoint. Top 10 claims for this provider by `submissionDate` desc.
|
||||
* Optional so legacy callers (in-memory sample data) keep working
|
||||
* without an API round-trip.
|
||||
*/
|
||||
recent_claims?: ClaimSummary[];
|
||||
/**
|
||||
* SP21 Task 1.6: populated by the extended `GET /api/config/providers/{npi}`
|
||||
* endpoint. Top 10 activity events (by `ts` desc) joined to claims
|
||||
* owned by this provider via `claim_id`.
|
||||
*/
|
||||
recent_activity?: ActivityEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* SP21 Task 1.6: slim claim projection returned by
|
||||
* `GET /api/config/providers/{npi}.recent_claims`. Mirrors the wire
|
||||
* shape of `store.iter_claims(provider_npi=...)` 1:1 (camelCase,
|
||||
* superset of the legacy `Claim` interface).
|
||||
*/
|
||||
export interface ClaimSummary {
|
||||
id: string;
|
||||
state: string;
|
||||
billedAmount: number;
|
||||
patientName: string;
|
||||
providerNpi: string;
|
||||
payerName: string;
|
||||
cptCode: string;
|
||||
submissionDate: string;
|
||||
parsedAt: string;
|
||||
status: string;
|
||||
matchedRemittanceId?: string | null;
|
||||
batchId: string;
|
||||
receivedAmount?: number;
|
||||
denialReason?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SP21 Task 1.6: one row in `recent_activity`. Mirrors the Python
|
||||
* ORM `ActivityEvent` (snake_case fields rewritten to camelCase for
|
||||
* the wire). `payload` carries the same dict the recorder wrote at
|
||||
* the time of the event (message, npi, amount, etc.).
|
||||
*/
|
||||
export interface ActivityEvent {
|
||||
id: number;
|
||||
ts: string; // ISO Z
|
||||
kind: string;
|
||||
batchId: string | null;
|
||||
claimId: string | null;
|
||||
remittanceId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Remittance {
|
||||
@@ -103,6 +155,23 @@ export interface Activity {
|
||||
timestamp: string;
|
||||
npi?: string;
|
||||
amount?: number;
|
||||
/**
|
||||
* SP21 Task 2.5: read from the backend `ActivityEvent.claim_id` so
|
||||
* the Dashboard "Recent activity" card can route a click on a
|
||||
* `claim_*` event to the matching claim drawer (via
|
||||
* `src/lib/event-routing.ts`). Populated only for events that
|
||||
* correspond to a specific claim; `null` for orphan events such as
|
||||
* `remit_received` or `provider_added`.
|
||||
*/
|
||||
claimId?: string | null;
|
||||
/**
|
||||
* SP21 Task 2.5: read from the backend `ActivityEvent.remittance_id`.
|
||||
* Populated only for events that reference a specific remittance
|
||||
* (e.g. `remit_received`); `null` otherwise. The Dashboard toast
|
||||
* covers the Phase 2 case where this routes to a not-yet-built
|
||||
* `RemitDrawer` (Phase 4).
|
||||
*/
|
||||
remittanceId?: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user