Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22720168c4 | |||
| 1c0d855b8e | |||
| 8db5db7610 | |||
| 786ead8c94 | |||
| 6c773c1159 | |||
| 5c7e9b6168 | |||
| ac87ed4908 | |||
| 4a8ce1a524 | |||
| 5053a1ea8e | |||
| 33fa899217 | |||
| 6fdbceefc2 | |||
| 7290cac643 | |||
| 5e8c7b11ea | |||
| 9c0cec8f0c | |||
| 9a313d2c1b | |||
| 2eb61f16ff | |||
| 12c2913ba1 | |||
| 54440da2cd | |||
| 7427838292 | |||
| b606e8c9a2 | |||
| ac709c07c3 | |||
| 08e83da91d | |||
| 3f672d5db3 | |||
| 65d98cf674 | |||
| d15c04d983 | |||
| 980627b675 | |||
| 1db6b8841c | |||
| 0678e25de7 | |||
| 5b3b8619e6 | |||
| b6607b2009 | |||
| c0b7924aad | |||
| ebd2834cc4 | |||
| 888c99d848 | |||
| 388ea6e49b | |||
| 736bf4d333 |
@@ -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
|
Without that, the UI falls back to its in-memory sample store via the
|
||||||
existing `data` adapter (parses are disabled).
|
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
|
## Skills
|
||||||
|
|
||||||
Cyclone ships 8 project-scoped AI-assistant skills under
|
Cyclone ships 8 project-scoped AI-assistant skills under
|
||||||
@@ -743,6 +769,17 @@ scope.
|
|||||||
|
|
||||||
Shipped sub-projects (most recent first):
|
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.**
|
- **Sub-project 19 (shipped) — Security hardening + health probe.**
|
||||||
Three pure-ASGI middlewares (`BodySizeLimitMiddleware`,
|
Three pure-ASGI middlewares (`BodySizeLimitMiddleware`,
|
||||||
`RateLimitMiddleware`, `SecurityHeadersMiddleware`) close the
|
`RateLimitMiddleware`, `SecurityHeadersMiddleware`) close the
|
||||||
|
|||||||
@@ -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
|
— one envelope, one line per claim/payout, then one summary — so the
|
||||||
UI can render records incrementally as they're produced.
|
UI can render records incrementally as they're produced.
|
||||||
|
|
||||||
CORS is configured to allow the Vite dev origin (``http://localhost:5173``)
|
CORS is configured to allow the Vite dev origins (``http://localhost:5173``
|
||||||
plus GET/POST with any header.
|
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
|
from __future__ import annotations
|
||||||
@@ -19,6 +22,7 @@ import csv
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -33,6 +37,7 @@ from pydantic import ValidationError
|
|||||||
from cyclone import __version__, db
|
from cyclone import __version__, db
|
||||||
from cyclone.db import Claim, ClaimState, Remittance
|
from cyclone.db import Claim, ClaimState, Remittance
|
||||||
from sqlalchemy import desc, or_
|
from sqlalchemy import desc, or_
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from cyclone.inbox_state import apply_999_rejections
|
from cyclone.inbox_state import apply_999_rejections
|
||||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||||
from cyclone.audit_log import AuditEvent, append_event, verify_chain
|
from cyclone.audit_log import AuditEvent, append_event, verify_chain
|
||||||
@@ -214,7 +219,17 @@ PAYER_FACTORIES_835: dict[str, Any] = {
|
|||||||
"generic_835": PayerConfig835.generic_835,
|
"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(
|
app = FastAPI(
|
||||||
title="Cyclone 837P / 835 Parser API",
|
title="Cyclone 837P / 835 Parser API",
|
||||||
@@ -225,7 +240,7 @@ app = FastAPI(
|
|||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[VITE_DEV_ORIGIN],
|
allow_origins=VITE_DEV_ORIGINS,
|
||||||
allow_credentials=False,
|
allow_credentials=False,
|
||||||
allow_methods=["GET", "POST"],
|
allow_methods=["GET", "POST"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -282,6 +297,35 @@ def _resolve_payer_835(name: str) -> PayerConfig835:
|
|||||||
return PAYER_FACTORIES_835[name]()
|
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")
|
@app.post("/api/parse-837")
|
||||||
async def parse_837(
|
async def parse_837(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -345,7 +389,30 @@ async def parse_837(
|
|||||||
parsed_at=utcnow(),
|
parsed_at=utcnow(),
|
||||||
result=result,
|
result=result,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
store.add(rec, event_bus=request.app.state.event_bus)
|
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):
|
if _client_wants_json(request):
|
||||||
body = json.loads(result.model_dump_json())
|
body = json.loads(result.model_dump_json())
|
||||||
@@ -516,7 +583,23 @@ async def parse_835_endpoint(
|
|||||||
parsed_at=utcnow(),
|
parsed_at=utcnow(),
|
||||||
result=result,
|
result=result,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
store.add(rec, event_bus=request.app.state.event_bus)
|
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):
|
if _client_wants_json(request):
|
||||||
body = json.loads(result.model_dump_json())
|
body = json.loads(result.model_dump_json())
|
||||||
|
|||||||
@@ -1668,7 +1668,16 @@ class CycloneStore:
|
|||||||
return list(by_npi.values())
|
return list(by_npi.values())
|
||||||
|
|
||||||
def recent_activity(self, *, limit: int = 200) -> list[dict]:
|
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:
|
with db.SessionLocal()() as s:
|
||||||
rows = (
|
rows = (
|
||||||
s.query(ActivityEvent)
|
s.query(ActivityEvent)
|
||||||
@@ -1684,6 +1693,8 @@ class CycloneStore:
|
|||||||
"timestamp": r.ts.isoformat().replace("+00:00", "Z"),
|
"timestamp": r.ts.isoformat().replace("+00:00", "Z"),
|
||||||
"npi": (r.payload_json or {}).get("npi"),
|
"npi": (r.payload_json or {}).get("npi"),
|
||||||
"amount": (r.payload_json or {}).get("amount"),
|
"amount": (r.payload_json or {}).get("amount"),
|
||||||
|
"claimId": r.claim_id,
|
||||||
|
"remittanceId": r.remittance_id,
|
||||||
}
|
}
|
||||||
for r in rows
|
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 resp.headers.get("access-control-allow-origin") == "http://localhost:5173"
|
||||||
assert "POST" in resp.headers.get("access-control-allow-methods", "").upper()
|
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,926 @@
|
|||||||
|
# Drop `UNIQUE(batch_id, patient_control_number)` + 409 UX Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Allow multi-claim 837P files (where many `CLM*` segments share a `member_id`) to ingest without 409, and surface a structured error panel on the upload page when a true duplicate claim still trips a 409.
|
||||||
|
|
||||||
|
**Architecture:** Backend migration drops the inline `UNIQUE(batch_id, patient_control_number)` on `claims` via SQLite table recreation. Two new store helpers (`find_existing_batch_for_claim`, `find_existing_batch_for_remit`) enable both 837 and 835 409 handlers to surface the id of the prior batch. Frontend `ApiError` carries `existingBatchId`; `Upload.tsx` renders an inline error panel above the streaming results with a link to the existing batch and a "Pick a different file" escape hatch.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11+, SQLAlchemy 2.x, FastAPI, SQLite (sqlcipher3 optional), React 18 + TanStack Query + Radix UI + sonner, Vitest + React Testing Library, pytest.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md` — read fully before starting.
|
||||||
|
|
||||||
|
**Worktree setup (one-time):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/openclaw/dev/cyclone
|
||||||
|
git worktree add .worktrees/claims-unique-fix -b claims-unique-fix main
|
||||||
|
cd .worktrees/claims-unique-fix
|
||||||
|
# Install backend deps if needed (venv assumed active)
|
||||||
|
pip install -e backend
|
||||||
|
# Install frontend deps if needed
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
All commits happen in this worktree. Merge to main via fast-forward when each phase ends.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Backend migration + helpers + API
|
||||||
|
|
||||||
|
### Task 1.1: Migration 0013 drops inline UNIQUE via table recreation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql`
|
||||||
|
- Modify: `backend/tests/test_db_migrate.py` (append test)
|
||||||
|
|
||||||
|
The runner (`backend/src/cyclone/db_migrate.py`) wraps each `.sql` in an implicit transaction via `engine.begin()`, so the migration MUST NOT use `BEGIN`/`COMMIT` or `PRAGMA foreign_keys=OFF` (no-op inside a transaction). Use `PRAGMA defer_foreign_keys = ON` instead — checks fire at commit against the renamed table.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `backend/tests/test_db_migrate.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Migration 0013 must drop the inline UNIQUE(batch_id, patient_control_number)
|
||||||
|
on claims by recreating the table. Idempotent and preserves data."""
|
||||||
|
monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
|
||||||
|
# Copy the real migrations so the test starts from v1.
|
||||||
|
real_dir = Path(db_migrate.__file__).parent / "migrations"
|
||||||
|
for src in sorted(real_dir.glob("00*.sql")):
|
||||||
|
(tmp_path / src.name).write_text(src.read_text())
|
||||||
|
|
||||||
|
engine = _fresh_engine(tmp_path)
|
||||||
|
db_migrate.run(engine)
|
||||||
|
# Before 0013: insert two claims with same (batch_id, patient_control_number) raises.
|
||||||
|
with engine.begin() as c:
|
||||||
|
c.exec_driver_sql("INSERT INTO batches(id, kind, input_filename, parsed_at) VALUES ('b1', '837p', 'x.txt', '2026-01-01')")
|
||||||
|
c.exec_driver_sql("INSERT INTO claims(id, batch_id, patient_control_number) VALUES ('c1', 'b1', 'M')")
|
||||||
|
with pytest.raises(sqlalchemy.exc.IntegrityError):
|
||||||
|
c.exec_driver_sql("INSERT INTO claims(id, batch_id, patient_control_number) VALUES ('c2', 'b1', 'M')")
|
||||||
|
# Now drop the migration in by name only:
|
||||||
|
(tmp_path / "0013_drop_claims_unique_constraint.sql").write_text(
|
||||||
|
"-- version: 13\n"
|
||||||
|
"PRAGMA defer_foreign_keys = ON;\n"
|
||||||
|
"CREATE TABLE claims_new ("
|
||||||
|
"id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,"
|
||||||
|
"patient_control_number TEXT NOT NULL, service_date_from DATE, service_date_to DATE,"
|
||||||
|
"charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0, provider_npi TEXT, payer_id TEXT,"
|
||||||
|
"state TEXT NOT NULL DEFAULT 'submitted', state_before_reversal TEXT,"
|
||||||
|
"matched_remittance_id TEXT REFERENCES remittances(id), raw_json TEXT,"
|
||||||
|
"rejection_reason TEXT, rejected_at TIMESTAMP, resubmit_count INTEGER NOT NULL DEFAULT 0,"
|
||||||
|
"state_changed_at TIMESTAMP, payer_rejected_at TEXT, payer_rejected_reason TEXT,"
|
||||||
|
"payer_rejected_status_code TEXT, payer_rejected_by_277ca_id TEXT,"
|
||||||
|
"payer_rejected_acknowledged_at TEXT, payer_rejected_acknowledged_actor TEXT);\n"
|
||||||
|
"INSERT INTO claims_new SELECT * FROM claims;"
|
||||||
|
"DROP TABLE claims;"
|
||||||
|
"ALTER TABLE claims_new RENAME TO claims;"
|
||||||
|
)
|
||||||
|
db_migrate.run(engine)
|
||||||
|
# After 0013: same insert succeeds.
|
||||||
|
with engine.begin() as c:
|
||||||
|
c.exec_driver_sql("INSERT INTO claims(id, batch_id, patient_control_number) VALUES ('c2', 'b1', 'M')")
|
||||||
|
rows = c.exec_driver_sql("SELECT COUNT(*) FROM claims").scalar()
|
||||||
|
assert rows == 2
|
||||||
|
assert _user_version(engine) == 13
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_0013_is_idempotent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Re-running db_migrate.run on a v13 DB is a no-op."""
|
||||||
|
monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
|
||||||
|
real_dir = Path(db_migrate.__file__).parent / "migrations"
|
||||||
|
for src in sorted(real_dir.glob("00*.sql")):
|
||||||
|
(tmp_path / src.name).write_text(src.read_text())
|
||||||
|
|
||||||
|
engine = _fresh_engine(tmp_path)
|
||||||
|
db_migrate.run(engine) # applies all
|
||||||
|
version_after_first = _user_version(engine)
|
||||||
|
db_migrate.run(engine) # second call: no-op
|
||||||
|
assert _user_version(engine) == version_after_first
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest tests/test_db_migrate.py::test_drop_claims_unique_constraint_migration tests/test_db_migrate.py::test_migration_0013_is_idempotent -v`
|
||||||
|
|
||||||
|
Expected: `test_drop_claims_unique_constraint_migration` FAILS because 0013 doesn't exist yet; `test_migration_0013_is_idempotent` PASSES (idempotency already works for any v).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write migration 0013**
|
||||||
|
|
||||||
|
Create `backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- version: 13
|
||||||
|
-- Drop the inline UNIQUE(batch_id, patient_control_number) on claims.
|
||||||
|
-- Migration 0003 attempted DROP INDEX IF EXISTS uq_claims_batch_pcn but
|
||||||
|
-- the constraint is inline in CREATE TABLE, so the drop was a no-op.
|
||||||
|
-- The only way to remove an inline UNIQUE in SQLite is table recreation.
|
||||||
|
--
|
||||||
|
-- X12 837P allows any number of CLM segments per 2000B subscriber loop;
|
||||||
|
-- claim identity is provided by the primary key (claims.id = CLM01).
|
||||||
|
-- The remittances table had a parallel constraint already removed in 0003
|
||||||
|
-- (because that one WAS a named index), so this migration only touches
|
||||||
|
-- claims.
|
||||||
|
--
|
||||||
|
-- The migration runner (db_migrate.py) wraps each .sql in an implicit
|
||||||
|
-- transaction via engine.begin(), so we MUST NOT use BEGIN/COMMIT.
|
||||||
|
-- PRAGMA defer_foreign_keys defers FK checks to commit, which is the
|
||||||
|
-- only way to drop a referenced table inside a transaction in SQLite.
|
||||||
|
|
||||||
|
PRAGMA defer_foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE claims_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
|
||||||
|
patient_control_number TEXT NOT NULL,
|
||||||
|
service_date_from DATE,
|
||||||
|
service_date_to DATE,
|
||||||
|
charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||||
|
provider_npi TEXT,
|
||||||
|
payer_id TEXT,
|
||||||
|
state TEXT NOT NULL DEFAULT 'submitted',
|
||||||
|
state_before_reversal TEXT,
|
||||||
|
matched_remittance_id TEXT REFERENCES remittances(id),
|
||||||
|
raw_json TEXT,
|
||||||
|
rejection_reason TEXT,
|
||||||
|
rejected_at TIMESTAMP,
|
||||||
|
resubmit_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
state_changed_at TIMESTAMP,
|
||||||
|
payer_rejected_at TEXT,
|
||||||
|
payer_rejected_reason TEXT,
|
||||||
|
payer_rejected_status_code TEXT,
|
||||||
|
payer_rejected_by_277ca_id TEXT,
|
||||||
|
payer_rejected_acknowledged_at TEXT,
|
||||||
|
payer_rejected_acknowledged_actor TEXT
|
||||||
|
-- NO UNIQUE (batch_id, patient_control_number) — removed.
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO claims_new SELECT * FROM claims;
|
||||||
|
DROP TABLE claims;
|
||||||
|
ALTER TABLE claims_new RENAME TO claims;
|
||||||
|
|
||||||
|
-- Recreate secondary indexes (same names, same columns as initial schema
|
||||||
|
-- plus later migrations).
|
||||||
|
CREATE INDEX ix_claims_state ON claims(state);
|
||||||
|
CREATE INDEX ix_claims_patient_control_number ON claims(patient_control_number);
|
||||||
|
CREATE INDEX ix_claims_service_date_from ON claims(service_date_from);
|
||||||
|
CREATE INDEX ix_claims_state_changed_at ON claims(state, state_changed_at);
|
||||||
|
CREATE INDEX idx_claims_payer_rejected_at ON claims(payer_rejected_at);
|
||||||
|
CREATE INDEX idx_claims_payer_rejected_unack
|
||||||
|
ON claims(payer_rejected_at)
|
||||||
|
WHERE payer_rejected_acknowledged_at IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest tests/test_db_migrate.py::test_drop_claims_unique_constraint_migration tests/test_db_migrate.py::test_migration_0013_is_idempotent -v`
|
||||||
|
|
||||||
|
Expected: PASS for both. The first test asserts the inline UNIQUE is gone (insert succeeds) and `user_version==13`. The second asserts idempotency.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql backend/tests/test_db_migrate.py
|
||||||
|
git commit -m "feat(db): drop inline UNIQUE(batch_id, patient_control_number) via migration 0013"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1.2: Store helpers `find_existing_batch_for_claim` and `find_existing_batch_for_remit`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/src/cyclone/store.py:1-50` (imports) and add new functions near the top
|
||||||
|
- Modify: `backend/tests/test_store.py` (append tests)
|
||||||
|
|
||||||
|
The helpers are pure reads. They return the batch_id of the first batch containing the given claim_id / remit_id, or None.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `backend/tests/test_store.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_find_existing_batch_for_claim_returns_none_for_unknown(global_store):
|
||||||
|
"""Unknown claim_id -> None."""
|
||||||
|
assert global_store.find_existing_batch_for_claim("nope") is None # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_existing_batch_for_claim_returns_batch_id(global_store):
|
||||||
|
"""Known claim_id -> batch_id of the holding batch."""
|
||||||
|
from cyclone.store import BatchRecord, _claim_837_row # noqa: F401
|
||||||
|
from cyclone.db import Batch, Claim, db as _db_mod
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from cyclone.parser.parsers_837p import ClaimOutput # noqa: F401
|
||||||
|
# Simpler: insert a batch + claim directly via the DB.
|
||||||
|
with _db_mod.SessionLocal()() as s:
|
||||||
|
s.add(Batch(id="B1", kind="837p", input_filename="x.txt",
|
||||||
|
parsed_at=datetime(2026,1,1,tzinfo=timezone.utc)))
|
||||||
|
s.add(Claim(id="CLM-A", batch_id="B1", patient_control_number="M1"))
|
||||||
|
s.commit()
|
||||||
|
assert global_store.find_existing_batch_for_claim("CLM-A") == "B1" # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_existing_batch_for_remit_returns_none_for_unknown(global_store):
|
||||||
|
"""Unknown remit id -> None."""
|
||||||
|
assert global_store.find_existing_batch_for_remit("nope") is None # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_existing_batch_for_remit_returns_batch_id(global_store):
|
||||||
|
"""Known remit id -> batch_id of the holding batch."""
|
||||||
|
from cyclone.db import Batch, Remittance, db as _db_mod
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
with _db_mod.SessionLocal()() as s:
|
||||||
|
s.add(Batch(id="B2", kind="835", input_filename="y.txt",
|
||||||
|
parsed_at=datetime(2026,1,1,tzinfo=timezone.utc)))
|
||||||
|
s.add(Remittance(id="CLP-A", batch_id="B2",
|
||||||
|
payer_claim_control_number="CLP-A",
|
||||||
|
status_code="1", received_at=datetime(2026,1,1,tzinfo=timezone.utc)))
|
||||||
|
s.commit()
|
||||||
|
assert global_store.find_existing_batch_for_remit("CLP-A") == "B2" # type: ignore[attr-defined]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest tests/test_store.py::test_find_existing_batch_for_claim_returns_none_for_unknown -v`
|
||||||
|
|
||||||
|
Expected: FAIL with `AttributeError: 'CycloneStore' object has no attribute 'find_existing_batch_for_claim'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement helpers**
|
||||||
|
|
||||||
|
Add to `backend/src/cyclone/store.py` near the top, after imports:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def find_existing_batch_for_claim(claim_id: str) -> str | None:
|
||||||
|
"""Return the batch_id of the first batch containing this claim id, or None.
|
||||||
|
|
||||||
|
Pure read; opens a short-lived session. Used by the 837 409 handler to
|
||||||
|
surface which prior batch already holds the same CLM01.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.db import Claim
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.execute(
|
||||||
|
select(Claim.batch_id).where(Claim.id == claim_id).limit(1)
|
||||||
|
).first()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def find_existing_batch_for_remit(remit_id: str) -> str | None:
|
||||||
|
"""Return the batch_id of the first batch containing this remit id, or None.
|
||||||
|
|
||||||
|
Pure read; opens a short-lived session. Used by the 835 409 handler.
|
||||||
|
`remit_id` is the PK on `remittances.id` (= payer_claim_control_number = CLP01).
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.db import Remittance
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.execute(
|
||||||
|
select(Remittance.batch_id).where(Remittance.id == remit_id).limit(1)
|
||||||
|
).first()
|
||||||
|
return row[0] if row else None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest tests/test_store.py -k "find_existing_batch" -v`
|
||||||
|
|
||||||
|
Expected: PASS for all 4 tests.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/src/cyclone/store.py backend/tests/test_store.py
|
||||||
|
git commit -m "feat(store): add find_existing_batch_for_claim and find_existing_batch_for_remit"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1.3: 409 handlers in api.py surface `existing_batch_id`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/src/cyclone/api.py:394-415` (837 409 handler)
|
||||||
|
- Modify: `backend/src/cyclone/api.py:588-602` (835 409 handler)
|
||||||
|
- Modify: `backend/tests/test_api_parse_persists.py` (append tests)
|
||||||
|
|
||||||
|
After migration 0013, IntegrityError on the 837 path can only fire when two claims in the same file share the same CLM01 (rare). The helper may still return a batch_id if the colliding CLM01 was previously ingested and not deleted (the dedup `s.get(Claim, claim_id)` only queries DB state, not pending session state, so cross-batch duplicates still get inserted and trip the PK).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add to `backend/tests/test_api_parse_persists.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_409_response_includes_existing_batch_id_for_837(client: TestClient):
|
||||||
|
"""When a CLM01 already exists in a prior batch, the 409 body has existing_batch_id."""
|
||||||
|
from cyclone.db import Batch, Claim
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
# Seed a prior batch with claim CLM-X.
|
||||||
|
with global_store._lock:
|
||||||
|
pass
|
||||||
|
from cyclone import db as _db
|
||||||
|
with _db.SessionLocal()() as s:
|
||||||
|
s.add(Batch(id="PRIOR", kind="837p", input_filename="prior.txt",
|
||||||
|
parsed_at=datetime(2026,1,1,tzinfo=timezone.utc),
|
||||||
|
raw_result_json={}))
|
||||||
|
s.add(Claim(id="CLM-X", batch_id="PRIOR", patient_control_number="M"))
|
||||||
|
s.commit()
|
||||||
|
# Build a file with two CLM* segments both using CLM-X (forces PK collision).
|
||||||
|
text = (
|
||||||
|
"ISA*00* *00* *ZZ*SUBMITTERID *ZZ*RECEIVERID "
|
||||||
|
"*240101*1200*^*00501*000000001*0*P*:~\n"
|
||||||
|
"GS*HC*SUBMITTERID*RECEIVERID*20240101*1200*1*X*005010X222A1~\n"
|
||||||
|
"ST*837*0001*005010X222A1~\n"
|
||||||
|
"BHT*0019*00*1*20240101*1200*CH~\n"
|
||||||
|
"NM1*41*2*SUBMITTER*****46*SUBMITTERID~\n"
|
||||||
|
"PER*IC*CONTACT*TE*5555555555~\n"
|
||||||
|
"NM1*40*2*RECEIVER*****46*RECEIVERID~\n"
|
||||||
|
"HL*1**20*1~\n"
|
||||||
|
"NM1*85*2*BILLING*****XX*1881068062~\n"
|
||||||
|
"N3*123 MAIN*\nN4*DENVER*CO*80202~\n"
|
||||||
|
"REF*EI*123456789~\n"
|
||||||
|
"HL*2*1*22*0~\n"
|
||||||
|
"SBR*P*18*******CI~\n"
|
||||||
|
"NM1*IL*1*DOE*JOHN****MI*M~\n"
|
||||||
|
"N3*456 ELM*\nN4*DENVER*CO*80202~\n"
|
||||||
|
"DMG*D8*19700101*M~\n"
|
||||||
|
"NM1*PR*2*MEDICAID*****PI*MCD~\n"
|
||||||
|
"CLM*CLM-X*100***11:B:1*Y*A*Y*Y~\n"
|
||||||
|
"LX*1~\nSV1*HC:99213*100*UN*1***1~\n"
|
||||||
|
"DTP*472*D8*20240101~\n"
|
||||||
|
"CLM*CLM-X*100***11:B:1*Y*A*Y*Y~\n"
|
||||||
|
"LX*2~\nSV1*HC:99213*100*UN*1***1~\n"
|
||||||
|
"DTP*472*D8*20240101~\n"
|
||||||
|
"SE*30*0001~\n"
|
||||||
|
"GE*1*1~\n"
|
||||||
|
"IEA*1*000000001~\n"
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": ("dup.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body.get("existing_batch_id") == "PRIOR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_409_response_includes_existing_batch_id_for_835(client: TestClient):
|
||||||
|
"""When a CLP01 already exists in a prior batch, the 835 409 body has existing_batch_id."""
|
||||||
|
from cyclone.db import Batch, Remittance
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from cyclone import db as _db
|
||||||
|
with _db.SessionLocal()() as s:
|
||||||
|
s.add(Batch(id="PRIOR835", kind="835", input_filename="prior.txt",
|
||||||
|
parsed_at=datetime(2026,1,1,tzinfo=timezone.utc),
|
||||||
|
raw_result_json={}))
|
||||||
|
s.add(Remittance(id="CLP-X", batch_id="PRIOR835",
|
||||||
|
payer_claim_control_number="CLP-X",
|
||||||
|
status_code="1",
|
||||||
|
received_at=datetime(2026,1,1,tzinfo=timezone.utc)))
|
||||||
|
s.commit()
|
||||||
|
# Build a minimal 835 with two CLP segments using CLP-X. This is contrived;
|
||||||
|
# we just need to trigger IntegrityError. The exact parser robustness to this
|
||||||
|
# fixture is not the focus — the test asserts the 409 body shape.
|
||||||
|
# ... or instead: directly invoke the handler via a unit-style test that
|
||||||
|
# pre-seeds and then makes a minimal 835 that includes CLP-X twice.
|
||||||
|
# For brevity, drop this test if constructing a fixture is too brittle;
|
||||||
|
# the 837 test above already exercises the same handler pattern.
|
||||||
|
pytest.skip("835 fixture with duplicate CLP01 is fragile; 837 case covers the pattern")
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the 835 test is intentionally skipped — constructing a minimal valid 835 with duplicate CLP01 is brittle. The 837 case exercises the handler pattern (call `find_existing_batch_for_remit` from a similarly-shaped except block in the 835 handler). If you need 835 coverage, manually inspect by running the API on a real fixture after the implementation lands.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest tests/test_api_parse_persists.py::test_409_response_includes_existing_batch_id_for_837 -v`
|
||||||
|
|
||||||
|
Expected: FAIL because the 409 handler does not yet add `existing_batch_id`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Modify 837 409 handler**
|
||||||
|
|
||||||
|
Edit `backend/src/cyclone/api.py` lines 394-415:
|
||||||
|
|
||||||
|
Replace the `except IntegrityError` block in `parse_837_endpoint` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
store.add(rec, event_bus=request.app.state.event_bus)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# After migration 0013, the only way an IntegrityError fires here
|
||||||
|
# is a PK collision on claims.id (CLM01) — either within-file or
|
||||||
|
# against a prior batch. Look up the first claim's id and ask the
|
||||||
|
# helper which batch already holds it.
|
||||||
|
first_claim_id = (
|
||||||
|
result.claims[0].claim_id if result.claims else None
|
||||||
|
)
|
||||||
|
existing_batch_id = (
|
||||||
|
store.find_existing_batch_for_claim(first_claim_id)
|
||||||
|
if first_claim_id else None
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"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 existing_batch_id and existing_batch_id != rec.id:
|
||||||
|
body["existing_batch_id"] = existing_batch_id
|
||||||
|
log.warning("Duplicate claim while persisting batch %s: %s", rec.id, exc)
|
||||||
|
return JSONResponse(status_code=409, content=body)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Modify 835 409 handler**
|
||||||
|
|
||||||
|
Edit `backend/src/cyclone/api.py` lines 588-602:
|
||||||
|
|
||||||
|
Replace the `except IntegrityError` block in `parse_835_endpoint` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
store.add(rec, event_bus=request.app.state.event_bus)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
first_pcn = (
|
||||||
|
result.claims[0].payer_claim_control_number
|
||||||
|
if result.claims else None
|
||||||
|
)
|
||||||
|
existing_batch_id = (
|
||||||
|
store.find_existing_batch_for_remit(first_pcn)
|
||||||
|
if first_pcn else None
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"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 existing_batch_id and existing_batch_id != rec.id:
|
||||||
|
body["existing_batch_id"] = existing_batch_id
|
||||||
|
log.warning("Duplicate remittance while persisting batch %s: %s", rec.id, exc)
|
||||||
|
return JSONResponse(status_code=409, content=body)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd backend && python -m pytest tests/test_api_parse_persists.py -v`
|
||||||
|
|
||||||
|
Expected: All PASS, including the new 409 test.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/src/cyclone/api.py backend/tests/test_api_parse_persists.py
|
||||||
|
git commit -m "feat(api): 409 responses for 837/435 include existing_batch_id"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Frontend
|
||||||
|
|
||||||
|
### Task 2.1: `ApiError.existingBatchId` + parse837/835 surface it
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/api.ts:164-167` (ApiError class)
|
||||||
|
- Modify: `src/lib/api.ts:174-191` (readErrorBody)
|
||||||
|
- Modify: `src/lib/api.ts:280-339` (parse837, parse835)
|
||||||
|
- Create: `src/lib/api.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `src/lib/api.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { ApiError, parse837 } from "@/lib/api";
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
describe("ApiError", () => {
|
||||||
|
it("carries existingBatchId when constructed", () => {
|
||||||
|
const e = new ApiError(409, "dup", "BATCH-1");
|
||||||
|
expect(e.status).toBe(409);
|
||||||
|
expect(e.existingBatchId).toBe("BATCH-1");
|
||||||
|
});
|
||||||
|
it("defaults existingBatchId to null", () => {
|
||||||
|
const e = new ApiError(500, "boom");
|
||||||
|
expect(e.existingBatchId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parse837 throws ApiError with existingBatchId", () => {
|
||||||
|
it("extracts existing_batch_id from 409 body", async () => {
|
||||||
|
vi.stubGlobal("import.meta.env", { VITE_API_BASE_URL: "http://x" });
|
||||||
|
const res = new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Duplicate claim",
|
||||||
|
detail: "collision",
|
||||||
|
batch_id: "NEW",
|
||||||
|
existing_batch_id: "PRIOR",
|
||||||
|
}),
|
||||||
|
{ status: 409, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => res),
|
||||||
|
);
|
||||||
|
const file = new File(["x"], "f.txt", { type: "text/plain" });
|
||||||
|
await expect(parse837(file, { onProgress: () => {} })).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
existingBatchId: "PRIOR",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets existingBatchId null when body omits it", async () => {
|
||||||
|
vi.stubGlobal("import.meta.env", { VITE_API_BASE_URL: "http://x" });
|
||||||
|
const res = new Response(
|
||||||
|
JSON.stringify({ error: "X", detail: "y", batch_id: "N" }),
|
||||||
|
{ status: 409, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => res),
|
||||||
|
);
|
||||||
|
const file = new File(["x"], "f.txt", { type: "text/plain" });
|
||||||
|
await expect(parse837(file)).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
existingBatchId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix && npm test -- src/lib/api.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL — `existingBatchId` not a constructor argument yet; `parse837` throws `Error` not `ApiError`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `ApiError`**
|
||||||
|
|
||||||
|
Edit `src/lib/api.ts` line 164-167:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
public existingBatchId: string | null = null,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `readErrorBody` to return parsed body**
|
||||||
|
|
||||||
|
Edit `src/lib/api.ts` line 174-191. Change return type and add JSON parse branch:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ErrorBody = {
|
||||||
|
detail?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
existing_batch_id?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readErrorBody(
|
||||||
|
res: Response,
|
||||||
|
): Promise<{ message: string; existingBatchId: string | null }> {
|
||||||
|
try {
|
||||||
|
const t = await res.text();
|
||||||
|
if (!t) return { message: "", existingBatchId: null };
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(t) as ErrorBody;
|
||||||
|
let message = "";
|
||||||
|
if (typeof obj.detail === "string") message = obj.detail;
|
||||||
|
else if (typeof obj.error === "string") message = obj.error;
|
||||||
|
else message = t;
|
||||||
|
const existing =
|
||||||
|
typeof obj.existing_batch_id === "string"
|
||||||
|
? obj.existing_batch_id
|
||||||
|
: null;
|
||||||
|
return { message, existingBatchId: existing };
|
||||||
|
} catch {
|
||||||
|
return { message: t, existingBatchId: null };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { message: "", existingBatchId: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update parse837 to throw ApiError**
|
||||||
|
|
||||||
|
Edit `src/lib/api.ts` lines 293-298:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (!res.ok) {
|
||||||
|
const { message, existingBatchId } = await readErrorBody(res);
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
`${res.status} ${res.statusText}${message ? ` — ${message}` : ""}`,
|
||||||
|
existingBatchId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update parse835 to throw ApiError**
|
||||||
|
|
||||||
|
Edit `src/lib/api.ts` lines 329-334:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (!res.ok) {
|
||||||
|
const { message, existingBatchId } = await readErrorBody(res);
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
`${res.status} ${res.statusText}${message ? ` — ${message}` : ""}`,
|
||||||
|
existingBatchId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd .worktrees/claims-unique-fix && npm test -- src/lib/api.test.ts`
|
||||||
|
|
||||||
|
Expected: PASS for all 4 tests.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/api.ts src/lib/api.test.ts
|
||||||
|
git commit -m "feat(api): ApiError carries existingBatchId; parse837/parse835 surface it"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2.2: `Upload.tsx` inline error panel for 409
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/Upload.tsx` (add error state + panel JSX + 409 catch branch)
|
||||||
|
- Create: `src/pages/Upload.test.tsx`
|
||||||
|
|
||||||
|
The panel renders above the streaming results when `uploadError.kind === "duplicate"`. It shows:
|
||||||
|
- A 409 badge
|
||||||
|
- "Duplicate claim — file not ingested" title
|
||||||
|
- Detail mentioning the file and (if `existingBatchId` is set) "Open the existing batch to compare, or pick a different file."
|
||||||
|
- A button linking to `/batches/{existingBatchId}` if present
|
||||||
|
- A "Pick a different file" ghost button that calls `pickFile(null)` and clears the error state
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `src/pages/Upload.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import * as apiModule from "@/lib/api";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { Upload } from "@/pages/Upload";
|
||||||
|
|
||||||
|
// We mock the api module so the component doesn't actually call fetch.
|
||||||
|
vi.mock("@/lib/api", async () => {
|
||||||
|
const actual = await vi.importActual<typeof apiModule>("@/lib/api");
|
||||||
|
return { ...actual, parse837: vi.fn(), parse835: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderUpload() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={["/upload"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/upload" element={<Upload />} />
|
||||||
|
<Route path="/batches/:id" element={<div data-testid="batch-page" />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Upload error panel", () => {
|
||||||
|
it("renders panel with link when 409 carries existingBatchId", async () => {
|
||||||
|
vi.mocked(apiModule.parse837).mockRejectedValueOnce(
|
||||||
|
new ApiError(409, "409 Conflict — dup", "PRIOR-BATCH"),
|
||||||
|
);
|
||||||
|
renderUpload();
|
||||||
|
// Find the file input and upload a file.
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
expect(input).toBeTruthy();
|
||||||
|
const file = new File(["x"], "test.txt", { type: "text/plain" });
|
||||||
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
// Wait for the panel to render.
|
||||||
|
const link = await screen.findByRole("button", { name: /open existing batch/i });
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/duplicate claim/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders panel without link when 409 omits existingBatchId", async () => {
|
||||||
|
vi.mocked(apiModule.parse837).mockRejectedValueOnce(
|
||||||
|
new ApiError(409, "409 Conflict — dup", null),
|
||||||
|
);
|
||||||
|
renderUpload();
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = new File(["x"], "test.txt", { type: "text/plain" });
|
||||||
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
await screen.findByText(/duplicate claim/i);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /open existing batch/i }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT render panel for non-409 errors", async () => {
|
||||||
|
vi.mocked(apiModule.parse837).mockRejectedValueOnce(
|
||||||
|
new ApiError(400, "bad file"),
|
||||||
|
);
|
||||||
|
renderUpload();
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = new File(["x"], "test.txt", { type: "text/plain" });
|
||||||
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
// Give the async error handler a tick.
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(screen.queryByText(/duplicate claim/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'Pick a different file' button clears error", async () => {
|
||||||
|
vi.mocked(apiModule.parse837).mockRejectedValueOnce(
|
||||||
|
new ApiError(409, "409 Conflict — dup", "PRIOR"),
|
||||||
|
);
|
||||||
|
renderUpload();
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = new File(["x"], "test.txt", { type: "text/plain" });
|
||||||
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
const clearBtn = await screen.findByRole("button", { name: /pick a different file/i });
|
||||||
|
fireEvent.click(clearBtn);
|
||||||
|
expect(screen.queryByText(/duplicate claim/i)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd .worktrees/claims-unique-fix && npm test -- src/pages/Upload.test.tsx`
|
||||||
|
|
||||||
|
Expected: FAIL — no error panel exists yet, all 4 tests fail.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add error state and 409 catch branch in Upload.tsx**
|
||||||
|
|
||||||
|
Edit `src/pages/Upload.tsx`:
|
||||||
|
|
||||||
|
1. At the top imports, add:
|
||||||
|
```tsx
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Find the existing error-handling catch block (around line 683-687) and replace with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
setUploadError({
|
||||||
|
kind: "duplicate",
|
||||||
|
existingBatchId: err.existingBatchId,
|
||||||
|
filename: file.name,
|
||||||
|
});
|
||||||
|
toast.error("Duplicate claim — file not ingested");
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Failed to parse file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add state declaration near the other useState calls:
|
||||||
|
```tsx
|
||||||
|
type UploadError =
|
||||||
|
| { kind: "duplicate"; existingBatchId: string | null; filename: string };
|
||||||
|
const [uploadError, setUploadError] = useState<UploadError | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
```
|
||||||
|
|
||||||
|
4. In the JSX, add the inline panel above the streaming-results section. Find the place where streaming results render (search for "streamDelay" or the section that shows the parsed batch) and insert just before it:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{uploadError && uploadError.kind === "duplicate" ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-testid="duplicate-error-panel"
|
||||||
|
className="error-panel mx-auto max-w-3xl rounded-md border border-destructive/40 bg-destructive/5 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center rounded-md bg-destructive px-2 py-0.5 text-xs font-semibold text-destructive-foreground">
|
||||||
|
409
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">Duplicate claim — file not ingested</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
<span className="font-mono">{uploadError.filename}</span> collides with an
|
||||||
|
existing record.
|
||||||
|
{uploadError.existingBatchId
|
||||||
|
? " Open the existing batch to compare, or pick a different file."
|
||||||
|
: " Pick a different file."}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
{uploadError.existingBatchId ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(`/batches/${uploadError.existingBatchId}`)}
|
||||||
|
>
|
||||||
|
Open existing batch →
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setUploadError(null);
|
||||||
|
pickFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pick a different file
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd .worktrees/claims-unique-fix && npm test -- src/pages/Upload.test.tsx`
|
||||||
|
|
||||||
|
Expected: PASS for all 4 tests.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/Upload.tsx src/pages/Upload.test.tsx
|
||||||
|
git commit -m "feat(upload): inline 409 error panel with existing-batch link"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — End-to-end verification
|
||||||
|
|
||||||
|
### Task 3.1: Repro the original 409 with the actual multi-claim 837P file
|
||||||
|
|
||||||
|
**Files:** none (manual verification)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Start backend + frontend**
|
||||||
|
|
||||||
|
In one terminal: `cd backend && uvicorn cyclone.api:app --reload --port 8000`
|
||||||
|
In another: `cd .worktrees/claims-unique-fix && npm run dev`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Upload the reproducer file**
|
||||||
|
|
||||||
|
Use `docs/prodfiles/837p-from-axiscare/tp11525703-837P-20260618153339862-1of1.txt` (93696 bytes, 28 NM1*IL segments, multi-claim member-id collisions).
|
||||||
|
|
||||||
|
Expected: 200 OK + batch persisted with multiple claims having identical `member_id` (this was previously 409).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-upload the same file to trigger 409**
|
||||||
|
|
||||||
|
Expected: 409 with `existing_batch_id` pointing at the first batch.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify inline panel in the browser**
|
||||||
|
|
||||||
|
Open the upload page, drag the file in, verify the panel appears with the "Open existing batch →" link.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit any tweaks**
|
||||||
|
|
||||||
|
If the panel needed any styling tweaks (e.g. spacing, colors), commit them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/Upload.tsx
|
||||||
|
git commit -m "polish(upload): 409 error panel visual tweaks"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review checklist (run after writing the plan)
|
||||||
|
|
||||||
|
1. **Spec coverage** — Each section of the spec maps to a task:
|
||||||
|
- §3 Schema migration → Task 1.1
|
||||||
|
- §4 Store helpers → Task 1.2
|
||||||
|
- §5 API change → Task 1.3
|
||||||
|
- §6 Frontend `ApiError.existingBatchId` → Task 2.1
|
||||||
|
- §6 Frontend `Upload.tsx` panel → Task 2.2
|
||||||
|
- §10 Test plan (9 tests) → All 9 covered (5 backend + 4 frontend)
|
||||||
|
|
||||||
|
2. **Placeholder scan** — No "TBD", "TODO", "implement later" markers. The only intentional skip is the 835 duplicate-CLP01 test, called out explicitly.
|
||||||
|
|
||||||
|
3. **Type consistency** —
|
||||||
|
- `find_existing_batch_for_claim` / `find_existing_batch_for_remit` return `str | None` everywhere.
|
||||||
|
- `ApiError.existingBatchId` is `string | null` in TS and `str | None` everywhere it's referenced.
|
||||||
|
- `setUploadError` payload shape `{ kind: "duplicate"; existingBatchId: string | null; filename: string }` is consistent in JSX and the catch branch.
|
||||||
|
|
||||||
|
4. **Risk acknowledged** — Migration reversibility, loss of uniqueness, race window are all documented in spec §9 and reflected in test scope (we don't test the race condition).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,32 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## As-built deviations
|
||||||
|
|
||||||
|
The implementation shipped with 10 deviations from this plan, all fixed
|
||||||
|
inline during TDD execution. They are catalogued here so a re-run of
|
||||||
|
the plan (or a reviewer of the implementation) knows what was
|
||||||
|
adjusted and why.
|
||||||
|
|
||||||
|
| # | Bug | Where | Fix |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1a | `AckTimeoutError.__init__(self, ack_kind, timeout_s: int)` — the `int` annotation broke tests asserting `timeout_s == pytest.approx(0.2, abs=0.1)`. | `src/cyclone_pipeline/exceptions.py` | Widened to `timeout_s: float`; added `self.phase = f"wait_{ack_kind}"` so `report.py` can render the right remediation hint. |
|
||||||
|
| 1b | `wait_for` raised `AckTimeoutError(label, int(timeout_s))` — round-tripping `float → int → float` lost sub-second precision and broke the same `pytest.approx` assertion. | `src/cyclone_pipeline/waiters.py` | Pass `timeout_s` through unchanged. |
|
||||||
|
| 2 | Markdown table assertion `assert "\| 1. Pre-flight" in text or "\| 1." in text` didn't match the actual table format `\| 1 \| preflight \|`. | `tests/test_report.py::test_markdown_contains_phase_table` | Tightened assertion to `\| 1 \| preflight`. |
|
||||||
|
| 3 | Markdown report had no "Remediation" section — operators had to read phase detail to figure out next steps when an ACK timed out. | `src/cyclone_pipeline/report.py::write_report_md` | Added a new `## Remediation` section after `expected_835_by` when `report.result` is `soft_fail` or `hard_fail`. Text mentions "in flight" and "Gainwell directly" so it's greppable. |
|
||||||
|
| 4 | 835 expected-by text didn't say *when* — just "CO Medicaid payment cycle". | `src/cyclone_pipeline/report.py::write_report_md` | Added "typically the following Monday" so operators can plan a check-835 run. |
|
||||||
|
| 5 | Test imported `HealthSnapshot as _Ignored` from `cyclone_pipeline.exceptions` — it lives in `state.py`. | `tests/test_pipeline.py` | Removed the bogus import. |
|
||||||
|
| 6 | Plan tests instantiated `CyclonePipeline(cfg, ...)` and called phases directly — but `__aexit__` is required to set `self._client` (and to close the browser on phase 2). | `tests/test_pipeline.py` (all phase tests) | Wrapped every `p = CyclonePipeline(...)` call in `async with CyclonePipeline(...) as p:`. |
|
||||||
|
| 7 | Plan did `from playwright.async_api import async_playwright` and `from cyclone_pipeline.browser import UploadPage` *inside* `phase2_upload` for "lazy loading" — but tests patch `cyclone_pipeline.pipeline.async_playwright` and `cyclone_pipeline.pipeline.UploadPage` (module-level), so the patches had no effect. | `src/cyclone_pipeline/pipeline.py` | Moved both imports to module level. |
|
||||||
|
| 8 | Plan had `phases=list(self.run_state.completed_phases)` — `PhaseRecord` (in `state.py`) and `PhaseResult` (in `report.py`) are distinct pydantic v2 models, so passing one where the other is expected fails (no auto-coercion). | `src/cyclone_pipeline/pipeline.py::phase7_scan_and_report` | Added `_phase_records_to_results()` helper that goes through `.model_dump()`; phase 7 now uses it. |
|
||||||
|
| 9 | Resume test stubbed phases 5/6/7 with `AsyncMock(return_value=PhaseResult(...))` — `resume()` only appends to `completed_phases` via its own logic; the mocks returned a result but didn't mutate state, so `len(report.phases) >= 4` failed (got 3 from the seeded phases 1/2/3). | `tests/test_pipeline.py::test_resume_skips_completed_phases` | Replaced with a `_make_stub(n, name)` factory that appends the result to `p.run_state.completed_phases`. Phase 4 also updated. |
|
||||||
|
| 10 | CLI `runner.invoke(main, ["status", "--run-dir", ...])` failed with click complaining that `--run-dir` appeared after the subcommand. | `src/cyclone_pipeline/cli.py` | Added `context_settings={"allow_interspersed_args": True}` to the `@click.group()` decorator. |
|
||||||
|
|
||||||
|
**Other as-built adjustments (not bugs):**
|
||||||
|
- Tasks 14–21 were committed as one combined commit `616e3be` instead of 8 separate commits — the plan's per-task commit cadence was dropped in favour of fewer, larger commits once the test-fix-verify cycle accelerated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## File structure
|
## File structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -393,9 +419,10 @@ class AckTimeoutError(CyclonePipelineError):
|
|||||||
"""An expected inbound ACK did not arrive within the configured
|
"""An expected inbound ACK did not arrive within the configured
|
||||||
timeout window. Soft-fail — the submission is in flight."""
|
timeout window. Soft-fail — the submission is in flight."""
|
||||||
|
|
||||||
def __init__(self, ack_kind: str, timeout_s: int):
|
def __init__(self, ack_kind: str, timeout_s: float):
|
||||||
self.ack_kind = ack_kind # "ta1" | "999" | "277ca" | "835"
|
self.ack_kind = ack_kind # "ta1" | "999" | "277ca" | "835"
|
||||||
self.timeout_s = timeout_s
|
self.timeout_s = timeout_s
|
||||||
|
self.phase = f"wait_{ack_kind}"
|
||||||
super().__init__(
|
super().__init__(
|
||||||
f"No {ack_kind} ACK arrived within {timeout_s}s; "
|
f"No {ack_kind} ACK arrived within {timeout_s}s; "
|
||||||
f"submission is in flight — check Gainwell directly."
|
f"submission is in flight — check Gainwell directly."
|
||||||
@@ -1984,7 +2011,7 @@ async def wait_for(
|
|||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
if time.monotonic() >= deadline:
|
if time.monotonic() >= deadline:
|
||||||
raise AckTimeoutError(label, int(timeout_s))
|
raise AckTimeoutError(label, timeout_s)
|
||||||
await asyncio.sleep(poll_interval_s)
|
await asyncio.sleep(poll_interval_s)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -2084,7 +2111,7 @@ def test_markdown_contains_phase_table(sample_report, tmp_path):
|
|||||||
assert "# Cyclone pipeline run" in text
|
assert "# Cyclone pipeline run" in text
|
||||||
assert "PASS" in text
|
assert "PASS" in text
|
||||||
assert "Phase summary" in text
|
assert "Phase summary" in text
|
||||||
assert "| 1. Pre-flight" in text or "| 1." in text
|
assert "| 1 | preflight" in text
|
||||||
assert "check-835" in text
|
assert "check-835" in text
|
||||||
assert "Monday" in text
|
assert "Monday" in text
|
||||||
assert "01-preflight.png" in text
|
assert "01-preflight.png" in text
|
||||||
@@ -2186,12 +2213,29 @@ def write_report_md(report: RunReport, path: Path) -> None:
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(
|
lines.append(
|
||||||
f"- **835 expected by {report.expected_835_by}** "
|
f"- **835 expected by {report.expected_835_by}** "
|
||||||
f"(CO Medicaid payment cycle)."
|
f"(typically the following Monday; CO Medicaid payment cycle)."
|
||||||
)
|
)
|
||||||
cmd = report.next_steps.get("check_835_cmd")
|
cmd = report.next_steps.get("check_835_cmd")
|
||||||
if cmd:
|
if cmd:
|
||||||
lines.append(f" - Re-run: `{cmd}`")
|
lines.append(f" - Re-run: `{cmd}`")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
# Remediation section — only when the run is not a clean pass.
|
||||||
|
if report.result in ("soft_fail", "hard_fail"):
|
||||||
|
lines.append("## Remediation")
|
||||||
|
lines.append("")
|
||||||
|
timed_out = [p for p in report.phases if p.status == "timeout"]
|
||||||
|
if timed_out:
|
||||||
|
names = ", ".join(p.name for p in timed_out)
|
||||||
|
lines.append(
|
||||||
|
f"- ACK timeout(s) in: {names}. Submission is in flight "
|
||||||
|
f"— check Gainwell directly for the inbound 999."
|
||||||
|
)
|
||||||
|
if report.result == "hard_fail":
|
||||||
|
lines.append(
|
||||||
|
"- Hard failure: review the run logs and any screenshots "
|
||||||
|
f"in the `screenshots/` directory before retrying."
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
lines.append("## Artifacts")
|
lines.append("## Artifacts")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
for s in report.screenshots:
|
for s in report.screenshots:
|
||||||
@@ -2433,7 +2477,7 @@ from pathlib import Path
|
|||||||
from cyclone_pipeline.pipeline import CyclonePipeline, PipelineConfig
|
from cyclone_pipeline.pipeline import CyclonePipeline, PipelineConfig
|
||||||
from cyclone_pipeline.state import RunState
|
from cyclone_pipeline.state import RunState
|
||||||
from cyclone_pipeline.exceptions import (
|
from cyclone_pipeline.exceptions import (
|
||||||
BrowserNotReachableError, HealthSnapshot as _Ignored, # noqa
|
BrowserNotReachableError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2474,7 +2518,7 @@ async def test_phase1_preflight_passes_when_backend_healthy(tmp_path: Path):
|
|||||||
run_dir=tmp_path / "runs",
|
run_dir=tmp_path / "runs",
|
||||||
headless=True,
|
headless=True,
|
||||||
)
|
)
|
||||||
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
|
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
|
||||||
p._run_id = "test-run-001"
|
p._run_id = "test-run-001"
|
||||||
result = await p.phase1_preflight()
|
result = await p.phase1_preflight()
|
||||||
assert result.status == "ok"
|
assert result.status == "ok"
|
||||||
@@ -2511,7 +2555,7 @@ async def test_phase1_preflight_fails_fast_if_browser_down(tmp_path: Path):
|
|||||||
run_dir=tmp_path / "runs",
|
run_dir=tmp_path / "runs",
|
||||||
headless=True,
|
headless=True,
|
||||||
)
|
)
|
||||||
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
|
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
|
||||||
p._run_id = "test-run-001"
|
p._run_id = "test-run-001"
|
||||||
with pytest.raises(BrowserNotReachableError):
|
with pytest.raises(BrowserNotReachableError):
|
||||||
await p.phase1_preflight()
|
await p.phase1_preflight()
|
||||||
@@ -2547,12 +2591,14 @@ import uuid
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Sequence
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import structlog
|
import structlog
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
from cyclone_pipeline.api_client import CycloneClient
|
from cyclone_pipeline.api_client import CycloneClient
|
||||||
|
from cyclone_pipeline.browser import UploadPage
|
||||||
from cyclone_pipeline.exceptions import (
|
from cyclone_pipeline.exceptions import (
|
||||||
BrowserNotReachableError, CyclonePipelineError, UploadError,
|
BrowserNotReachableError, CyclonePipelineError, UploadError,
|
||||||
ParseError, SubmitError, AckTimeoutError, IdempotencyError,
|
ParseError, SubmitError, AckTimeoutError, IdempotencyError,
|
||||||
@@ -2560,7 +2606,7 @@ from cyclone_pipeline.exceptions import (
|
|||||||
from cyclone_pipeline.logging_setup import get_logger
|
from cyclone_pipeline.logging_setup import get_logger
|
||||||
from cyclone_pipeline.report import PhaseResult, RunReport
|
from cyclone_pipeline.report import PhaseResult, RunReport
|
||||||
from cyclone_pipeline.screenshots import ScreenshotWriter
|
from cyclone_pipeline.screenshots import ScreenshotWriter
|
||||||
from cyclone_pipeline.state import RunState
|
from cyclone_pipeline.state import PhaseRecord, RunState
|
||||||
|
|
||||||
log = get_logger("cyclone_pipeline.pipeline")
|
log = get_logger("cyclone_pipeline.pipeline")
|
||||||
|
|
||||||
@@ -2706,7 +2752,7 @@ async def test_phase2_upload_retries_on_failure(tmp_path: Path):
|
|||||||
run_dir=tmp_path / "runs",
|
run_dir=tmp_path / "runs",
|
||||||
headless=True,
|
headless=True,
|
||||||
)
|
)
|
||||||
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
|
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
|
||||||
p._run_id = "test-run-002"
|
p._run_id = "test-run-002"
|
||||||
|
|
||||||
fake_page = AsyncMock()
|
fake_page = AsyncMock()
|
||||||
@@ -2763,9 +2809,6 @@ Append to `cyclone_pipeline/pipeline.py`:
|
|||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
for attempt in range(1, max_attempts + 1):
|
for attempt in range(1, max_attempts + 1):
|
||||||
try:
|
try:
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
from cyclone_pipeline.browser import UploadPage
|
|
||||||
|
|
||||||
async with async_playwright() as pw:
|
async with async_playwright() as pw:
|
||||||
browser = await pw.chromium.launch(
|
browser = await pw.chromium.launch(
|
||||||
headless=self.cfg.headless
|
headless=self.cfg.headless
|
||||||
@@ -2868,7 +2911,7 @@ async def test_phase3_verify_parse_collects_new_claim_ids(tmp_path: Path):
|
|||||||
browser_base="http://127.0.0.1:5173",
|
browser_base="http://127.0.0.1:5173",
|
||||||
run_dir=tmp_path / "runs",
|
run_dir=tmp_path / "runs",
|
||||||
)
|
)
|
||||||
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
|
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
|
||||||
p._run_id = "test-run-003"
|
p._run_id = "test-run-003"
|
||||||
result = await p.phase3_verify_parse()
|
result = await p.phase3_verify_parse()
|
||||||
assert result.status == "ok"
|
assert result.status == "ok"
|
||||||
@@ -2887,7 +2930,7 @@ async def test_phase3_verify_parse_fails_on_zero_claims(tmp_path: Path):
|
|||||||
browser_base="http://127.0.0.1:5173",
|
browser_base="http://127.0.0.1:5173",
|
||||||
run_dir=tmp_path / "runs",
|
run_dir=tmp_path / "runs",
|
||||||
)
|
)
|
||||||
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
|
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
|
||||||
p._run_id = "test-run-003b"
|
p._run_id = "test-run-003b"
|
||||||
with pytest.raises(ParseError):
|
with pytest.raises(ParseError):
|
||||||
await p.phase3_verify_parse(timeout_s=0.2)
|
await p.phase3_verify_parse(timeout_s=0.2)
|
||||||
@@ -2995,7 +3038,7 @@ async def test_phase4_submit_stores_submission_data(tmp_path: Path):
|
|||||||
browser_base="http://127.0.0.1:5173",
|
browser_base="http://127.0.0.1:5173",
|
||||||
run_dir=tmp_path / "runs",
|
run_dir=tmp_path / "runs",
|
||||||
)
|
)
|
||||||
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
|
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
|
||||||
p._run_id = "test-run-004"
|
p._run_id = "test-run-004"
|
||||||
p.run_state.pending_data["claim_ids"] = ["CLM-1", "CLM-2"]
|
p.run_state.pending_data["claim_ids"] = ["CLM-1", "CLM-2"]
|
||||||
result = await p.phase4_submit()
|
result = await p.phase4_submit()
|
||||||
@@ -3019,7 +3062,7 @@ async def test_phase4_submit_propagates_idempotency_error(tmp_path: Path):
|
|||||||
browser_base="http://127.0.0.1:5173",
|
browser_base="http://127.0.0.1:5173",
|
||||||
run_dir=tmp_path / "runs",
|
run_dir=tmp_path / "runs",
|
||||||
)
|
)
|
||||||
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
|
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
|
||||||
p._run_id = "test-run-004b"
|
p._run_id = "test-run-004b"
|
||||||
with pytest.raises(IdempotencyError):
|
with pytest.raises(IdempotencyError):
|
||||||
await p.phase4_submit()
|
await p.phase4_submit()
|
||||||
@@ -3128,7 +3171,7 @@ async def test_phase5_wait_ta1_returns_matching_ack(tmp_path: Path):
|
|||||||
ta1_timeout_s=2.0,
|
ta1_timeout_s=2.0,
|
||||||
poll_interval_s=0.05,
|
poll_interval_s=0.05,
|
||||||
)
|
)
|
||||||
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
|
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
|
||||||
p._run_id = "test-run-005"
|
p._run_id = "test-run-005"
|
||||||
p.run_state.pending_data["submission_filename"] = (
|
p.run_state.pending_data["submission_filename"] = (
|
||||||
"11525703-837P-…-1of1.txt"
|
"11525703-837P-…-1of1.txt"
|
||||||
@@ -3152,7 +3195,7 @@ async def test_phase5_wait_ta1_timeout_returns_status_timeout(tmp_path: Path):
|
|||||||
ta1_timeout_s=0.2,
|
ta1_timeout_s=0.2,
|
||||||
poll_interval_s=0.05,
|
poll_interval_s=0.05,
|
||||||
)
|
)
|
||||||
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
|
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
|
||||||
p._run_id = "test-run-005b"
|
p._run_id = "test-run-005b"
|
||||||
p.run_state.pending_data["submission_filename"] = "x.txt"
|
p.run_state.pending_data["submission_filename"] = "x.txt"
|
||||||
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
|
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
|
||||||
@@ -3261,7 +3304,7 @@ async def test_phase6_wait_999_returns_matching_ack(tmp_path: Path):
|
|||||||
ack_999_timeout_s=2.0,
|
ack_999_timeout_s=2.0,
|
||||||
poll_interval_s=0.05,
|
poll_interval_s=0.05,
|
||||||
)
|
)
|
||||||
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
|
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
|
||||||
p._run_id = "test-run-006"
|
p._run_id = "test-run-006"
|
||||||
p.run_state.pending_data["submission_filename"] = "x.txt"
|
p.run_state.pending_data["submission_filename"] = "x.txt"
|
||||||
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
|
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
|
||||||
@@ -3288,7 +3331,7 @@ async def test_phase6_wait_999_rejected_is_hard_fail(tmp_path: Path):
|
|||||||
ack_999_timeout_s=2.0,
|
ack_999_timeout_s=2.0,
|
||||||
poll_interval_s=0.05,
|
poll_interval_s=0.05,
|
||||||
)
|
)
|
||||||
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
|
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
|
||||||
p._run_id = "test-run-006b"
|
p._run_id = "test-run-006b"
|
||||||
p.run_state.pending_data["submission_filename"] = "x.txt"
|
p.run_state.pending_data["submission_filename"] = "x.txt"
|
||||||
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
|
p.run_state.pending_data["claim_ids"] = ["CLM-1"]
|
||||||
@@ -3405,7 +3448,7 @@ async def test_phase7_scan_html_and_write_reports(tmp_path: Path):
|
|||||||
browser_base="http://127.0.0.1:5173",
|
browser_base="http://127.0.0.1:5173",
|
||||||
run_dir=tmp_path / "runs",
|
run_dir=tmp_path / "runs",
|
||||||
)
|
)
|
||||||
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
|
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
|
||||||
p._run_id = "test-run-007"
|
p._run_id = "test-run-007"
|
||||||
# Seed a couple of completed phases to make the report non-empty
|
# Seed a couple of completed phases to make the report non-empty
|
||||||
from cyclone_pipeline.report import PhaseResult
|
from cyclone_pipeline.report import PhaseResult
|
||||||
@@ -3562,7 +3605,9 @@ Append to `cyclone_pipeline/pipeline.py`:
|
|||||||
duration_s=sum(
|
duration_s=sum(
|
||||||
p.duration_s for p in self.run_state.completed_phases
|
p.duration_s for p in self.run_state.completed_phases
|
||||||
),
|
),
|
||||||
phases=list(self.run_state.completed_phases),
|
phases=self._phase_records_to_results(
|
||||||
|
self.run_state.completed_phases
|
||||||
|
),
|
||||||
expected_835_by=expected_835,
|
expected_835_by=expected_835,
|
||||||
next_steps={
|
next_steps={
|
||||||
"check_835_cmd": (
|
"check_835_cmd": (
|
||||||
@@ -3647,6 +3692,18 @@ Append to `cyclone_pipeline/pipeline.py`:
|
|||||||
|
|
||||||
# --- helpers ---------------------------------------------------------------
|
# --- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _phase_records_to_results(
|
||||||
|
records: Sequence[PhaseRecord],
|
||||||
|
) -> list[PhaseResult]:
|
||||||
|
"""Convert PhaseRecord (state.py) → PhaseResult (report.py).
|
||||||
|
|
||||||
|
Both models have the same fields, but pydantic v2 doesn't
|
||||||
|
auto-coerce between distinct model classes, so we go via
|
||||||
|
`model_dump()`. Called when building the final RunReport.
|
||||||
|
"""
|
||||||
|
return [PhaseResult(**r.model_dump()) for r in records]
|
||||||
|
|
||||||
|
|
||||||
def _next_monday_iso() -> str:
|
def _next_monday_iso() -> str:
|
||||||
"""Return YYYY-MM-DD of the next expected 835 arrival.
|
"""Return YYYY-MM-DD of the next expected 835 arrival.
|
||||||
Computed from today in Mountain Time. If today is Monday before
|
Computed from today in Mountain Time. If today is Monday before
|
||||||
@@ -3705,7 +3762,7 @@ async def test_resume_skips_completed_phases(tmp_path: Path):
|
|||||||
browser_base="http://127.0.0.1:5173",
|
browser_base="http://127.0.0.1:5173",
|
||||||
run_dir=tmp_path / "runs",
|
run_dir=tmp_path / "runs",
|
||||||
)
|
)
|
||||||
p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi")
|
async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p:
|
||||||
p._run_id = "resume-run-001"
|
p._run_id = "resume-run-001"
|
||||||
run_dir = tmp_path / "runs" / "resume-run-001"
|
run_dir = tmp_path / "runs" / "resume-run-001"
|
||||||
run_dir.mkdir(parents=True, exist_ok=True)
|
run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -3723,18 +3780,22 @@ async def test_resume_skips_completed_phases(tmp_path: Path):
|
|||||||
called = {"phase4": False}
|
called = {"phase4": False}
|
||||||
async def fake_phase4():
|
async def fake_phase4():
|
||||||
called["phase4"] = True
|
called["phase4"] = True
|
||||||
return PhaseResult(n=4, name="submit", status="ok", duration_s=1.0)
|
result = PhaseResult(n=4, name="submit", status="ok", duration_s=1.0)
|
||||||
|
p.run_state.completed_phases.append(result)
|
||||||
|
return result
|
||||||
p.phase4_submit = fake_phase4 # type: ignore[assignment]
|
p.phase4_submit = fake_phase4 # type: ignore[assignment]
|
||||||
# Stub out phases 5, 6, 7 to short-circuit
|
|
||||||
p.phase5_wait_ta1 = AsyncMock(return_value=PhaseResult( # type: ignore[assignment]
|
def _make_stub(n: int, name: str):
|
||||||
n=5, name="wait_ta1", status="ok", duration_s=0.1
|
"""Stub factory: appends the result to completed_phases so
|
||||||
))
|
resume() sees them in the final report."""
|
||||||
p.phase6_wait_999 = AsyncMock(return_value=PhaseResult( # type: ignore[assignment]
|
async def stub():
|
||||||
n=6, name="wait_999", status="ok", duration_s=0.1
|
result = PhaseResult(n=n, name=name, status="ok", duration_s=0.1)
|
||||||
))
|
p.run_state.completed_phases.append(result)
|
||||||
p.phase7_scan_and_report = AsyncMock(return_value=PhaseResult( # type: ignore[assignment]
|
return result
|
||||||
n=7, name="scan_and_report", status="ok", duration_s=0.1
|
return stub
|
||||||
))
|
p.phase5_wait_ta1 = _make_stub(5, "wait_ta1") # type: ignore[assignment]
|
||||||
|
p.phase6_wait_999 = _make_stub(6, "wait_999") # type: ignore[assignment]
|
||||||
|
p.phase7_scan_and_report = _make_stub(7, "scan_and_report") # type: ignore[assignment]
|
||||||
|
|
||||||
# Mock the API responses needed for phases 4-7
|
# Mock the API responses needed for phases 4-7
|
||||||
respx.post("http://127.0.0.1:8000/api/clearhouse/submit").mock(
|
respx.post("http://127.0.0.1:8000/api/clearhouse/submit").mock(
|
||||||
@@ -3760,7 +3821,6 @@ async def test_resume_skips_completed_phases(tmp_path: Path):
|
|||||||
return_value=Response(200, json=[])
|
return_value=Response(200, json=[])
|
||||||
)
|
)
|
||||||
|
|
||||||
async with p:
|
|
||||||
report = await p.resume("resume-run-001")
|
report = await p.resume("resume-run-001")
|
||||||
assert called["phase4"] is True
|
assert called["phase4"] is True
|
||||||
assert len(report.phases) >= 4
|
assert len(report.phases) >= 4
|
||||||
@@ -4111,7 +4171,7 @@ def _run_dir_default() -> Path:
|
|||||||
return Path.cwd() / "runs"
|
return Path.cwd() / "runs"
|
||||||
|
|
||||||
|
|
||||||
@click.group()
|
@click.group(context_settings={"allow_interspersed_args": True})
|
||||||
@click.option(
|
@click.option(
|
||||||
"--api-base", default="http://127.0.0.1:8000",
|
"--api-base", default="http://127.0.0.1:8000",
|
||||||
envvar="CYCLONE_API_BASE",
|
envvar="CYCLONE_API_BASE",
|
||||||
|
|||||||
+386
@@ -0,0 +1,386 @@
|
|||||||
|
# Drop `UNIQUE(batch_id, patient_control_number)` on Claims + Robust 409 UX
|
||||||
|
|
||||||
|
**Date:** 2026-06-21
|
||||||
|
**Branch:** `main`
|
||||||
|
**Status:** Draft (brainstorming approved, awaiting writing-plans)
|
||||||
|
**Scope:** Backend schema + minimal frontend error handling for collision 409.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this exists
|
||||||
|
|
||||||
|
Today, every multi-claim 837P file in `docs/prodfiles/837p-from-axiscare/` and
|
||||||
|
`docs/prodfiles/FromHPE/` returns **HTTP 409** on upload. The cause:
|
||||||
|
|
||||||
|
* The `claims` table has `UNIQUE(batch_id, patient_control_number)` declared
|
||||||
|
inline in `CREATE TABLE` (auto-index `sqlite_autoindex_claims_2`).
|
||||||
|
* `store._claim_837_row` populates `patient_control_number` from
|
||||||
|
`claim.subscriber.member_id` (the subscriber's insurance member id),
|
||||||
|
not from the CLM01 segment value.
|
||||||
|
* A real 837P submission routinely contains many `CLM*` segments per
|
||||||
|
subscriber (a member seeing multiple providers on the same day). All
|
||||||
|
those rows share the same `member_id` → same `patient_control_number`.
|
||||||
|
Within one batch, the constraint fires on claim #2.
|
||||||
|
|
||||||
|
Migration `0003_drop_claims_remits_unique_constraints.sql` already exists
|
||||||
|
with the right intent but is **buggy**: it does
|
||||||
|
`DROP INDEX IF EXISTS uq_claims_batch_pcn`, but the constraint is inline,
|
||||||
|
not a named index — so the `DROP INDEX` is a no-op and the constraint
|
||||||
|
survives.
|
||||||
|
|
||||||
|
The 409 error message ("duplicate CLM01 control numbers") is also
|
||||||
|
misleading because the column stores `member_id`, not CLM01.
|
||||||
|
|
||||||
|
This SP fixes both: drops the constraint properly via SQLite table
|
||||||
|
recreation, and gives the upload page a structured error panel for the
|
||||||
|
collision case so the operator can find the existing batch in one click.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Operator surface
|
||||||
|
|
||||||
|
| Surface | Change |
|
||||||
|
|---|---|
|
||||||
|
| DB | New migration `0013_drop_claims_unique_constraint.sql`; idempotent. |
|
||||||
|
| Python | `store.find_existing_batch_for_claim(claim_id)` new helper. |
|
||||||
|
| API | `POST /api/parse-837` and `/api/parse-835` 409 responses gain `existing_batch_id`. |
|
||||||
|
| UI | `/upload` page renders an inline error panel for 409 with a link to the existing batch. |
|
||||||
|
| Tests | New migration test, store helper test, API test, component test. |
|
||||||
|
|
||||||
|
No CLI / settings changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Schema migration
|
||||||
|
|
||||||
|
`backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- version: 13
|
||||||
|
-- Drop the inline UNIQUE(batch_id, patient_control_number) on claims.
|
||||||
|
-- Migration 0003 attempted DROP INDEX IF EXISTS uq_claims_batch_pcn but
|
||||||
|
-- the constraint is inline in CREATE TABLE, so the drop was a no-op.
|
||||||
|
-- The only way to remove an inline UNIQUE in SQLite is table recreation.
|
||||||
|
--
|
||||||
|
-- X12 837P allows any number of CLM segments per 2000B subscriber loop;
|
||||||
|
-- claim identity is provided by the primary key (claims.id = CLM01).
|
||||||
|
-- The remittances table had a parallel constraint already removed in 0003
|
||||||
|
-- (because that one WAS a named index), so this migration only touches
|
||||||
|
-- claims.
|
||||||
|
--
|
||||||
|
-- The migration runner (db_migrate.py) wraps each .sql file in an
|
||||||
|
-- implicit transaction via engine.begin(), so we MUST NOT use BEGIN/COMMIT
|
||||||
|
-- inside the file (nested transactions fail in SQLite). We defer FK
|
||||||
|
-- enforcement with PRAGMA defer_foreign_keys instead of turning FKs off
|
||||||
|
-- (which is a no-op inside a transaction in SQLite). The deferred
|
||||||
|
-- checks fire at commit and validate against the renamed claims table.
|
||||||
|
|
||||||
|
PRAGMA defer_foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE claims_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
|
||||||
|
patient_control_number TEXT NOT NULL,
|
||||||
|
service_date_from DATE,
|
||||||
|
service_date_to DATE,
|
||||||
|
charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||||
|
provider_npi TEXT,
|
||||||
|
payer_id TEXT,
|
||||||
|
state TEXT NOT NULL DEFAULT 'submitted',
|
||||||
|
state_before_reversal TEXT,
|
||||||
|
matched_remittance_id TEXT REFERENCES remittances(id),
|
||||||
|
raw_json TEXT,
|
||||||
|
rejection_reason TEXT,
|
||||||
|
rejected_at TIMESTAMP,
|
||||||
|
resubmit_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
state_changed_at TIMESTAMP,
|
||||||
|
payer_rejected_at TEXT,
|
||||||
|
payer_rejected_reason TEXT,
|
||||||
|
payer_rejected_status_code TEXT,
|
||||||
|
payer_rejected_by_277ca_id TEXT,
|
||||||
|
payer_rejected_acknowledged_at TEXT,
|
||||||
|
payer_rejected_acknowledged_actor TEXT
|
||||||
|
-- NO UNIQUE (batch_id, patient_control_number) — removed.
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO claims_new SELECT * FROM claims;
|
||||||
|
DROP TABLE claims;
|
||||||
|
ALTER TABLE claims_new RENAME TO claims;
|
||||||
|
|
||||||
|
-- Recreate secondary indexes (same names, same columns as initial schema
|
||||||
|
-- plus later migrations).
|
||||||
|
CREATE INDEX ix_claims_state ON claims(state);
|
||||||
|
CREATE INDEX ix_claims_patient_control_number ON claims(patient_control_number);
|
||||||
|
CREATE INDEX ix_claims_service_date_from ON claims(service_date_from);
|
||||||
|
CREATE INDEX ix_claims_state_changed_at ON claims(state, state_changed_at);
|
||||||
|
CREATE INDEX idx_claims_payer_rejected_at ON claims(payer_rejected_at);
|
||||||
|
CREATE INDEX idx_claims_payer_rejected_unack
|
||||||
|
ON claims(payer_rejected_at)
|
||||||
|
WHERE payer_rejected_acknowledged_at IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note on the `db.py` ORM model:** `backend/src/cyclone/db.py` declares
|
||||||
|
the `Claim` ORM model with `UNIQUE(batch_id, patient_control_number)` in
|
||||||
|
`__table_args__`. After this migration the DB no longer enforces that
|
||||||
|
constraint, so the ORM declaration becomes aspirational. We leave it in
|
||||||
|
place as documentation (and as a guard if the DB ever gets rebuilt from
|
||||||
|
ORM on a fresh schema). No code reads through it; the dedup happens via
|
||||||
|
PK on `claims.id` in `store.add`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Store helper
|
||||||
|
|
||||||
|
New function in `backend/src/cyclone/store.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def find_existing_batch_for_claim(claim_id: str) -> str | None:
|
||||||
|
"""Return the batch_id of the first batch containing this CLM01, or None."""
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.db import Claim
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.execute(
|
||||||
|
select(Claim.batch_id).where(Claim.id == claim_id).limit(1)
|
||||||
|
).first()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def find_existing_batch_for_remit(payer_claim_control_number: str) -> str | None:
|
||||||
|
"""Return the batch_id of the first batch containing this CLP01, or None."""
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.db import Remittance
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.execute(
|
||||||
|
select(Remittance.batch_id)
|
||||||
|
.where(Remittance.id == payer_claim_control_number)
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
return row[0] if row else None
|
||||||
|
```
|
||||||
|
|
||||||
|
Both pure reads; no transaction management needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API change
|
||||||
|
|
||||||
|
`backend/src/cyclone/api.py`:
|
||||||
|
|
||||||
|
### 837 path (line 394-415)
|
||||||
|
|
||||||
|
After the UNIQUE constraint is dropped in migration 0013, an `IntegrityError`
|
||||||
|
in `store.add` can only originate from the PK on `claims.id` (CLM01) —
|
||||||
|
either two claims in the same file share CLM01 (rare) or the same CLM01
|
||||||
|
exists in a prior batch. The handler picks the first claim from
|
||||||
|
`result.claims` and asks the helper whether a prior batch already holds
|
||||||
|
that CLM01:
|
||||||
|
|
||||||
|
```python
|
||||||
|
except IntegrityError as exc:
|
||||||
|
first_claim_id = result.claims[0].claim_id if result.claims else None
|
||||||
|
existing_batch_id = (
|
||||||
|
store.find_existing_batch_for_claim(first_claim_id)
|
||||||
|
if first_claim_id else None
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"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 existing_batch_id and existing_batch_id != rec.id:
|
||||||
|
body["existing_batch_id"] = existing_batch_id
|
||||||
|
log.warning("Duplicate claim while persisting batch %s: %s", rec.id, exc)
|
||||||
|
return JSONResponse(status_code=409, content=body)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `existing_batch_id != rec.id` guard avoids surfacing the just-failed
|
||||||
|
batch as a "previous" batch (it never persisted).
|
||||||
|
|
||||||
|
### 835 path (line 588-602)
|
||||||
|
|
||||||
|
Same pattern with `find_existing_batch_for_remit` and the first remit's
|
||||||
|
`payer_claim_control_number` (`result.claims[0].payer_claim_control_number`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
except IntegrityError as exc:
|
||||||
|
first_pcn = result.claims[0].payer_claim_control_number if result.claims else None
|
||||||
|
existing_batch_id = (
|
||||||
|
store.find_existing_batch_for_remit(first_pcn)
|
||||||
|
if first_pcn else None
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"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 existing_batch_id and existing_batch_id != rec.id:
|
||||||
|
body["existing_batch_id"] = existing_batch_id
|
||||||
|
log.warning("Duplicate remittance while persisting batch %s: %s", rec.id, exc)
|
||||||
|
return JSONResponse(status_code=409, content=body)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Frontend change
|
||||||
|
|
||||||
|
### `src/lib/api.ts`
|
||||||
|
|
||||||
|
`ApiError` carries an optional `existingBatchId`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
public existingBatchId: string | null = null,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`readErrorBody()` parses JSON and, when status ≥ 400, attempts to extract
|
||||||
|
`existing_batch_id` and pass it through. `parse837`/`parse835` throw
|
||||||
|
`new ApiError(res.status, detail, existingBatchId)`.
|
||||||
|
|
||||||
|
### `src/pages/Upload.tsx`
|
||||||
|
|
||||||
|
New local state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type UploadError = {
|
||||||
|
kind: "duplicate";
|
||||||
|
existingBatchId: string | null;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
const [uploadError, setUploadError] = useState<UploadError | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
On mutation error:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
setUploadError({
|
||||||
|
kind: "duplicate",
|
||||||
|
existingBatchId: err.existingBatchId,
|
||||||
|
filename: file.name,
|
||||||
|
});
|
||||||
|
toast.error("Duplicate claim — file not ingested");
|
||||||
|
} else {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to parse file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Inline error panel JSX (renders above streaming results when
|
||||||
|
`uploadError` is set):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{uploadError ? (
|
||||||
|
<div className="error-panel">
|
||||||
|
<Badge variant="destructive">409</Badge>
|
||||||
|
<div className="error-title">Duplicate claim — file not ingested</div>
|
||||||
|
<div className="error-detail">
|
||||||
|
{filename} collides with an existing record.
|
||||||
|
{existingBatchId
|
||||||
|
? " Open the existing batch to compare, or pick a different file."
|
||||||
|
: " Pick a different file."}
|
||||||
|
</div>
|
||||||
|
<div className="error-actions">
|
||||||
|
{existingBatchId ? (
|
||||||
|
<Button onClick={() => navigate(`/batches/${existingBatchId}`)}>
|
||||||
|
Open existing batch →
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button variant="ghost" onClick={() => { pickFile(null); }}>
|
||||||
|
Pick a different file
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
```
|
||||||
|
|
||||||
|
Styling matches the existing paper-toned surface (slate/parchment) per
|
||||||
|
the hybrid dark/paper treatment used elsewhere in the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Files changed
|
||||||
|
|
||||||
|
* `backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql` — new
|
||||||
|
* `backend/src/cyclone/store.py` — `find_existing_batch_for_claim`,
|
||||||
|
`find_existing_batch_for_remit`
|
||||||
|
* `backend/src/cyclone/api.py` — both 409 handlers add
|
||||||
|
`existing_batch_id` lookup
|
||||||
|
* `src/lib/api.ts` — `ApiError.existingBatchId`
|
||||||
|
* `src/pages/Upload.tsx` — error state + inline panel
|
||||||
|
* `src/pages/Upload.test.tsx` — new test file
|
||||||
|
* `backend/tests/test_api_parse_persists.py` — new test cases
|
||||||
|
* `backend/tests/test_db_migrate.py` — new migration test
|
||||||
|
* `backend/tests/test_store.py` — new helper tests
|
||||||
|
|
||||||
|
No new dependencies. No config changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Out of scope
|
||||||
|
|
||||||
|
* A "Replace existing batch" destructive action — requires
|
||||||
|
`DELETE /api/batches/{id}` and reconciliation cascade handling; deferred.
|
||||||
|
* General 4xx error UI for other statuses (empty file, parse error,
|
||||||
|
validation errors). Those already surface in toasts and the streaming
|
||||||
|
view; a future SP can generalize the inline panel pattern.
|
||||||
|
* Renaming `patient_control_number` to `subscriber_member_id` to match
|
||||||
|
what it actually stores. Out of scope for this fix; tracked separately
|
||||||
|
if it becomes a source of confusion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Risk
|
||||||
|
|
||||||
|
* **Migration reversibility**: SQLite has no `DROP CONSTRAINT`; the
|
||||||
|
recreation is destructive to schema (but not to data — `INSERT INTO
|
||||||
|
claims_new SELECT * FROM claims` preserves every row). If the
|
||||||
|
migration fails mid-way, the implicit transaction (`engine.begin()`
|
||||||
|
in `db_migrate.py`) rolls back. `PRAGMA defer_foreign_keys = ON` is
|
||||||
|
required because SQLite otherwise can't drop a table that other
|
||||||
|
tables reference (`remittances.claim_id`, `matches.claim_id`,
|
||||||
|
`line_reconciliations.claim_id`, `activity_events.claim_id`). The
|
||||||
|
deferred checks fire at commit and validate against the renamed
|
||||||
|
`claims` table.
|
||||||
|
* **Loss of uniqueness**: after the migration, two claims in one batch
|
||||||
|
*can* share a `patient_control_number`. This is the intended behavior.
|
||||||
|
Claim identity is still unique via `claims.id` (CLM01, PK) and the
|
||||||
|
existing dedup check in `store.add` (`s.get(Claim, claim.claim_id)`).
|
||||||
|
* **`existing_batch_id` race**: the helper runs after the failed
|
||||||
|
`store.add` transaction has rolled back. Between rollback and helper
|
||||||
|
call, another writer could delete the colliding claim. Result: 409
|
||||||
|
fires with no `existing_batch_id`; UI shows panel without link.
|
||||||
|
Acceptable — the panel still explains the collision and offers
|
||||||
|
"Pick a different file."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Test plan
|
||||||
|
|
||||||
|
| Test | Asserts |
|
||||||
|
|---|---|
|
||||||
|
| `test_db_migrate.py::test_drop_claims_unique_constraint` | After running migrations from v12, `user_version=13`, no `*claims*unique*` index exists, two rows with same `(batch_id, patient_control_number)` insert cleanly. |
|
||||||
|
| `test_db_migrate.py::test_migration_idempotent` | Running migrations on a v13 DB is a no-op. |
|
||||||
|
| `test_store.py::test_find_existing_batch_for_claim` | Helper returns None for unknown, returns batch_id for known, deterministic on duplicates. |
|
||||||
|
| `test_api_parse_persists.py::test_409_response_includes_existing_batch_id` | Upload duplicate; assert body has `existing_batch_id` pointing to the right batch. |
|
||||||
|
| `test_api_parse_persists.py::test_multi_claim_batch_with_duplicate_member_id_succeeds` | Upload a file with duplicate `member_id` claims; assert 200, all claims persisted. |
|
||||||
|
| `Upload.test.tsx::test_error_panel_renders_on_409_with_link` | Mock `useParse` to throw `ApiError(409, ..., existingBatchId)`; assert panel visible, link present. |
|
||||||
|
| `Upload.test.tsx::test_error_panel_renders_on_409_without_link` | Mock 409 with `existingBatchId: null`; assert panel visible, no link. |
|
||||||
|
| `Upload.test.tsx::test_no_panel_on_non_409` | Mock 400; assert panel absent, only toast. |
|
||||||
|
| `Upload.test.tsx::test_pick_different_clears_error` | Click button; assert `pickFile(null)` called and `errorState` cleared. |
|
||||||
@@ -0,0 +1,798 @@
|
|||||||
|
# Parse → Detect → Decide: 837P/835 Upload Workflow
|
||||||
|
|
||||||
|
**Date:** 2026-06-21
|
||||||
|
**Branch:** `claims-unique-fix` (worktree)
|
||||||
|
**Status:** Draft (brainstorming approved, awaiting writing-plans)
|
||||||
|
**Supersedes:** `2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md` (kept for the migration 0013 + store helper sections, which still apply).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this exists
|
||||||
|
|
||||||
|
Today's upload flow is "parse → validate → persist" in a single call.
|
||||||
|
When a claim's CLM01 collides with a prior batch, the persist raises
|
||||||
|
`IntegrityError`, the transaction rolls back, and the API returns 409
|
||||||
|
with **no parse result, no list of colliding claims, and no way to act**.
|
||||||
|
The user sees only an error message and a `batch_id` that doesn't exist.
|
||||||
|
|
||||||
|
This is wrong: the parse already happened. The user should see what was
|
||||||
|
parsed, see which claims collide with which prior batches, and decide
|
||||||
|
what to do (force-insert, delete the prior batch, or pick a different
|
||||||
|
file). The 409 response body today is too thin to make that decision.
|
||||||
|
|
||||||
|
The root cause of the 409s is a schema bug — see
|
||||||
|
`2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md` §1.
|
||||||
|
Migration 0013 drops the `UNIQUE(batch_id, patient_control_number)` inline
|
||||||
|
constraint that 0003 was supposed to drop. After 0013 lands, **multi-claim
|
||||||
|
837P files where many CLM segments share a subscriber's `member_id` will
|
||||||
|
ingest cleanly for the first time**.
|
||||||
|
|
||||||
|
But 0013 alone is not enough. The current schema has `claims.id` and
|
||||||
|
`remittances.id` as single-column PRIMARY KEYs, which means the same
|
||||||
|
CLM01 cannot exist in two different batches. That makes "cross-batch
|
||||||
|
CLM01 collisions" impossible to express in the data — but it also makes
|
||||||
|
resubmits impossible, and it makes the 409-with-collision-summary workflow
|
||||||
|
this SP describes unreachable. **Migration 0014 (added as Task 1.3 to the
|
||||||
|
plan) relaxes the PKs to composite `(batch_id, id)`.** After 0014 lands,
|
||||||
|
real resubmits are representable, the pre-flight 409 path actually fires,
|
||||||
|
and the workflow defined below is exercisable end-to-end against real data.
|
||||||
|
|
||||||
|
This SP defines the workflow for both classes of collision:
|
||||||
|
1. Multi-claim files with shared `member_id` (no longer a 409 after 0013).
|
||||||
|
2. Files where one or more CLM01s exist in a prior batch (a 409 after 0014; this SP defines the UX for it).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Operator surface
|
||||||
|
|
||||||
|
| Surface | Change |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Pre-flight dedup check in `parse_837` and `parse_835`. New `?force=true` query param. New `DELETE /api/batches/{id}` endpoint. 409 body shape changes. |
|
||||||
|
| Frontend | `Upload.tsx` panel renders the full parse result + collision summary, with actions: "Force insert (skip dups)", "Open prior batch", "Delete prior batch and retry", "Pick a different file". |
|
||||||
|
| Tests | Migration + store helpers (already done in `claims-unique-fix`). New tests for: pre-flight dedup, force-insert, within-file dup, race 409, DELETE endpoint, frontend panel. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The workflow
|
||||||
|
|
||||||
|
### 3.1 No collision (the happy path)
|
||||||
|
|
||||||
|
```
|
||||||
|
User → POST /api/parse-837 (file)
|
||||||
|
← 200 + ParseResult + batch_id
|
||||||
|
Batch persisted. UI shows parsed claims and links to the new batch.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Collision (the new path)
|
||||||
|
|
||||||
|
```
|
||||||
|
User → POST /api/parse-837 (file)
|
||||||
|
← 409 + {
|
||||||
|
error: "Duplicate claim",
|
||||||
|
detail: "...",
|
||||||
|
existing_batch_id: "B123", # most-recent prior batch with a colliding CLM01
|
||||||
|
collisions: {
|
||||||
|
colliding_claim_ids: ["A", "B"],
|
||||||
|
total_collisions: 2,
|
||||||
|
total_claims: 141, # claims in the file
|
||||||
|
new_claims_after_skip: 139, # claims that WOULD be inserted on force
|
||||||
|
},
|
||||||
|
parse_result: { ... full ParseResult ... },
|
||||||
|
}
|
||||||
|
User sees the parse result in the panel.
|
||||||
|
User can:
|
||||||
|
- Click "Force insert (skip 2 dups)" → POST /api/parse-837?force=true (same file)
|
||||||
|
← 200 + ParseResult + { skipped_claim_ids: ["A", "B"], inserted: 139 }
|
||||||
|
- Click "Open prior batch" → navigate to /batches/B123
|
||||||
|
- Click "Delete prior batch" → DELETE /api/batches/B123, then click "Re-upload"
|
||||||
|
- Click "Pick a different file" → clear the upload state
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Force-insert after collision
|
||||||
|
|
||||||
|
`force=true` skips the pre-flight check. The store's existing per-row
|
||||||
|
`s.get(Claim, claim_id)` dedup still skips colliding rows silently, so
|
||||||
|
the new batch persists with only the non-colliding claims. The response
|
||||||
|
body includes `skipped_claim_ids` so the UI can show what was skipped.
|
||||||
|
|
||||||
|
`force=true` does NOT bypass the parser. If the file fails validation
|
||||||
|
(missing diagnosis, malformed segment), the response is still 422.
|
||||||
|
|
||||||
|
### 3.4 Race condition (pre-flight clean, persist fails)
|
||||||
|
|
||||||
|
If a pre-flight dedup check finds no collisions, but a concurrent process
|
||||||
|
ingests a colliding CLM01 between the check and the persist, the persist
|
||||||
|
will still raise `IntegrityError`. The handler catches it and returns
|
||||||
|
**the same 409 shape as the pre-flight collision** with
|
||||||
|
`existing_batch_id` set to the racing batch and `detail` mentioning
|
||||||
|
"another process ingested this between the check and the persist —
|
||||||
|
re-upload to retry". The user re-runs the same flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Within-file duplicates
|
||||||
|
|
||||||
|
If the file itself has the same CLM01 twice (a malformed file, not a
|
||||||
|
cross-batch collision), the pre-flight check catches it the same way:
|
||||||
|
it returns 409 with `existing_batch_id: null` and `detail: "CLM01 A
|
||||||
|
appears twice in this file"`. The user can only force-insert (which
|
||||||
|
skips the second instance). They can't "delete the prior batch" because
|
||||||
|
there isn't one — it's a bad file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The 409 body shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Duplicate claim",
|
||||||
|
"detail": "This file (or one previously ingested with the same claim control number) collides with an existing record. 2 of 141 claims collide with batch B123.",
|
||||||
|
"batch_id": null,
|
||||||
|
"existing_batch_id": "B123",
|
||||||
|
"collisions": {
|
||||||
|
"colliding_claim_ids": ["A", "B"],
|
||||||
|
"total_collisions": 2,
|
||||||
|
"total_claims": 141,
|
||||||
|
"new_claims_after_skip": 139
|
||||||
|
},
|
||||||
|
"parse_result": { ... full ParseResult ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field semantics:
|
||||||
|
- `error`: short tag for the UI ("Duplicate claim", "Duplicate remittance", "Within-file duplicate CLM01").
|
||||||
|
- `detail`: human-readable, mentions the count and the existing batch when known.
|
||||||
|
- `batch_id`: always `null` on 409 (the insert rolled back).
|
||||||
|
- `existing_batch_id`: the most-recent prior batch that contains a colliding CLM01, or `null` if (a) the collision is within-file, or (b) the colliding claim has since been deleted (race).
|
||||||
|
- `collisions.colliding_claim_ids`: subset of `parse_result.claims[].claim_id` that collides.
|
||||||
|
- `collisions.total_claims`: count from `parse_result.summary.total_claims`.
|
||||||
|
- `collisions.new_claims_after_skip`: `total_claims - total_collisions`.
|
||||||
|
- `parse_result`: the full `ParseResult` (same shape as a 200 response body). The UI uses this to render the parsed claims list.
|
||||||
|
|
||||||
|
The 200 body on `force=true` adds `skipped_claim_ids: ["A", "B"]` at the top level so the UI can show a "skipped" badge per claim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `DELETE /api/batches/{id}`
|
||||||
|
|
||||||
|
New endpoint. Cascades through `ON DELETE CASCADE` FKs:
|
||||||
|
|
||||||
|
```
|
||||||
|
batches ─┬─ claims ─┬─ matches
|
||||||
|
│ ├─ activity_events (claim_id)
|
||||||
|
│ └─ line_reconciliations
|
||||||
|
├─ remittances ─┬─ cas_adjustments
|
||||||
|
│ ├─ service_line_payments
|
||||||
|
│ └─ activity_events (remittance_id)
|
||||||
|
└─ activity_events (batch_id only)
|
||||||
|
```
|
||||||
|
|
||||||
|
FKs already declare `ON DELETE CASCADE` in the migrations, so the
|
||||||
|
SQLite engine handles the cascade. The endpoint just needs to
|
||||||
|
`session.delete(batch_row)` and commit.
|
||||||
|
|
||||||
|
The endpoint:
|
||||||
|
- `204 No Content` on success.
|
||||||
|
- `404 Not Found` if the batch doesn't exist.
|
||||||
|
- `409 Conflict` if the batch has any claims in a non-`submitted` state
|
||||||
|
(e.g., `paid`, `reversed`, `denied`). Forces the user to first
|
||||||
|
unreconcile — same as the existing 409 pattern for `manual_match` /
|
||||||
|
`manual_unmatch` (see `store.py:AlreadyMatchedError`).
|
||||||
|
|
||||||
|
A `batch_deleted` activity event is recorded before the delete so the
|
||||||
|
audit log has a tombstone. The event's `batch_id` will be `null` after
|
||||||
|
the cascade (the FK is to `batches.id` with no `ON DELETE` clause
|
||||||
|
specified in any migration; verify in `migrations/0001_initial.sql` —
|
||||||
|
the spec says we preserve audit history). If the FK is `ON DELETE
|
||||||
|
CASCADE`, we record the event AFTER the cascade with `batch_id` set to
|
||||||
|
the deleted id and rely on the cascade to remove it (acceptable, or we
|
||||||
|
use a no-cascade FK and keep the tombstone). **Open question resolved
|
||||||
|
during implementation by reading the actual FK clauses.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Backend implementation
|
||||||
|
|
||||||
|
### 7.1 New dedup helper
|
||||||
|
|
||||||
|
`backend/src/cyclone/store.py` (already added in `claims-unique-fix`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def find_existing_batch_for_claim(claim_id: str) -> str | None:
|
||||||
|
"""Return the batch_id of the first batch containing this claim id, or None.
|
||||||
|
|
||||||
|
Pure read; opens a short-lived session. Used by the 837 409 handler to
|
||||||
|
surface which prior batch already holds the same CLM01.
|
||||||
|
|
||||||
|
Returns the most-recent batch (ORDER BY parsed_at DESC LIMIT 1) so the
|
||||||
|
UI links to the most likely "where did the dup come from" answer.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from cyclone.db import Claim
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.execute(
|
||||||
|
select(Claim.batch_id)
|
||||||
|
.where(Claim.id == claim_id)
|
||||||
|
.order_by(Claim.state_changed_at.desc()) # most-recent touch
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def find_existing_batch_for_remit(remit_id: str) -> str | None:
|
||||||
|
"""Same shape as find_existing_batch_for_claim but for remittances."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from cyclone.db import Remittance
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.execute(
|
||||||
|
select(Remittance.batch_id)
|
||||||
|
.where(Remittance.id == remit_id)
|
||||||
|
.order_by(Remittance.received_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
return row[0] if row else None
|
||||||
|
```
|
||||||
|
|
||||||
|
The current `claims-unique-fix` implementation uses
|
||||||
|
`select(Claim.batch_id).where(Claim.id == claim_id).limit(1)` without
|
||||||
|
`ORDER BY`. We replace it with the ordered version to satisfy
|
||||||
|
"return the most-recent colliding batch".
|
||||||
|
|
||||||
|
### 7.2 New pre-flight dedup check
|
||||||
|
|
||||||
|
`backend/src/cyclone/dedup.py` (new file, single responsibility):
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Pre-flight dedup for parsed 837P/835 batches.
|
||||||
|
|
||||||
|
Splits the parsed result into "would-insert" and "would-skip" sets by
|
||||||
|
querying the DB for any claim_id / payer_claim_control_number already
|
||||||
|
present. Also detects within-file duplicates by counting claim_id
|
||||||
|
frequencies.
|
||||||
|
|
||||||
|
Used by the parse-837 and parse-835 endpoints between validation and
|
||||||
|
persist, so the user can see the parse result + collision summary
|
||||||
|
before any DB write.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.db import Claim, Remittance
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CollisionReport:
|
||||||
|
"""What the parse endpoint needs to render a 409 response."""
|
||||||
|
colliding_claim_ids: list[str] # CLM01s (837) or CLP01s (835)
|
||||||
|
existing_batch_id: str | None # most-recent prior batch with a collision, or None
|
||||||
|
within_file_duplicate_ids: list[str] # CLM01s appearing twice in this file (subset of colliding_claim_ids)
|
||||||
|
total_claims: int
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_837(result, session: Session | None = None) -> CollisionReport:
|
||||||
|
"""Detect 837 collisions: within-file dupes + cross-batch CLM01 dupes."""
|
||||||
|
claim_ids = [c.claim_id for c in result.claims]
|
||||||
|
counts = Counter(claim_ids)
|
||||||
|
within_file_duplicate_ids = sorted(
|
||||||
|
cid for cid, n in counts.items() if n > 1
|
||||||
|
)
|
||||||
|
seen: set[str] = set(claim_ids)
|
||||||
|
if not seen:
|
||||||
|
return CollisionReport(
|
||||||
|
colliding_claim_ids=[],
|
||||||
|
existing_batch_id=None,
|
||||||
|
within_file_duplicate_ids=[],
|
||||||
|
total_claims=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
own_session = session is None
|
||||||
|
if own_session:
|
||||||
|
session = db.SessionLocal()()
|
||||||
|
try:
|
||||||
|
rows = session.execute(
|
||||||
|
select(Claim.id, Claim.batch_id)
|
||||||
|
.where(Claim.id.in_(seen))
|
||||||
|
.order_by(Claim.state_changed_at.desc())
|
||||||
|
).all()
|
||||||
|
finally:
|
||||||
|
if own_session:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
db_collisions = {cid: bid for cid, bid in rows}
|
||||||
|
colliding = sorted(cid for cid in seen if cid in db_collisions)
|
||||||
|
existing_batch_id = next(iter(db_collisions.values()), None) if db_collisions else None
|
||||||
|
return CollisionReport(
|
||||||
|
colliding_claim_ids=colliding,
|
||||||
|
existing_batch_id=existing_batch_id,
|
||||||
|
within_file_duplicate_ids=within_file_duplicate_ids,
|
||||||
|
total_claims=len(claim_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_835(result, session: Session | None = None) -> CollisionReport:
|
||||||
|
"""Same shape for 835 remittances. Payer claim control number = CLP01 = remittance.id."""
|
||||||
|
pcns = [c.payer_claim_control_number for c in result.claims]
|
||||||
|
counts = Counter(pcns)
|
||||||
|
within_file_duplicate_ids = sorted(p for p, n in counts.items() if n > 1)
|
||||||
|
seen = set(pcns)
|
||||||
|
if not seen:
|
||||||
|
return CollisionReport(
|
||||||
|
colliding_claim_ids=[],
|
||||||
|
existing_batch_id=None,
|
||||||
|
within_file_duplicate_ids=[],
|
||||||
|
total_claims=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
own_session = session is None
|
||||||
|
if own_session:
|
||||||
|
session = db.SessionLocal()()
|
||||||
|
try:
|
||||||
|
rows = session.execute(
|
||||||
|
select(Remittance.id, Remittance.batch_id)
|
||||||
|
.where(Remittance.id.in_(seen))
|
||||||
|
.order_by(Remittance.received_at.desc())
|
||||||
|
).all()
|
||||||
|
finally:
|
||||||
|
if own_session:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
db_collisions = {pcn: bid for pcn, bid in rows}
|
||||||
|
colliding = sorted(pcn for pcn in seen if pcn in db_collisions)
|
||||||
|
existing_batch_id = next(iter(db_collisions.values()), None) if db_collisions else None
|
||||||
|
return CollisionReport(
|
||||||
|
colliding_claim_ids=colliding,
|
||||||
|
existing_batch_id=existing_batch_id,
|
||||||
|
within_file_duplicate_ids=within_file_duplicate_ids,
|
||||||
|
total_claims=len(pcns),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Modified `parse_837` endpoint
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/api/parse-837")
|
||||||
|
async def parse_837(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
payer: str = Query("co_medicaid"),
|
||||||
|
include_raw_segments: bool = Query(True),
|
||||||
|
strict: bool = Query(False),
|
||||||
|
ack: bool = Query(False),
|
||||||
|
force: bool = Query(False), # NEW
|
||||||
|
) -> Any:
|
||||||
|
# ... existing parse + validate ...
|
||||||
|
|
||||||
|
if _has_claim_validation_errors(result):
|
||||||
|
return JSONResponse(status_code=422, content=json.loads(result.model_dump_json()))
|
||||||
|
|
||||||
|
# NEW: pre-flight dedup check
|
||||||
|
if not force and result.claims:
|
||||||
|
report = dedup.preflight_837(result)
|
||||||
|
if report.colliding_claim_ids or report.within_file_duplicate_ids:
|
||||||
|
return _build_409_response(
|
||||||
|
result=result,
|
||||||
|
report=report,
|
||||||
|
error="Duplicate claim",
|
||||||
|
kind="cross_batch" if report.existing_batch_id else "within_file",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist (existing path). On IntegrityError (race), same 409 shape.
|
||||||
|
rec = BatchRecord(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
kind="837p",
|
||||||
|
input_filename=file.filename or "upload.txt",
|
||||||
|
parsed_at=utcnow(),
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
store.add(rec, event_bus=request.app.state.event_bus)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# Race: pre-flight said clean, but persist hit a PK. Re-run pre-flight
|
||||||
|
# so the 409 body has the same shape.
|
||||||
|
report = dedup.preflight_837(result)
|
||||||
|
return _build_409_response(
|
||||||
|
result=result,
|
||||||
|
report=report,
|
||||||
|
error="Duplicate claim (race condition)",
|
||||||
|
kind="race",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ... existing response ...
|
||||||
|
if _client_wants_json(request):
|
||||||
|
body = json.loads(result.model_dump_json())
|
||||||
|
if ack:
|
||||||
|
ack_body = _build_and_persist_ack(rec.id)
|
||||||
|
if ack_body is not None:
|
||||||
|
body["ack"] = ack_body
|
||||||
|
# If force=true, the store.add silently skipped some claims.
|
||||||
|
# Surface what was skipped so the UI can show a "skipped" badge.
|
||||||
|
if force:
|
||||||
|
body["skipped_claim_ids"] = sorted({
|
||||||
|
c.claim_id for c in result.claims
|
||||||
|
if _claim_skipped(c.claim_id, rec.id)
|
||||||
|
})
|
||||||
|
return JSONResponse(content=body)
|
||||||
|
# ... streaming response ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _build_409_response(
|
||||||
|
result, report, error: str, kind: str
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Build the standard 409 body for any dedup failure."""
|
||||||
|
if kind == "cross_batch":
|
||||||
|
detail = (
|
||||||
|
f"{len(report.colliding_claim_ids)} of {report.total_claims} "
|
||||||
|
f"claims collide with prior batch {report.existing_batch_id}. "
|
||||||
|
f"Force-insert to skip the duplicates, or delete the prior batch."
|
||||||
|
)
|
||||||
|
elif kind == "within_file":
|
||||||
|
detail = (
|
||||||
|
f"CLM01(s) {', '.join(report.within_file_duplicate_ids)} appear "
|
||||||
|
f"twice in this file. Force-insert will keep the first occurrence "
|
||||||
|
f"and skip the rest."
|
||||||
|
)
|
||||||
|
else: # race
|
||||||
|
detail = (
|
||||||
|
f"Another process ingested a colliding batch between the check "
|
||||||
|
f"and the persist. Re-upload to retry with the latest state."
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"error": error,
|
||||||
|
"detail": detail,
|
||||||
|
"batch_id": None,
|
||||||
|
"existing_batch_id": report.existing_batch_id,
|
||||||
|
"collisions": {
|
||||||
|
"colliding_claim_ids": report.colliding_claim_ids,
|
||||||
|
"total_collisions": len(report.colliding_claim_ids),
|
||||||
|
"total_claims": report.total_claims,
|
||||||
|
"new_claims_after_skip": report.total_claims - len(report.colliding_claim_ids),
|
||||||
|
},
|
||||||
|
"parse_result": json.loads(result.model_dump_json()),
|
||||||
|
}
|
||||||
|
return JSONResponse(status_code=409, content=body)
|
||||||
|
```
|
||||||
|
|
||||||
|
`force=true` does NOT bypass validation (still 422 for bad data). It
|
||||||
|
only bypasses the pre-flight dedup. The `store.add` dedup still skips
|
||||||
|
colliding claims silently, but the response surfaces the skip list.
|
||||||
|
|
||||||
|
### 7.4 Modified `parse_835` endpoint
|
||||||
|
|
||||||
|
Same pattern, with `dedup.preflight_835` and the 835 parse result. Not
|
||||||
|
shown in detail; the structure mirrors 837.
|
||||||
|
|
||||||
|
### 7.5 New `DELETE /api/batches/{id}`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.delete("/api/batches/{batch_id}")
|
||||||
|
def delete_batch(batch_id: str) -> dict:
|
||||||
|
"""Hard-delete a batch and all its child rows.
|
||||||
|
|
||||||
|
Returns 204 on success, 404 if missing, 409 if the batch has any
|
||||||
|
claims/remits in a non-`submitted` state (must unreconcile first).
|
||||||
|
"""
|
||||||
|
from cyclone import db
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
batch = s.get(db.Batch, batch_id)
|
||||||
|
if batch is None:
|
||||||
|
raise HTTPException(404, f"Batch {batch_id} not found")
|
||||||
|
# Refuse if any claim/remittance is past 'submitted' state
|
||||||
|
non_submitted = s.execute(
|
||||||
|
select(db.Claim.id)
|
||||||
|
.where(db.Claim.batch_id == batch_id)
|
||||||
|
.where(db.Claim.state != "submitted")
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
if non_submitted is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Batch {batch_id} has claims in non-submitted state; "
|
||||||
|
f"unreconcile first before deleting.",
|
||||||
|
)
|
||||||
|
# Record tombstone activity event before the cascade
|
||||||
|
s.add(db.ActivityEvent(
|
||||||
|
ts=utcnow(),
|
||||||
|
kind="batch_deleted",
|
||||||
|
batch_id=batch_id,
|
||||||
|
payload_json={"message": f"Batch {batch_id} deleted"},
|
||||||
|
))
|
||||||
|
s.flush()
|
||||||
|
s.delete(batch)
|
||||||
|
s.commit()
|
||||||
|
return {"ok": True, "batch_id": batch_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
The FKs in the schema (`migrations/0001_initial.sql` and later) declare
|
||||||
|
`ON DELETE CASCADE` on `claims.batch_id`, `remittances.batch_id`, etc.
|
||||||
|
SQLite handles the cascade at the engine level. We verify this assumption
|
||||||
|
in the implementation test by deleting a batch with child rows and
|
||||||
|
asserting the child rows are gone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Frontend
|
||||||
|
|
||||||
|
### 8.1 `src/lib/api.ts`
|
||||||
|
|
||||||
|
`ApiError` carries more collision data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
public existingBatchId: string | null = null,
|
||||||
|
public collisions: CollisionSummary | null = null,
|
||||||
|
public parseResult: unknown = null,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollisionSummary = {
|
||||||
|
colliding_claim_ids: string[];
|
||||||
|
total_collisions: number;
|
||||||
|
total_claims: number;
|
||||||
|
new_claims_after_skip: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`parse837` adds `?force=true` to the URL when called for the
|
||||||
|
"force-insert" action:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function parse837(
|
||||||
|
file: File,
|
||||||
|
options: { onProgress?: (p: number) => void; force?: boolean } = {},
|
||||||
|
): Promise<ParseResult> {
|
||||||
|
const url = `${base}/api/parse-837${options.force ? "?force=true" : ""}`;
|
||||||
|
// ... existing fetch + body parse ...
|
||||||
|
if (!res.ok) {
|
||||||
|
const { message, existingBatchId, collisions, parseResult } = await readErrorBody(res);
|
||||||
|
throw new ApiError(res.status, message, existingBatchId, collisions, parseResult);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 `src/pages/Upload.tsx`
|
||||||
|
|
||||||
|
New state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type UploadError = {
|
||||||
|
kind: "duplicate";
|
||||||
|
existingBatchId: string | null;
|
||||||
|
collisions: CollisionSummary;
|
||||||
|
parseResult: ParseResult;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
const [uploadError, setUploadError] = useState<UploadError | null>(null);
|
||||||
|
const [forceInserting, setForceInserting] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
Panel JSX (above the streaming results):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{uploadError ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded-md border border-destructive/40 bg-destructive/5 p-4 mx-auto max-w-3xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center rounded-md bg-destructive px-2 py-0.5 text-xs font-semibold text-destructive-foreground">
|
||||||
|
409
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{uploadError.collisions.total_collisions} of {uploadError.collisions.total_claims} claims
|
||||||
|
collide
|
||||||
|
{uploadError.existingBatchId
|
||||||
|
? ` with batch ${uploadError.existingBatchId}`
|
||||||
|
: " within this file"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
File <span className="font-mono">{uploadError.filename}</span> would persist
|
||||||
|
{" "}{uploadError.collisions.new_claims_after_skip} of {uploadError.collisions.total_claims} claims.
|
||||||
|
Colliding CLM01s: {uploadError.collisions.colliding_claim_ids.join(", ")}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={forceInserting}
|
||||||
|
onClick={async () => {
|
||||||
|
setForceInserting(true);
|
||||||
|
try {
|
||||||
|
// re-call with force=true; the response will be 200 + skipped_claim_ids
|
||||||
|
const result = await parse837(file, { onProgress: () => {}, force: true });
|
||||||
|
setParseResult(result);
|
||||||
|
setUploadError(null);
|
||||||
|
toast.success(
|
||||||
|
`Force-inserted: ${result.summary.total_claims - (result.skipped_claim_ids?.length ?? 0)} of ${result.summary.total_claims} claims (skipped ${result.skipped_claim_ids?.length ?? 0} dups)`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Force-insert failed");
|
||||||
|
} finally {
|
||||||
|
setForceInserting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Force insert (skip {uploadError.collisions.total_collisions} dups)
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{uploadError.existingBatchId ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => navigate(`/batches/${uploadError.existingBatchId}`)}>
|
||||||
|
Open prior batch
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm(`Delete batch ${uploadError.existingBatchId}? This cannot be undone.`)) return;
|
||||||
|
await deleteBatch(uploadError.existingBatchId);
|
||||||
|
toast.success(`Deleted ${uploadError.existingBatchId}`);
|
||||||
|
setUploadError(null);
|
||||||
|
pickFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete prior batch
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button variant="ghost" onClick={() => { setUploadError(null); pickFile(null); }}>
|
||||||
|
Pick a different file
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The full parse result is rendered below so the user can see what was parsed. */}
|
||||||
|
<details className="mt-3 text-sm">
|
||||||
|
<summary>Show parsed claims ({uploadError.parseResult.claims.length})</summary>
|
||||||
|
<pre className="mt-2 max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
|
||||||
|
{JSON.stringify(uploadError.parseResult.summary, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Database
|
||||||
|
|
||||||
|
Migration 0013 already exists on the `claims-unique-fix` worktree. It
|
||||||
|
drops the `UNIQUE(batch_id, patient_control_number)` inline constraint.
|
||||||
|
After it runs:
|
||||||
|
|
||||||
|
- The 409 fires only on actual CLM01 collisions (not the `member_id`
|
||||||
|
dedup that was over-constraining before).
|
||||||
|
- Multi-claim 837P files with shared `member_id` ingest cleanly for
|
||||||
|
the first time.
|
||||||
|
|
||||||
|
Migration 0014 (added as Task 1.3 in the plan) further relaxes the schema:
|
||||||
|
it changes the PKs on `claims` and `remittances` from single-column
|
||||||
|
(`id`) to composite (`batch_id`, `id`). This is what allows resubmits and
|
||||||
|
makes the workflow in §3 reachable.
|
||||||
|
|
||||||
|
No new tables. No new columns. The DELETE endpoint relies on existing
|
||||||
|
`ON DELETE CASCADE` FKs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Files changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql` | new (DONE on `claims-unique-fix`) |
|
||||||
|
| `backend/src/cyclone/migrations/0014_relax_claims_remits_pk.sql` | new: composite PK `(batch_id, id)` on `claims` and `remittances`; updates FKs |
|
||||||
|
| `backend/src/cyclone/store.py` | new `find_existing_batch_for_claim` / `find_existing_batch_for_remit` (DONE) + new `delete_batch` method |
|
||||||
|
| `backend/src/cyclone/dedup.py` | new file: pre-flight `preflight_837` / `preflight_835` + `CollisionReport` dataclass |
|
||||||
|
| `backend/src/cyclone/api.py` | 837/835 endpoints: pre-flight check, force param, new 409 body, race handler, new DELETE endpoint |
|
||||||
|
| `src/lib/api.ts` | `ApiError` adds `collisions` + `parseResult`; `parse837`/`parse835` accept `force`; new `deleteBatch` |
|
||||||
|
| `src/pages/Upload.tsx` | new `UploadError` state, error panel JSX, force-insert handler, delete-prior handler |
|
||||||
|
| `src/pages/Upload.test.tsx` | new tests (4 cases from §11) |
|
||||||
|
| `backend/tests/test_db_migrate.py` | 0013 idempotency + UNIQUE-dropped tests (DONE); 0014 composite-PK + FK-cascade tests |
|
||||||
|
| `backend/tests/test_store.py` | `find_existing_batch_for_claim`/`remit` tests (DONE) |
|
||||||
|
| `backend/tests/test_dedup.py` | new tests for `preflight_837` / `preflight_835` (§11) |
|
||||||
|
| `backend/tests/test_api_parse_persists.py` | new tests: pre-flight 409, force-insert, within-file 409, race 409, DELETE endpoint (§11) |
|
||||||
|
|
||||||
|
No new dependencies. No config changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Test plan
|
||||||
|
|
||||||
|
### Backend (pytest)
|
||||||
|
|
||||||
|
| Test | File | Asserts |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_preflight_837_finds_no_collisions_on_empty_db` | `test_dedup.py` | empty DB → empty `colliding_claim_ids`, no `existing_batch_id` |
|
||||||
|
| `test_preflight_837_finds_cross_batch_collision` | `test_dedup.py` | pre-seed a claim; pre-flight returns that claim_id in `colliding_claim_ids` and the seeded batch in `existing_batch_id` |
|
||||||
|
| `test_preflight_837_finds_within_file_duplicate` | `test_dedup.py` | parsed result has the same CLM01 twice; pre-flight returns it in both `colliding_claim_ids` and `within_file_duplicate_ids` |
|
||||||
|
| `test_preflight_837_returns_most_recent_batch_id` | `test_dedup.py` | pre-seed 3 batches with the same CLM01 at different times; pre-flight returns the most-recent batch_id |
|
||||||
|
| `test_preflight_835_mirrors_837` | `test_dedup.py` | same shape for remittances |
|
||||||
|
| `test_parse_837_409_includes_parse_result_and_collisions` | `test_api_parse_persists.py` | pre-seed a claim; upload a file with a colliding CLM01; assert 409 with `parse_result`, `collisions.colliding_claim_ids`, `existing_batch_id` |
|
||||||
|
| `test_parse_837_409_within_file_duplicate_has_null_batch_id` | `test_api_parse_persists.py` | upload a file with the same CLM01 twice; assert 409 with `existing_batch_id: null` and `within_file_duplicate_ids` populated |
|
||||||
|
| `test_parse_837_force_true_persists_non_colliding_claims` | `test_api_parse_persists.py` | pre-seed a claim; upload a file with 3 claims, 1 colliding; assert 200 with `skipped_claim_ids: [colliding_id]`, the 2 non-colliding claims persist |
|
||||||
|
| `test_parse_837_force_true_does_not_bypass_validation` | `test_api_parse_persists.py` | a file that fails validation still returns 422 with `force=true` |
|
||||||
|
| `test_parse_837_race_409_uses_same_body_shape` | `test_api_parse_persists.py` | mock `store.add` to raise IntegrityError; assert 409 body has the same shape as the pre-flight 409 |
|
||||||
|
| `test_delete_batch_cascades_to_claims` | `test_api_parse_persists.py` | persist a batch with 2 claims; DELETE; assert batch and both claims are gone |
|
||||||
|
| `test_delete_batch_404_on_unknown` | `test_api_parse_persists.py` | DELETE /api/batches/does-not-exist → 404 |
|
||||||
|
| `test_delete_batch_409_on_reconciled_claims` | `test_api_parse_persists.py` | persist a batch, mark a claim state='paid'; DELETE → 409 |
|
||||||
|
| `test_parse_837_after_delete_succeeds` | `test_api_parse_persists.py` | pre-seed a colliding claim; DELETE that batch; re-upload the same file; assert 200 |
|
||||||
|
|
||||||
|
### Frontend (vitest)
|
||||||
|
|
||||||
|
| Test | File | Asserts |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_error_panel_renders_on_409_with_collisions` | `Upload.test.tsx` | mock `parse837` to throw `ApiError(409, ..., PRIOR, collisions, parseResult)`; assert panel visible with all collision data |
|
||||||
|
| `test_force_insert_button_re_calls_with_force_true` | `Upload.test.tsx` | user clicks "Force insert"; assert `parse837` is called with `{ force: true }` |
|
||||||
|
| `test_delete_prior_button_calls_deleteBatch` | `Upload.test.tsx` | user clicks "Delete prior batch"; assert `deleteBatch(existingBatchId)` is called |
|
||||||
|
| `test_pick_different_clears_error` | `Upload.test.tsx` | user clicks "Pick a different file"; assert `uploadError` is cleared and file picker is reset |
|
||||||
|
| `test_no_panel_on_non_409` | `Upload.test.tsx` | 400 error; assert panel absent |
|
||||||
|
| `test_within_file_duplicate_omits_prior_batch_actions` | `Upload.test.tsx` | 409 with `existingBatchId: null`; assert "Open prior batch" and "Delete prior batch" buttons are absent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Out of scope
|
||||||
|
|
||||||
|
* Batch editing (update claim state, edit claim fields). Future SP.
|
||||||
|
* Cross-batch dedup REPORT (a "find all CLM01s in batches B1+B2+B3"
|
||||||
|
query). Future SP.
|
||||||
|
* Migration reversibility for 0013 — the recreation preserves data but
|
||||||
|
not schema history. Acceptable since 0013 just drops an inline
|
||||||
|
constraint; recreating the constraint would be a separate migration.
|
||||||
|
* Audit event for force-insert skips. The user explicitly chose to
|
||||||
|
skip silently; we honor that.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Risk
|
||||||
|
|
||||||
|
* **Pre-flight check race**: between the check and the persist, a
|
||||||
|
concurrent process could ingest a colliding claim. The persist would
|
||||||
|
then raise `IntegrityError`; the handler returns the same 409 shape
|
||||||
|
with `detail` mentioning the race. The user re-runs. Acceptable.
|
||||||
|
* **DELETE on a large batch**: cascade through `claims`, `remittances`,
|
||||||
|
`matches`, `line_reconciliations`, `activity_events`. SQLite handles
|
||||||
|
the cascade in a single transaction; a 140-claim batch deletes in
|
||||||
|
<100ms. The endpoint refuses if any claim is past `submitted` state.
|
||||||
|
* **`force=true` silent skip**: the user clicks "Force insert" and
|
||||||
|
the response says "X of Y claims persisted, Z skipped". They
|
||||||
|
acknowledged this in the panel before clicking. No undo.
|
||||||
|
* **Within-file duplicates and force-insert**: the user can force-insert
|
||||||
|
a file with the same CLM01 twice. The first instance persists, the
|
||||||
|
second is silently skipped. This is intentional — within-file dupes
|
||||||
|
are usually a typo, and the user has explicitly asked to proceed.
|
||||||
|
* **`existing_batch_id` may be stale**: the helper returns the
|
||||||
|
most-recent batch by `state_changed_at` (or `received_at` for 835).
|
||||||
|
The user clicks "Open prior batch" and the batch may have been
|
||||||
|
deleted in the meantime. The BatchesList page already handles 404
|
||||||
|
gracefully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Rollout
|
||||||
|
|
||||||
|
1. **Schema**: migration 0013 applies on next `cyclone` startup.
|
||||||
|
Idempotent and reversible only by rebuilding the `claims` table
|
||||||
|
(acceptable; production data preserved by the INSERT...SELECT).
|
||||||
|
2. **Backend API**: new `force` param + new 409 body shape + new
|
||||||
|
DELETE endpoint. Existing clients that don't pass `force` see the
|
||||||
|
same behavior as before for collision-free files. Collision cases
|
||||||
|
now get a richer 409 body that includes `parse_result`; clients
|
||||||
|
that ignore the new fields keep working.
|
||||||
|
3. **Frontend**: `Upload.tsx` panel replaces the toast on 409. Users
|
||||||
|
who don't read the panel still see the toast and the 409 message
|
||||||
|
in the streaming view.
|
||||||
|
4. **No data migration**: nothing to migrate. 0013 is structural only.
|
||||||
Generated
+62
@@ -12,6 +12,7 @@
|
|||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.15",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"ansi-styles": "^6.2.3",
|
"ansi-styles": "^6.2.3",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
@@ -1331,6 +1332,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
|
"version": "1.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz",
|
||||||
|
"integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.4",
|
||||||
|
"@radix-ui/react-collection": "1.1.10",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.4",
|
||||||
|
"@radix-ui/react-direction": "1.1.2",
|
||||||
|
"@radix-ui/react-id": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.6",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.2",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-select": {
|
"node_modules/@radix-ui/react-select": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.1.tgz",
|
||||||
@@ -1393,6 +1425,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.4",
|
||||||
|
"@radix-ui/react-context": "1.1.4",
|
||||||
|
"@radix-ui/react-direction": "1.1.2",
|
||||||
|
"@radix-ui/react-id": "1.1.2",
|
||||||
|
"@radix-ui/react-presence": "1.1.6",
|
||||||
|
"@radix-ui/react-primitive": "2.1.6",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.13",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.15",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"ansi-styles": "^6.2.3",
|
"ansi-styles": "^6.2.3",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// AckDrawer wires `useAckDetail` (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, render } from "@testing-library/react";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { AckDrawer } from "@/components/AckDrawer";
|
||||||
|
import type { Ack } 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.getAck` — lets each test pin the hook's exact return shape
|
||||||
|
// without standing up a real `QueryClient`.
|
||||||
|
const { useAckDetail } = vi.hoisted(() => ({
|
||||||
|
useAckDetail: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/hooks/useAckDetail", () => ({
|
||||||
|
useAckDetail,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal valid `Ack` fixture — every required key present so the
|
||||||
|
* component typechecks. The wire shape extends with `raw_999_text`
|
||||||
|
* (and `rawJson`), per `useAckDetail`'s `AckDetail` type — populated
|
||||||
|
* when the backend serves it; absent on older rows.
|
||||||
|
*/
|
||||||
|
const SAMPLE_ACK: Ack & { raw_999_text: string } = {
|
||||||
|
id: 42,
|
||||||
|
sourceBatchId: "b-uuid-1",
|
||||||
|
acceptedCount: 3,
|
||||||
|
rejectedCount: 1,
|
||||||
|
receivedCount: 4,
|
||||||
|
ackCode: "P",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
raw_999_text: "ISA*...*~\nGS*...*~\nST*999*0001~",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: (Ack & { raw_999_text?: string }) | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => void;
|
||||||
|
}> = {}
|
||||||
|
) {
|
||||||
|
useAckDetail.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("AckDrawer", () => {
|
||||||
|
it("test_renders_nothing_when_ackId_is_null", () => {
|
||||||
|
mockDetail({ data: null });
|
||||||
|
render(<AckDrawer ackId={null} onClose={() => {}} />);
|
||||||
|
|
||||||
|
// No ack 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("b-uuid-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_calls_useAckDetail_with_ackId", () => {
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
expect(useAckDetail).toHaveBeenCalledWith("42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_ack_summary_on_success", () => {
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// Header shows the source batch id as the title.
|
||||||
|
expect(document.body.textContent).toContain("b-uuid-1");
|
||||||
|
// Counts (3 accepted, 1 rejected, 4 received) are surfaced as
|
||||||
|
// StatTile values.
|
||||||
|
expect(document.body.textContent).toContain("3");
|
||||||
|
expect(document.body.textContent).toContain("1");
|
||||||
|
expect(document.body.textContent).toContain("4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_ack_code_pill_with_human_label", () => {
|
||||||
|
mockDetail({ data: { ...SAMPLE_ACK, ackCode: "P" } });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The pill renders a human label, not just the bare code letter.
|
||||||
|
expect(document.body.textContent).toContain("Partially accepted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_skeleton_while_loading", () => {
|
||||||
|
mockDetail({ isLoading: true });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The `Skeleton` primitive sets `aria-busy="true"` — a stable
|
||||||
|
// hook for the loading state.
|
||||||
|
expect(document.querySelectorAll('[aria-busy="true"]').length).toBeGreaterThan(0);
|
||||||
|
// And the ack id should NOT have leaked in yet.
|
||||||
|
expect(document.body.textContent).not.toContain("b-uuid-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_not_found_error_on_404", () => {
|
||||||
|
mockDetail({
|
||||||
|
isError: true,
|
||||||
|
error: new ApiError(404, "Ack ghost not found"),
|
||||||
|
});
|
||||||
|
render(<AckDrawer ackId="9999" onClose={() => {}} />);
|
||||||
|
|
||||||
|
const errEl = document.querySelector('[data-testid="ack-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(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
const errEl = document.querySelector('[data-testid="ack-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_close_button_calls_onClose", () => {
|
||||||
|
const onClose = vi.fn<() => void>();
|
||||||
|
mockDetail({ isError: true, error: new Error("network down") });
|
||||||
|
render(<AckDrawer ackId="42" onClose={onClose} />);
|
||||||
|
|
||||||
|
const closeBtn = document.querySelector(
|
||||||
|
'[data-testid="error-close"]'
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(closeBtn).not.toBeNull();
|
||||||
|
closeBtn!.click();
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_download_button_when_raw_999_text_present", () => {
|
||||||
|
mockDetail({ data: SAMPLE_ACK });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// The header action slot populates with the Download 999 button
|
||||||
|
// only when the ack detail carries raw_999_text.
|
||||||
|
const dlBtn = document.querySelector('[data-testid="ack-drawer-download"]');
|
||||||
|
expect(dlBtn).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_omits_download_button_when_raw_999_text_absent", () => {
|
||||||
|
const data: Ack = {
|
||||||
|
id: 42,
|
||||||
|
sourceBatchId: "b-uuid-1",
|
||||||
|
acceptedCount: 3,
|
||||||
|
rejectedCount: 1,
|
||||||
|
receivedCount: 4,
|
||||||
|
ackCode: "A",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
};
|
||||||
|
mockDetail({ data });
|
||||||
|
render(<AckDrawer ackId="42" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// No raw_999_text → no Download button.
|
||||||
|
expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Download } from "lucide-react";
|
||||||
|
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 { cn } from "@/lib/utils";
|
||||||
|
import { useAckDetail, type AckDetail } from "@/hooks/useAckDetail";
|
||||||
|
import { SegmentStatusList } from "./SegmentStatusList";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Currently-open ack id (string), or `null` when the drawer is
|
||||||
|
* closed. The URL stores ids as strings so deep links round-trip
|
||||||
|
* cleanly; the hook does the `Number()` coercion before calling
|
||||||
|
* `api.getAck`.
|
||||||
|
*/
|
||||||
|
ackId: string | null;
|
||||||
|
/** Fired when the user dismisses the drawer (X button, Escape, etc.). */
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll a hex code into a "kind" so the drawer's error branch can
|
||||||
|
* pick the right copy. Mirrors `ProviderDrawer`'s `errorKind`
|
||||||
|
* computation.
|
||||||
|
*/
|
||||||
|
type ErrorKind = "not_found" | "network";
|
||||||
|
|
||||||
|
function AckDrawerError({
|
||||||
|
kind,
|
||||||
|
onRetry,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
kind: ErrorKind;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const COPY = {
|
||||||
|
not_found: {
|
||||||
|
eyebrow: "NOT FOUND",
|
||||||
|
message: "This 999 ACK doesn't exist or has been removed.",
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
eyebrow: "CONNECTION",
|
||||||
|
message:
|
||||||
|
"Couldn't reach the server. Check your connection and try again.",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
const { eyebrow, message } = COPY[kind];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full flex-col"
|
||||||
|
role="alert"
|
||||||
|
data-testid={`ack-drawer-error-${kind}`}
|
||||||
|
>
|
||||||
|
<DrillDrawerHeader eyebrow="999 ACK" title="—" onClose={onClose} />
|
||||||
|
<div className="flex flex-1 flex-col items-start gap-4 px-6 py-6">
|
||||||
|
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive">
|
||||||
|
{eyebrow}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-sm">{message}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{kind === "network" && onRetry ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
data-testid="error-retry"
|
||||||
|
className="rounded-md border px-2.5 py-1 text-[12.5px] font-medium hover:bg-muted"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="error-close"
|
||||||
|
className="rounded-md border px-2.5 py-1 text-[12.5px] font-medium hover:bg-muted"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline ack code pill — same color palette as `AcksPage`
|
||||||
|
* (`Acks.tsx`) so the badge reads the same in both surfaces.
|
||||||
|
*/
|
||||||
|
function AckCodePill({ code }: { code: AckDetail["ackCode"] }) {
|
||||||
|
const cfg =
|
||||||
|
code === "A"
|
||||||
|
? {
|
||||||
|
fg: "hsl(152 64% 30%)",
|
||||||
|
bg: "hsl(152 50% 88%)",
|
||||||
|
border: "hsl(152 64% 38% / 0.30)",
|
||||||
|
label: "Accepted",
|
||||||
|
}
|
||||||
|
: code === "R"
|
||||||
|
? {
|
||||||
|
fg: "hsl(358 70% 36%)",
|
||||||
|
bg: "hsl(358 70% 92%)",
|
||||||
|
border: "hsl(358 70% 50% / 0.30)",
|
||||||
|
label: "Rejected",
|
||||||
|
}
|
||||||
|
: code === "P"
|
||||||
|
? {
|
||||||
|
fg: "hsl(36 92% 30%)",
|
||||||
|
bg: "hsl(36 82% 88%)",
|
||||||
|
border: "hsl(36 92% 50% / 0.30)",
|
||||||
|
label: "Partially accepted",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
fg: "hsl(36 92% 30%)",
|
||||||
|
bg: "hsl(36 82% 88%)",
|
||||||
|
border: "hsl(36 92% 50% / 0.30)",
|
||||||
|
label: "Accepted w/ errors",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{ color: cfg.fg, backgroundColor: cfg.bg, borderColor: cfg.border }}
|
||||||
|
>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
tone: "success" | "destructive" | "ink";
|
||||||
|
}) {
|
||||||
|
const color =
|
||||||
|
tone === "success"
|
||||||
|
? "hsl(152 64% 30%)"
|
||||||
|
: tone === "destructive"
|
||||||
|
? "hsl(358 70% 36%)"
|
||||||
|
: "hsl(var(--foreground))";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border px-3.5 py-3"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "hsl(var(--card) / 0.4)",
|
||||||
|
borderColor: "hsl(var(--border) / 0.5)",
|
||||||
|
}}
|
||||||
|
data-testid="ack-stat"
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="display mono tabular-nums mt-1"
|
||||||
|
style={{ color, fontSize: 22, lineHeight: 1.1 }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 999 ACK drill-down drawer (SP21 Phase 5 Task 5.2).
|
||||||
|
*
|
||||||
|
* Mirror of `ProviderDrawer` — same right-anchored side-panel shell,
|
||||||
|
* same `errorKind` + `useAckDetail` shape (404 vs network), same
|
||||||
|
* skeleton-first loading state. The body is slim: rolled-up counts
|
||||||
|
* at the top, the ack code pill, the source batch id, and a
|
||||||
|
* per-segment status list. The header carries a "Download 999"
|
||||||
|
* action so the user can grab the original X12 file from the drawer
|
||||||
|
* without a second round-trip to `/api/acks/{id}/raw`.
|
||||||
|
*
|
||||||
|
* Layout mirrors `ProviderDrawer`/`ClaimDrawer`: Radix Dialog
|
||||||
|
* repositioned to the right edge as a fixed-height side panel, with
|
||||||
|
* the shared `DrillDrawerHeader` on top and a scrollable body below.
|
||||||
|
*
|
||||||
|
* Error branching:
|
||||||
|
* - `ApiError(404)` → "not_found" (no retry, the ack is gone)
|
||||||
|
* - anything else → "network" (retry available)
|
||||||
|
*/
|
||||||
|
export function AckDrawer({ ackId, onClose }: Props) {
|
||||||
|
const { data, isLoading, isError, error, refetch } = useAckDetail(ackId);
|
||||||
|
|
||||||
|
const errorKind: ErrorKind | null = isError
|
||||||
|
? error instanceof ApiError && error.status === 404
|
||||||
|
? "not_found"
|
||||||
|
: "network"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Download affordance for the regenerated 999 X12 text. Lives in
|
||||||
|
// the drawer (not the page) so a deep-linked user can grab the file
|
||||||
|
// without bouncing back to /acks. `raw_999_text` may be empty for
|
||||||
|
// older rows without the field — we silently no-op rather than
|
||||||
|
// surfacing an error toast (the spec calls this a "low stakes"
|
||||||
|
// affordance).
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const onDownload = useCallback(async () => {
|
||||||
|
if (!data) return;
|
||||||
|
const raw =
|
||||||
|
(data as AckDetail & { raw_999_text?: string }).raw_999_text ?? "";
|
||||||
|
if (!raw || downloading) return;
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
const blob = new Blob([raw], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `ack-${data.sourceBatchId}.999`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
}, [data, downloading]);
|
||||||
|
|
||||||
|
const downloadAction =
|
||||||
|
data && (data as AckDetail & { raw_999_text?: string }).raw_999_text ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDownload}
|
||||||
|
disabled={downloading}
|
||||||
|
aria-label="Download 999 file"
|
||||||
|
title="Download 999 file"
|
||||||
|
data-testid="ack-drawer-download"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-medium uppercase tracking-[0.14em] mono transition-colors",
|
||||||
|
downloading && "opacity-50 cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: "hsl(var(--border) / 0.7)",
|
||||||
|
color: "hsl(var(--foreground))",
|
||||||
|
backgroundColor: "hsl(var(--card) / 0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
|
||||||
|
999
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={ackId !== 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}
|
||||||
|
data-testid="ack-drawer"
|
||||||
|
>
|
||||||
|
{errorKind ? (
|
||||||
|
<AckDrawerError
|
||||||
|
kind={errorKind}
|
||||||
|
onRetry={() => {
|
||||||
|
void refetch();
|
||||||
|
}}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
) : isLoading || !data ? (
|
||||||
|
<div className="flex h-full flex-col overflow-y-auto">
|
||||||
|
<DrillDrawerHeader
|
||||||
|
eyebrow="999 ACK"
|
||||||
|
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"
|
||||||
|
data-testid="ack-drawer-content"
|
||||||
|
>
|
||||||
|
<DrillDrawerHeader
|
||||||
|
eyebrow="999 ACK"
|
||||||
|
title={data.sourceBatchId}
|
||||||
|
onClose={onClose}
|
||||||
|
action={downloadAction}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col divide-y divide-border/40">
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-4 px-6 py-4"
|
||||||
|
data-testid="ack-summary"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<AckCodePill code={data.ackCode} />
|
||||||
|
<span className="mono text-[12px] text-muted-foreground">
|
||||||
|
ID {data.id}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-[12px] text-muted-foreground">
|
||||||
|
· {data.parsedAt ? data.parsedAt.slice(0, 10) : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<StatTile
|
||||||
|
label="Accepted"
|
||||||
|
value={data.acceptedCount}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Rejected"
|
||||||
|
value={data.rejectedCount}
|
||||||
|
tone="destructive"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Received"
|
||||||
|
value={data.receivedCount}
|
||||||
|
tone="ink"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<SegmentStatusList segments={[]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { CheckCircle2, AlertTriangle, Minus } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One 999 ACK segment status row. The 999 spec labels each transaction
|
||||||
|
* set with a 3-char code:
|
||||||
|
*
|
||||||
|
* "A" = Accepted
|
||||||
|
* "E" = Accepted, but errors are noted (one or more segments had
|
||||||
|
* errors; the transaction set as a whole was accepted)
|
||||||
|
* "R" = Rejected
|
||||||
|
* "P" = Partially accepted (mixed — some segments accepted, some
|
||||||
|
* not; a degraded success the operator must investigate)
|
||||||
|
*
|
||||||
|
* The wire shape's `rawJson` (set by the parser, see backend
|
||||||
|
* `cyclone.parsers.parsers_999`) carries an array of segment rows.
|
||||||
|
* Until that array is parsed into a stable UI shape, we render the
|
||||||
|
* rolled-up counts on the ack row (`acceptedCount` / `rejectedCount`)
|
||||||
|
* and a placeholder explaining how to read it.
|
||||||
|
*/
|
||||||
|
interface SegmentRow {
|
||||||
|
/** Loop / segment reference like "ST*999*0001" or "AK2*HC*0001". */
|
||||||
|
reference?: string;
|
||||||
|
/** "A" | "E" | "R" | "P". */
|
||||||
|
status: "A" | "E" | "R" | "P";
|
||||||
|
/** Free-form note from the parser (e.g. "SVC*HC:9450 missing"). */
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
segments: SegmentRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pill for one segment status. Color mirrors the badge palette used on
|
||||||
|
* the Acks page (`Acks.tsx`) so a status reads the same in both
|
||||||
|
* surfaces.
|
||||||
|
*/
|
||||||
|
function StatusPill({ status }: { status: SegmentRow["status"] }) {
|
||||||
|
const cfg = {
|
||||||
|
A: {
|
||||||
|
fg: "hsl(152 64% 30%)",
|
||||||
|
bg: "hsl(152 50% 88%)",
|
||||||
|
border: "hsl(152 64% 38% / 0.30)",
|
||||||
|
label: "Accepted",
|
||||||
|
Icon: CheckCircle2,
|
||||||
|
},
|
||||||
|
E: {
|
||||||
|
fg: "hsl(36 92% 30%)",
|
||||||
|
bg: "hsl(36 82% 88%)",
|
||||||
|
border: "hsl(36 92% 50% / 0.30)",
|
||||||
|
label: "Accepted w/ errors",
|
||||||
|
Icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
R: {
|
||||||
|
fg: "hsl(358 70% 36%)",
|
||||||
|
bg: "hsl(358 70% 92%)",
|
||||||
|
border: "hsl(358 70% 50% / 0.30)",
|
||||||
|
label: "Rejected",
|
||||||
|
Icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
P: {
|
||||||
|
fg: "hsl(36 92% 30%)",
|
||||||
|
bg: "hsl(36 82% 88%)",
|
||||||
|
border: "hsl(36 92% 50% / 0.30)",
|
||||||
|
label: "Partially accepted",
|
||||||
|
Icon: Minus,
|
||||||
|
},
|
||||||
|
}[status];
|
||||||
|
|
||||||
|
const Icon = cfg.Icon;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
|
||||||
|
style={{ color: cfg.fg, backgroundColor: cfg.bg, borderColor: cfg.border }}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-segment status list inside the AckDrawer body (SP21 Phase 5
|
||||||
|
* Task 5.2). Renders one row per parsed 999 segment, each with the
|
||||||
|
* segment reference (when present), the parsed status code, and the
|
||||||
|
* parser's note (when present). An empty list renders a small
|
||||||
|
* explanatory note so the drawer doesn't look broken on old acks
|
||||||
|
* without the per-segment slice.
|
||||||
|
*/
|
||||||
|
export function SegmentStatusList({ segments }: Props) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-3 px-6 py-4"
|
||||||
|
data-testid="ack-segment-list"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Segment status
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="mono text-[10.5px] text-muted-foreground"
|
||||||
|
data-testid="ack-segment-count"
|
||||||
|
>
|
||||||
|
{segments.length} segment{segments.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{segments.length === 0 ? (
|
||||||
|
<p
|
||||||
|
className="text-[12.5px] text-muted-foreground"
|
||||||
|
data-testid="ack-segment-empty"
|
||||||
|
>
|
||||||
|
No per-segment breakdown for this ack — the rolled-up accepted
|
||||||
|
/ rejected counts above are the only signal.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border/40 border-y border-border/30">
|
||||||
|
{segments.map((seg, idx) => (
|
||||||
|
<li
|
||||||
|
key={`${seg.reference ?? idx}`}
|
||||||
|
className="flex items-start gap-3 py-2.5"
|
||||||
|
data-testid="ack-segment-row"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="mono text-[12px] tabular-nums text-muted-foreground shrink-0"
|
||||||
|
style={{ minWidth: 32 }}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<StatusPill status={seg.status} />
|
||||||
|
{seg.reference ? (
|
||||||
|
<span className="mono text-[12px] truncate">
|
||||||
|
{seg.reference}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{seg.note ? (
|
||||||
|
<p className="text-[12.5px] text-muted-foreground mt-1 leading-snug">
|
||||||
|
{seg.note}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Barrel export for the AckDrawer module (SP21 Phase 5 Task 5.2).
|
||||||
|
|
||||||
|
export { AckDrawer } from "./AckDrawer";
|
||||||
|
export { SegmentStatusList } from "./SegmentStatusList";
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { ActivityFeed } from "./ActivityFeed";
|
import { ActivityFeed } from "./ActivityFeed";
|
||||||
import type { Activity } from "@/types";
|
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 = {
|
const baseActivity: Activity = {
|
||||||
id: "a-1",
|
id: "a-1",
|
||||||
kind: "claim_submitted",
|
kind: "claim_submitted",
|
||||||
@@ -47,4 +51,99 @@ describe("ActivityFeed", () => {
|
|||||||
expect(() => render(<ActivityFeed items={[unknown]} />)).not.toThrow();
|
expect(() => render(<ActivityFeed items={[unknown]} />)).not.toThrow();
|
||||||
expect(screen.getByText("future_kind event arrived")).toBeTruthy();
|
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 {
|
import {
|
||||||
Banknote,
|
Banknote,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -63,9 +64,22 @@ const FALLBACK_KIND: { icon: LucideIcon; tone: string; tint: string } = {
|
|||||||
export function ActivityFeed({
|
export function ActivityFeed({
|
||||||
items,
|
items,
|
||||||
emptyMessage = "No activity yet.",
|
emptyMessage = "No activity yet.",
|
||||||
|
onItemClick,
|
||||||
}: {
|
}: {
|
||||||
items: Activity[];
|
items: Activity[];
|
||||||
emptyMessage?: string;
|
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) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -79,11 +93,36 @@ export function ActivityFeed({
|
|||||||
{items.map((a) => {
|
{items.map((a) => {
|
||||||
const cfg = kindConfig[a.kind] ?? FALLBACK_KIND;
|
const cfg = kindConfig[a.kind] ?? FALLBACK_KIND;
|
||||||
const Icon = cfg.icon;
|
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 (
|
return (
|
||||||
<li
|
<li key={a.id} {...rowProps}>
|
||||||
key={a.id}
|
|
||||||
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"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,11 @@
|
|||||||
import { Plus, Minus, Equal, ArrowRight, type LucideIcon } from "lucide-react";
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Equal,
|
||||||
|
Minus,
|
||||||
|
Plus,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
@@ -9,19 +16,21 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { KpiCard } from "@/components/KpiCard";
|
import { DrillableCell } from "@/components/drill/DrillableCell";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
|
BatchClaimDiffSummary,
|
||||||
BatchDiff,
|
BatchDiff,
|
||||||
BatchDiffChangedRow,
|
BatchDiffChangedRow,
|
||||||
BatchDiffSideMeta,
|
BatchDiffSideMeta,
|
||||||
BatchDiffSummary,
|
BatchDiffSummary,
|
||||||
BatchClaimDiffSummary,
|
|
||||||
} from "@/types";
|
} 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({
|
function SideMeta({
|
||||||
@@ -39,10 +48,19 @@ function SideMeta({
|
|||||||
: "text-amber-300 border-amber-400/30 bg-amber-400/10";
|
: "text-amber-300 border-amber-400/30 bg-amber-400/10";
|
||||||
return (
|
return (
|
||||||
<div
|
<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()}`}
|
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]"
|
||||||
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||||
>
|
>
|
||||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -54,12 +72,23 @@ function SideMeta({
|
|||||||
>
|
>
|
||||||
{side.kind}
|
{side.kind}
|
||||||
</span>
|
</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>
|
||||||
<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}
|
{side.inputFilename}
|
||||||
</div>
|
</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>Parsed {side.parsedAt ? fmt.dateShort(side.parsedAt) : "—"}</span>
|
||||||
<span className="font-mono num">
|
<span className="font-mono num">
|
||||||
{fmt.num(side.claimCount)} claim{side.claimCount === 1 ? "" : "s"}
|
{fmt.num(side.claimCount)} claim{side.claimCount === 1 ? "" : "s"}
|
||||||
@@ -70,40 +99,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 }) {
|
function SummaryCards({ summary }: { summary: BatchDiffSummary }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3" data-testid="batch-diff-summary">
|
<div
|
||||||
<KpiCard
|
className="grid grid-cols-2 md:grid-cols-4 gap-3"
|
||||||
|
data-testid="batch-diff-summary"
|
||||||
|
>
|
||||||
|
<DiffKpiTile
|
||||||
label="Added in B"
|
label="Added in B"
|
||||||
value={fmt.num(summary.addedCount)}
|
value={fmt.num(summary.addedCount)}
|
||||||
icon={Plus}
|
icon={Plus}
|
||||||
hint="In B, not in A"
|
hint="In B, not in A"
|
||||||
|
tone="success"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<DiffKpiTile
|
||||||
label="Removed from A"
|
label="Removed from A"
|
||||||
value={fmt.num(summary.removedCount)}
|
value={fmt.num(summary.removedCount)}
|
||||||
icon={Minus}
|
icon={Minus}
|
||||||
hint="In A, not in B"
|
hint="In A, not in B"
|
||||||
|
tone="destructive"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<DiffKpiTile
|
||||||
label="Changed"
|
label="Changed"
|
||||||
value={fmt.num(summary.changedCount)}
|
value={fmt.num(summary.changedCount)}
|
||||||
icon={Equal}
|
icon={Equal}
|
||||||
hint="Deltas in either side"
|
hint="Deltas in either side"
|
||||||
|
tone="amber"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<DiffKpiTile
|
||||||
label="Unchanged"
|
label="Unchanged"
|
||||||
value={fmt.num(summary.unchangedCount)}
|
value={fmt.num(summary.unchangedCount)}
|
||||||
icon={Equal}
|
icon={Equal}
|
||||||
hint="Identical across A & B"
|
hint="Identical across A & B"
|
||||||
|
tone="ink"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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.
|
// Row indicator — `+` / `-` / `~` gutter on the left of every row.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -151,23 +265,58 @@ function RowIndicator({
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function ClaimIdCell({ id }: { id: string }) {
|
function ClaimIdCell({ id }: { id: string }) {
|
||||||
|
// SP21 Phase 5 Task 5.6: each claim id is drillable to
|
||||||
|
// /claims?claim=ID so the operator can jump straight from a
|
||||||
|
// "Removed from A" or "Changed" row into the ClaimDrawer.
|
||||||
|
// DrillableCell handles e.stopPropagation internally — important
|
||||||
|
// if these rows ever get a row-level onClick. For removed claims
|
||||||
|
// that no longer exist in the DB, the ClaimDrawer's 404 state
|
||||||
|
// takes over (verified in Phase 2 testing).
|
||||||
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<span className="font-mono text-[12px] tracking-tight" data-testid="diff-claim-id">
|
<DrillableCell
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/claims?claim=${encodeURIComponent(id)}`)
|
||||||
|
}
|
||||||
|
ariaLabel={`View claim ${id} in detail`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono text-[12px] tracking-tight"
|
||||||
|
data-testid="diff-claim-id"
|
||||||
|
>
|
||||||
{id}
|
{id}
|
||||||
</span>
|
</span>
|
||||||
|
</DrillableCell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PatientCell({ name }: { name: string }) {
|
function PatientCell({ name }: { name: string }) {
|
||||||
if (!name) {
|
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 }) {
|
function ChargeCell({ value }: { value: number }) {
|
||||||
return (
|
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)}
|
{fmt.usdPrecise(value)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -175,19 +324,44 @@ function ChargeCell({ value }: { value: number }) {
|
|||||||
|
|
||||||
function DateCell({ value }: { value: string | null }) {
|
function DateCell({ value }: { value: string | null }) {
|
||||||
if (!value) {
|
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 }) {
|
function StatusCell({ value }: { value: string }) {
|
||||||
if (!value) {
|
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 (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="font-mono uppercase tracking-wider text-[10px]"
|
className="font-mono uppercase tracking-wider text-[10px]"
|
||||||
|
style={{
|
||||||
|
color: "hsl(var(--surface-ink))",
|
||||||
|
borderColor: "hsl(30 14% 14% / 0.20)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -196,17 +370,28 @@ function StatusCell({ value }: { value: string }) {
|
|||||||
|
|
||||||
function CptCell({ codes }: { codes: string[] }) {
|
function CptCell({ codes }: { codes: string[] }) {
|
||||||
if (!codes || codes.length === 0) {
|
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 (
|
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(", ")}
|
{codes.join(", ")}
|
||||||
</span>
|
</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";
|
type Side = "a" | "b";
|
||||||
@@ -386,25 +571,53 @@ function SectionTable({
|
|||||||
<section className="space-y-3" data-testid={testid}>
|
<section className="space-y-3" data-testid={testid}>
|
||||||
<header className="flex items-baseline justify-between">
|
<header className="flex items-baseline justify-between">
|
||||||
<div>
|
<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}
|
{eyebrow}
|
||||||
</div>
|
</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>
|
</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)}
|
{fmt.num(count)}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
{count === 0 ? (
|
{count === 0 ? (
|
||||||
<div
|
<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`}
|
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}
|
{emptyMessage}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="surface rounded-xl overflow-hidden">
|
<div
|
||||||
<Table>
|
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>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-10" aria-label="Diff indicator" />
|
<TableHead className="w-10" aria-label="Diff indicator" />
|
||||||
@@ -425,7 +638,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() {
|
export function BatchDiffViewSkeleton() {
|
||||||
@@ -462,11 +676,24 @@ export function BatchDiffEmpty({ data }: { data: BatchDiff }) {
|
|||||||
<SideMeta side={data.b} label="B (right)" />
|
<SideMeta side={data.b} label="B (right)" />
|
||||||
</div>
|
</div>
|
||||||
<SummaryCards summary={data.summary} />
|
<SummaryCards summary={data.summary} />
|
||||||
<div className="surface rounded-xl border border-dashed border-border/60 p-10 text-center">
|
<div
|
||||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
|
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
|
Diff · no deltas
|
||||||
</div>
|
</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
|
These two batches are identical — no claims added, removed, or
|
||||||
changed between A and B.
|
changed between A and B.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ import type { BatchSummary } from "@/lib/api";
|
|||||||
*
|
*
|
||||||
* Voice mirrors `AckCodeBadge` in `src/pages/Acks.tsx` (uppercase,
|
* Voice mirrors `AckCodeBadge` in `src/pages/Acks.tsx` (uppercase,
|
||||||
* wide tracking, hairline border, low-opacity fill).
|
* 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"] }) {
|
function KindBadge({ kind }: { kind: BatchSummary["kind"] }) {
|
||||||
const color =
|
const color =
|
||||||
@@ -42,7 +47,8 @@ function KindBadge({ kind }: { kind: BatchSummary["kind"] }) {
|
|||||||
/**
|
/**
|
||||||
* Skeleton rows for the batches table. Mirrors the row count used in
|
* Skeleton rows for the batches table. Mirrors the row count used in
|
||||||
* `Acks.tsx` (5 placeholders) so the loading density matches the rest
|
* `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() {
|
export function BatchesListSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -63,6 +69,13 @@ type BatchesListProps = {
|
|||||||
*/
|
*/
|
||||||
openId: string | null;
|
openId: string | null;
|
||||||
onOpen: (id: string) => void;
|
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
|
* so the numbers tick up from 0 on first render (gives the page a
|
||||||
* little life on load; consistent with the Dashboard KPI cards).
|
* 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 (
|
return (
|
||||||
<Table data-testid="batches-table">
|
<Table data-testid="batches-table" tone={tone}>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Kind</TableHead>
|
<TableHead>Kind</TableHead>
|
||||||
<TableHead>Batch</TableHead>
|
<TableHead>Batch</TableHead>
|
||||||
<TableHead>Input file</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>Parsed</TableHead>
|
||||||
<TableHead className="w-6" aria-label="Open" />
|
<TableHead className="w-6" aria-label="Open" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -93,28 +117,57 @@ export function BatchesList({ items, openId, onOpen }: BatchesListProps) {
|
|||||||
data-open={openId === b.id ? "true" : undefined}
|
data-open={openId === b.id ? "true" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"animate-row-flash cursor-pointer",
|
"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>
|
<TableCell>
|
||||||
<KindBadge kind={b.kind} />
|
<KindBadge kind={b.kind} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="display num text-[13px]">
|
<TableCell
|
||||||
|
className="display num text-[13px]"
|
||||||
|
style={isPaper ? { color: "hsl(var(--surface-ink))" } : undefined}
|
||||||
|
>
|
||||||
{b.id}
|
{b.id}
|
||||||
</TableCell>
|
</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}
|
{b.inputFilename}
|
||||||
</TableCell>
|
</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
|
<AnimatedNumber
|
||||||
value={b.claimCount}
|
value={b.claimCount}
|
||||||
format={(n) => fmt.num(Math.round(n))}
|
format={(n) => fmt.num(Math.round(n))}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</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) : "—"}
|
{b.parsedAt ? fmt.dateShort(b.parsedAt) : "—"}
|
||||||
</TableCell>
|
</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>
|
<span aria-hidden>›</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createRoot, type Root } from "react-dom/client";
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ClaimDrawer } from "./ClaimDrawer";
|
import { ClaimDrawer } from "./ClaimDrawer";
|
||||||
|
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||||
import { api, ApiError } from "@/lib/api";
|
import { api, ApiError } from "@/lib/api";
|
||||||
import type { ClaimDetail } from "@/types";
|
import type { ClaimDetail } from "@/types";
|
||||||
|
|
||||||
@@ -162,14 +163,22 @@ function renderDrawer(
|
|||||||
React.createElement(
|
React.createElement(
|
||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
{ client: qc },
|
{ client: qc },
|
||||||
|
// SP21 Phase 5 Task 5.8: PartiesGrid (mounted by ClaimDrawer)
|
||||||
|
// calls useDrillStack(). Wrap the drawer in DrillStackProvider
|
||||||
|
// so the hook has a context. (The provider is also mounted at
|
||||||
|
// the App root in production.)
|
||||||
|
React.createElement(
|
||||||
|
DrillStackProvider,
|
||||||
|
null,
|
||||||
React.createElement(ClaimDrawer, {
|
React.createElement(ClaimDrawer, {
|
||||||
claimId: props.claimId,
|
claimId: props.claimId,
|
||||||
claims,
|
claims,
|
||||||
onClose,
|
onClose,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onToggleHelp,
|
onToggleHelp,
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -131,14 +131,17 @@ describe("ClaimDrawerHeader", () => {
|
|||||||
it("test_renders_claim_id_label_and_value", () => {
|
it("test_renders_claim_id_label_and_value", () => {
|
||||||
const { container, unmount } = renderHeader({ id: "CLM-42" });
|
const { container, unmount } = renderHeader({ id: "CLM-42" });
|
||||||
|
|
||||||
// The eyebrow label "Claim" is rendered as uppercase text.
|
// The eyebrow label "Claim" is rendered as uppercase text by
|
||||||
|
// DrillDrawerHeader.
|
||||||
const text = (container.textContent ?? "").toLowerCase();
|
const text = (container.textContent ?? "").toLowerCase();
|
||||||
expect(text).toContain("claim");
|
expect(text).toContain("claim");
|
||||||
|
|
||||||
// The claim id sits in a node tagged with data-testid="header-id".
|
// SP21 Phase 5 Task 5.10: the claim id is now the title passed
|
||||||
const idEl = container.querySelector('[data-testid="header-id"]');
|
// to DrillDrawerHeader, which renders it inside an <h2>. Find
|
||||||
expect(idEl).not.toBeNull();
|
// the h2 and assert its text matches the claim id.
|
||||||
expect(idEl?.textContent).toBe("CLM-42");
|
const titleEl = container.querySelector("h2");
|
||||||
|
expect(titleEl).not.toBeNull();
|
||||||
|
expect(titleEl?.textContent).toBe("CLM-42");
|
||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -212,8 +215,11 @@ describe("ClaimDrawerHeader", () => {
|
|||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const { container, unmount } = renderHeader({}, onClose);
|
const { container, unmount } = renderHeader({}, onClose);
|
||||||
|
|
||||||
|
// SP21 Phase 5 Task 5.10: the close button is now rendered by
|
||||||
|
// DrillDrawerHeader (no `data-testid`); find it via its
|
||||||
|
// accessible name instead.
|
||||||
const closeBtn = container.querySelector(
|
const closeBtn = container.querySelector(
|
||||||
'[data-testid="header-close"]'
|
'button[aria-label="Close drawer"]'
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
expect(closeBtn).not.toBeNull();
|
expect(closeBtn).not.toBeNull();
|
||||||
expect(onClose).not.toHaveBeenCalled();
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
@@ -226,18 +232,25 @@ describe("ClaimDrawerHeader", () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("test_uses_modern_palette_surface", () => {
|
it("test_uses_shared_drilldrawerheader_shell", () => {
|
||||||
// The drawer header anchors itself on the light surface palette
|
// SP21 Phase 5 Task 5.10: the header is now a thin wrapper
|
||||||
// token (matches ClaimDrawerSkeleton / ClaimDrawerError). The root
|
// around DrillDrawerHeader — verify the wrapper is present and
|
||||||
// <header> element is tagged with data-testid="claim-drawer-header"
|
// that the underlying shell is the shared one (an h2 with the
|
||||||
// so we can sniff its className without coupling to the badge
|
// expected Tailwind treatment). The "Claim" eyebrow + claim id
|
||||||
// or close-button wrappers.
|
// title prove the shell rendered.
|
||||||
const { container, unmount } = renderHeader({});
|
const { container, unmount } = renderHeader({ id: "CLM-42" });
|
||||||
|
|
||||||
const root = container.querySelector('[data-testid="claim-drawer-header"]');
|
const root = container.querySelector('[data-testid="claim-drawer-header"]');
|
||||||
expect(root).not.toBeNull();
|
expect(root).not.toBeNull();
|
||||||
const cls = root?.className ?? "";
|
|
||||||
expect(cls).toContain("bg-[color:var(--m-surface)]");
|
// The h2 is DrillDrawerHeader's title slot.
|
||||||
|
const titleEl = root?.querySelector("h2");
|
||||||
|
expect(titleEl).not.toBeNull();
|
||||||
|
expect(titleEl?.textContent).toBe("CLM-42");
|
||||||
|
// The className pattern DrillDrawerHeader uses for the title.
|
||||||
|
const cls = titleEl?.className ?? "";
|
||||||
|
expect(cls).toContain("text-[18px]");
|
||||||
|
expect(cls).toContain("font-semibold");
|
||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Download, X } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { downloadTextFile } from "@/lib/download";
|
import { downloadTextFile } from "@/lib/download";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { ClaimDetail } from "@/types";
|
import type { ClaimDetail } from "@/types";
|
||||||
|
|
||||||
type ClaimDrawerHeaderProps = {
|
type ClaimDrawerHeaderProps = {
|
||||||
@@ -47,12 +47,19 @@ function badgeVariantFor(state: string): BadgeProps["variant"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header band for the claim detail drawer (SP4).
|
* Header band for the claim detail drawer (SP4 → refactored SP21
|
||||||
|
* Phase 5 Task 5.10).
|
||||||
*
|
*
|
||||||
* Top-left: instrument-style "Claim" eyebrow + large mono ID.
|
* The shell is now the shared `DrillDrawerHeader` (same as
|
||||||
* Top-right: state badge + total billed amount + close button. The
|
* `ProviderDrawer` / `AckDrawer`) — eyebrow + title on the left,
|
||||||
* badge color encodes state so the user can read the drawer's status
|
* close button on the right. The right-side `action` slot carries
|
||||||
* at a glance without scrolling.
|
* the state badge, total billed amount, and the "Download 837"
|
||||||
|
* button, all of which used to live in a custom <header> block.
|
||||||
|
*
|
||||||
|
* Top-left: instrument-style "Claim" eyebrow + the claim ID.
|
||||||
|
* Top-right: state badge + total billed amount + download button.
|
||||||
|
* The badge color encodes state so the user can read the drawer's
|
||||||
|
* status at a glance without scrolling.
|
||||||
*/
|
*/
|
||||||
export function ClaimDrawerHeader({
|
export function ClaimDrawerHeader({
|
||||||
claim,
|
claim,
|
||||||
@@ -80,31 +87,12 @@ export function ClaimDrawerHeader({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// The action slot is rendered by DrillDrawerHeader to the left of
|
||||||
<header
|
// the close button. Group the three action pieces (badge, amount,
|
||||||
className={cn(
|
// download) in a single flex row so they read as a unit.
|
||||||
"flex items-start justify-between gap-4 px-6 py-5",
|
const action = (
|
||||||
"border-b border-[color:var(--m-border-heavy)]/40",
|
<div className="flex items-center gap-2" data-testid="claim-header-actions">
|
||||||
"bg-[color:var(--m-surface)]"
|
<div className="flex items-center gap-2">
|
||||||
)}
|
|
||||||
data-testid="claim-drawer-header"
|
|
||||||
>
|
|
||||||
{/* Left: eyebrow + mono claim ID */}
|
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
|
||||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
|
||||||
Claim
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
data-testid="header-id"
|
|
||||||
className="mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
|
||||||
>
|
|
||||||
{claim.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: state badge + total amount + download + close */}
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
<Badge
|
<Badge
|
||||||
variant={badgeVariantFor(claim.state)}
|
variant={badgeVariantFor(claim.state)}
|
||||||
data-testid="header-state"
|
data-testid="header-state"
|
||||||
@@ -114,7 +102,7 @@ export function ClaimDrawerHeader({
|
|||||||
</Badge>
|
</Badge>
|
||||||
<span
|
<span
|
||||||
data-testid="header-amount"
|
data-testid="header-amount"
|
||||||
className="mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
|
className="mono text-sm tabular-nums text-muted-foreground"
|
||||||
>
|
>
|
||||||
{fmt.usdPrecise(claim.billedAmount)}
|
{fmt.usdPrecise(claim.billedAmount)}
|
||||||
</span>
|
</span>
|
||||||
@@ -130,16 +118,17 @@ export function ClaimDrawerHeader({
|
|||||||
>
|
>
|
||||||
<Download className="h-4 w-4" strokeWidth={1.75} />
|
<Download className="h-4 w-4" strokeWidth={1.75} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close drawer"
|
|
||||||
data-testid="header-close"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" strokeWidth={1.75} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header data-testid="claim-drawer-header">
|
||||||
|
<DrillDrawerHeader
|
||||||
|
eyebrow="Claim"
|
||||||
|
title={claim.id}
|
||||||
|
onClose={onClose}
|
||||||
|
action={action}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,23 @@
|
|||||||
|
|
||||||
import React, { act } from "react";
|
import React, { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import { PartiesGrid } from "./PartiesGrid";
|
import { PartiesGrid } from "./PartiesGrid";
|
||||||
|
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||||
import type {
|
import type {
|
||||||
ClaimDetailAddress,
|
ClaimDetailAddress,
|
||||||
ClaimDetailParties,
|
ClaimDetailParties,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
|
// Mock the api module so PayerPeekContent's usePayerSummary call
|
||||||
|
// doesn't make a real network request. The spy is set per-test in
|
||||||
|
// beforeEach.
|
||||||
|
vi.mock("@/hooks/usePayerSummary", () => ({
|
||||||
|
usePayerSummary: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { usePayerSummary } from "@/hooks/usePayerSummary";
|
||||||
|
|
||||||
function renderIntoContainer(element: React.ReactElement): {
|
function renderIntoContainer(element: React.ReactElement): {
|
||||||
container: HTMLDivElement;
|
container: HTMLDivElement;
|
||||||
unmount: () => void;
|
unmount: () => void;
|
||||||
@@ -20,8 +30,11 @@ function renderIntoContainer(element: React.ReactElement): {
|
|||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
const root: Root = createRoot(container);
|
const root: Root = createRoot(container);
|
||||||
|
// SP21 Phase 5 Task 5.8: PartiesGrid now uses useDrillStack() to
|
||||||
|
// open payer peeks. Wrap every render in a DrillStackProvider so
|
||||||
|
// the hook has a context to read from.
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(element);
|
root.render(<DrillStackProvider>{element}</DrillStackProvider>);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
container,
|
container,
|
||||||
@@ -279,4 +292,112 @@ describe("PartiesGrid", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.8: payer name is drillable (renders as a button)", () => {
|
||||||
|
// The payer card's name now wraps in a button with the
|
||||||
|
// `drillable` affordance + `data-testid="party-payer-name-drill"`.
|
||||||
|
// The other two cards (billing-provider, subscriber) keep the
|
||||||
|
// name as a plain div.
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<PartiesGrid parties={makeParties()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const payerNameBtn = container.querySelector(
|
||||||
|
'[data-testid="party-payer-name-drill"]',
|
||||||
|
);
|
||||||
|
expect(payerNameBtn).not.toBeNull();
|
||||||
|
expect(payerNameBtn?.textContent).toContain("Aetna");
|
||||||
|
|
||||||
|
// The billing-provider and subscriber names stay plain divs.
|
||||||
|
expect(
|
||||||
|
container.querySelector(
|
||||||
|
'[data-testid="party-billing-provider-name-drill"]',
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
container.querySelector(
|
||||||
|
'[data-testid="party-subscriber-name-drill"]',
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.8: clicking the payer name opens the PeekModal", async () => {
|
||||||
|
// Mock the payer summary fetch so PayerPeekContent resolves
|
||||||
|
// without a real network call. The peek modal renders
|
||||||
|
// regardless of fetch state (loading skeleton → content).
|
||||||
|
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<PartiesGrid parties={makeParties()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const payerNameBtn = container.querySelector(
|
||||||
|
'[data-testid="party-payer-name-drill"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(payerNameBtn).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
payerNameBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The PeekModal is a Radix Dialog that portals into document.body.
|
||||||
|
// Look for the [role="dialog"] (modal) with our eyebrow "Payer".
|
||||||
|
const dialogs = document.body.querySelectorAll('[role="dialog"]');
|
||||||
|
// One dialog is the modal itself (the peek). PartiesGrid doesn't
|
||||||
|
// open a drawer, so we expect exactly one.
|
||||||
|
expect(dialogs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const peekDialog = Array.from(dialogs).find((d) =>
|
||||||
|
d.textContent?.includes("Payer"),
|
||||||
|
);
|
||||||
|
expect(peekDialog).not.toBeNull();
|
||||||
|
expect(peekDialog?.textContent).toContain("Aetna");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.8: peek uses the X12 payer id, not the human name", () => {
|
||||||
|
// The PayerPeekContent is given the X12 payer id from
|
||||||
|
// `payer.id`. Verify the click flow passes the right id by
|
||||||
|
// checking usePayerSummary was called with "PAYER01".
|
||||||
|
const mockUsePayerSummary = vi.fn().mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
mockUsePayerSummary,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<PartiesGrid parties={makeParties()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// No peek yet → no usePayerSummary call.
|
||||||
|
expect(mockUsePayerSummary).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const payerNameBtn = container.querySelector(
|
||||||
|
'[data-testid="party-payer-name-drill"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
act(() => {
|
||||||
|
payerNameBtn!.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// After the click, PayerPeekContent mounts and calls
|
||||||
|
// usePayerSummary("PAYER01") — NOT the human name "Aetna".
|
||||||
|
expect(mockUsePayerSummary).toHaveBeenCalledWith("PAYER01");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset the mock between tests so per-test implementations stick.
|
||||||
|
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { ClaimDetail, ClaimDetailAddress } from "@/types";
|
import type { ClaimDetail, ClaimDetailAddress } from "@/types";
|
||||||
|
import { useDrillStack } from "@/components/drill/DrillStackProvider";
|
||||||
|
import { PeekModal } from "@/components/drill/PeekModal";
|
||||||
|
import { PayerPeekContent } from "@/components/drill/PayerPeekContent";
|
||||||
|
|
||||||
type PartiesGridProps = {
|
type PartiesGridProps = {
|
||||||
parties: ClaimDetail["parties"];
|
parties: ClaimDetail["parties"];
|
||||||
@@ -48,12 +51,14 @@ function PartyCard({
|
|||||||
name,
|
name,
|
||||||
identity,
|
identity,
|
||||||
address,
|
address,
|
||||||
|
onNameClick,
|
||||||
}: {
|
}: {
|
||||||
testId: string;
|
testId: string;
|
||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
identity: React.ReactNode;
|
identity: React.ReactNode;
|
||||||
address?: AddressLike;
|
address?: AddressLike;
|
||||||
|
onNameClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -63,9 +68,21 @@ function PartyCard({
|
|||||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
{onNameClick ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNameClick}
|
||||||
|
data-testid={`${testId}-name-drill`}
|
||||||
|
aria-label={`Drill into ${label.toLowerCase()} ${name}`}
|
||||||
|
className="display text-lg text-[color:var(--m-ink-primary)] text-left cursor-pointer drillable rounded-sm px-0 -mx-0"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<div className="display text-lg text-[color:var(--m-ink-primary)]">
|
<div className="display text-lg text-[color:var(--m-ink-primary)]">
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
|
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||||
>
|
>
|
||||||
@@ -88,6 +105,13 @@ function PartyCard({
|
|||||||
*/
|
*/
|
||||||
export function PartiesGrid({ parties }: PartiesGridProps) {
|
export function PartiesGrid({ parties }: PartiesGridProps) {
|
||||||
const { billingProvider, subscriber, payer } = parties;
|
const { billingProvider, subscriber, payer } = parties;
|
||||||
|
// SP21 Phase 5 Task 5.8: payer name opens a PeekModal on top of
|
||||||
|
// the drawer — the peek stack (DrillStackProvider) holds at most
|
||||||
|
// one peek at a time, so opening payer-peek replaces any earlier
|
||||||
|
// peek. The X12 payer id (`payer.id`) is what the peek endpoint
|
||||||
|
// expects, NOT the human name.
|
||||||
|
const { stack, openPeek, closeTop } = useDrillStack();
|
||||||
|
const topPeek = stack[stack.length - 1];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -142,8 +166,31 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
|
|||||||
<div>ID {payer.id}</div>
|
<div>ID {payer.id}</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
// SP21 Phase 5 Task 5.8: payer name is drillable. Clicking
|
||||||
|
// pushes a payer peek onto the drill stack; the PeekModal
|
||||||
|
// at the bottom of this section renders when the top of
|
||||||
|
// the stack is "payer". The payer id used here is the X12
|
||||||
|
// payer_id (e.g. "SKCO0") — verified in ClaimDetailPayer
|
||||||
|
// type — which is what usePayerSummary expects.
|
||||||
|
onNameClick={() => openPeek({ kind: "payer", payerId: payer.id })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* SP21 Phase 5 Task 5.8: payer peek. Mounted at the bottom
|
||||||
|
of PartiesGrid so the peek sits on top of the drawer (Radix
|
||||||
|
Dialog portals). Closing the peek pops the stack. Only one
|
||||||
|
peek renders at a time (the stack caps at 1) — when the
|
||||||
|
user opens a payer peek, any earlier peek is replaced. */}
|
||||||
|
{topPeek?.kind === "payer" ? (
|
||||||
|
<PeekModal
|
||||||
|
open
|
||||||
|
onClose={closeTop}
|
||||||
|
eyebrow="Payer"
|
||||||
|
title={payer.name}
|
||||||
|
>
|
||||||
|
<PayerPeekContent payerId={topPeek.payerId} />
|
||||||
|
</PeekModal>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
import React, { act } from "react";
|
import React, { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, beforeEach } from "vitest";
|
||||||
import { ValidationPanel } from "./ValidationPanel";
|
import { ValidationPanel } from "./ValidationPanel";
|
||||||
|
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||||
import type { ClaimDetailValidation, ClaimDetailValidationIssue } from "@/types";
|
import type { ClaimDetailValidation, ClaimDetailValidationIssue } from "@/types";
|
||||||
|
|
||||||
function renderIntoContainer(element: React.ReactElement): {
|
function renderIntoContainer(element: React.ReactElement): {
|
||||||
@@ -17,8 +18,11 @@ function renderIntoContainer(element: React.ReactElement): {
|
|||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
const root: Root = createRoot(container);
|
const root: Root = createRoot(container);
|
||||||
|
// SP21 Phase 5 Task 5.9: ValidationPanel now uses useDrillStack()
|
||||||
|
// to open validation-rule peeks. Wrap every render in a
|
||||||
|
// DrillStackProvider so the hook has a context to read from.
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(element);
|
root.render(<DrillStackProvider>{element}</DrillStackProvider>);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
container,
|
container,
|
||||||
@@ -244,4 +248,129 @@ describe("ValidationPanel", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.9: rule code is drillable (renders as a button)", () => {
|
||||||
|
// The rule code in each IssueGroup now wraps in a button with
|
||||||
|
// data-testid="...-rule-drill". The other rule codes also drill
|
||||||
|
// the same way.
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<ValidationPanel
|
||||||
|
validation={makeValidation({
|
||||||
|
passed: false,
|
||||||
|
errors: [
|
||||||
|
makeIssue({ rule: "R050_diagnosis_present" }),
|
||||||
|
],
|
||||||
|
warnings: [
|
||||||
|
makeIssue({
|
||||||
|
rule: "R200_units_recommended",
|
||||||
|
severity: "warning",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorRuleDrill = container.querySelector(
|
||||||
|
'[data-testid="validation-errors-rule-drill"]',
|
||||||
|
);
|
||||||
|
const warningRuleDrill = container.querySelector(
|
||||||
|
'[data-testid="validation-warnings-rule-drill"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errorRuleDrill).not.toBeNull();
|
||||||
|
expect(errorRuleDrill?.tagName).toBe("BUTTON");
|
||||||
|
expect(errorRuleDrill?.textContent).toContain("R050_diagnosis_present");
|
||||||
|
|
||||||
|
expect(warningRuleDrill).not.toBeNull();
|
||||||
|
expect(warningRuleDrill?.tagName).toBe("BUTTON");
|
||||||
|
expect(warningRuleDrill?.textContent).toContain("R200_units_recommended");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.9: clicking the rule code opens the PeekModal", async () => {
|
||||||
|
// Clicking the rule code in the errors sub-section pushes a
|
||||||
|
// `{ kind: "rule", rule }` peek onto the drill stack. The peek
|
||||||
|
// is a Radix Dialog portal with the eyebrow "Validation rule"
|
||||||
|
// and the rule code as the title.
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<ValidationPanel
|
||||||
|
validation={makeValidation({
|
||||||
|
passed: false,
|
||||||
|
errors: [
|
||||||
|
makeIssue({ rule: "R050_diagnosis_present" }),
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ruleBtn = container.querySelector(
|
||||||
|
'[data-testid="validation-errors-rule-drill"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(ruleBtn).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
ruleBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The PeekModal portals to document.body as a Radix dialog. Find
|
||||||
|
// the dialog with the "Validation rule" eyebrow.
|
||||||
|
const dialogs = document.body.querySelectorAll('[role="dialog"]');
|
||||||
|
const peekDialog = Array.from(dialogs).find((d) =>
|
||||||
|
d.textContent?.includes("Validation rule"),
|
||||||
|
);
|
||||||
|
expect(peekDialog).not.toBeNull();
|
||||||
|
// Title is the rule code.
|
||||||
|
expect(peekDialog?.textContent).toContain("R050_diagnosis_present");
|
||||||
|
// Body includes the catalog description for R050.
|
||||||
|
expect(peekDialog?.textContent).toContain("Diagnosis pointer present");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.9: unknown rule still opens the peek (fallback note)", async () => {
|
||||||
|
// Rules not in the catalog still open the peek — operators
|
||||||
|
// should be able to correlate unknown rule codes to whatever
|
||||||
|
// they were just looking at. The peek shows an "Unknown rule"
|
||||||
|
// note instead of the catalog text.
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<ValidationPanel
|
||||||
|
validation={makeValidation({
|
||||||
|
passed: false,
|
||||||
|
errors: [
|
||||||
|
makeIssue({ rule: "R999_totally_made_up" }),
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ruleBtn = container.querySelector(
|
||||||
|
'[data-testid="validation-errors-rule-drill"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(ruleBtn).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
ruleBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogs = document.body.querySelectorAll('[role="dialog"]');
|
||||||
|
const peekDialog = Array.from(dialogs).find((d) =>
|
||||||
|
d.textContent?.includes("Validation rule"),
|
||||||
|
);
|
||||||
|
expect(peekDialog).not.toBeNull();
|
||||||
|
expect(peekDialog?.textContent).toContain("R999_totally_made_up");
|
||||||
|
expect(peekDialog?.textContent).toContain("Unknown rule");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// The PeekModal portals to document.body as a Radix dialog. Wipe
|
||||||
|
// any leftover dialogs between tests so the "find dialog by
|
||||||
|
// eyebrow" assertions in the rule-drill tests don't see stale
|
||||||
|
// portals from a prior test.
|
||||||
|
document.body.querySelectorAll('[role="dialog"]').forEach((d) => d.remove());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { AlertCircle, AlertTriangle, CheckCircle2 } from "lucide-react";
|
import { AlertCircle, AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||||
|
import { useDrillStack } from "@/components/drill/DrillStackProvider";
|
||||||
|
import { PeekModal } from "@/components/drill/PeekModal";
|
||||||
|
import { ValidationRulePeekContent } from "@/components/drill/ValidationRulePeekContent";
|
||||||
import type { ClaimDetail } from "@/types";
|
import type { ClaimDetail } from "@/types";
|
||||||
|
|
||||||
type ValidationPanelProps = {
|
type ValidationPanelProps = {
|
||||||
@@ -28,6 +31,13 @@ function groupByRule(issues: IssueList): Array<[string, IssueList]> {
|
|||||||
/**
|
/**
|
||||||
* One rule-group block: header with rule code + count chip, followed by
|
* One rule-group block: header with rule code + count chip, followed by
|
||||||
* the list of messages underneath.
|
* the list of messages underneath.
|
||||||
|
*
|
||||||
|
* SP21 Phase 5 Task 5.9: the rule code is now drillable — clicking it
|
||||||
|
* opens the validation-rule peek on top of the drawer (via the drill
|
||||||
|
* stack). The peek renders ValidationRulePeekContent for the rule
|
||||||
|
* code, falling back to a "unknown rule" note when the catalog has
|
||||||
|
* no entry. Unknown rules still render the peek so operators can
|
||||||
|
* correlate the code to whatever they were just looking at.
|
||||||
*/
|
*/
|
||||||
function IssueGroup({
|
function IssueGroup({
|
||||||
rule,
|
rule,
|
||||||
@@ -43,15 +53,20 @@ function IssueGroup({
|
|||||||
testId === "validation-errors"
|
testId === "validation-errors"
|
||||||
? "text-[color:var(--m-error)]"
|
? "text-[color:var(--m-error)]"
|
||||||
: "text-[color:var(--m-warning)]";
|
: "text-[color:var(--m-warning)]";
|
||||||
|
const { openPeek } = useDrillStack();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<button
|
||||||
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
type="button"
|
||||||
|
onClick={() => openPeek({ kind: "rule", rule })}
|
||||||
|
data-testid={`${testId}-rule-drill`}
|
||||||
|
aria-label={`Drill into rule ${rule}`}
|
||||||
|
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)] cursor-pointer drillable rounded-sm px-0 -mx-0"
|
||||||
>
|
>
|
||||||
{rule}
|
{rule}
|
||||||
</span>
|
</button>
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums"
|
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||||
data-testid={`${testId}-count`}
|
data-testid={`${testId}-count`}
|
||||||
@@ -93,6 +108,11 @@ function IssueGroup({
|
|||||||
*/
|
*/
|
||||||
export function ValidationPanel({ validation }: ValidationPanelProps) {
|
export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||||
const allPassed = validation.passed && validation.warnings.length === 0;
|
const allPassed = validation.passed && validation.warnings.length === 0;
|
||||||
|
// SP21 Phase 5 Task 5.9: peek stack for rule drill. The drill
|
||||||
|
// provider is mounted at the App root; we only read the top entry
|
||||||
|
// here so the peek renders regardless of which section pushed it.
|
||||||
|
const { stack, closeTop } = useDrillStack();
|
||||||
|
const topPeek = stack[stack.length - 1];
|
||||||
|
|
||||||
if (allPassed) {
|
if (allPassed) {
|
||||||
return (
|
return (
|
||||||
@@ -185,6 +205,22 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* SP21 Phase 5 Task 5.9: validation-rule peek. Mounted at the
|
||||||
|
bottom of the panel so the peek (a Radix Dialog portal) sits
|
||||||
|
on top of the drawer. Only renders when the top of the
|
||||||
|
drill stack is a rule peek — payer peek (from PartiesGrid)
|
||||||
|
wins when it's on top because the stack caps at 1 entry. */}
|
||||||
|
{topPeek?.kind === "rule" ? (
|
||||||
|
<PeekModal
|
||||||
|
open
|
||||||
|
onClose={closeTop}
|
||||||
|
eyebrow="Validation rule"
|
||||||
|
title={topPeek.rule}
|
||||||
|
>
|
||||||
|
<ValidationRulePeekContent rule={topPeek.rule} />
|
||||||
|
</PeekModal>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
// @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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended provider fixture (SP21 Task 3.1) — adds the
|
||||||
|
* `recent_claims` and `recent_activity` slices the Claims/Activity
|
||||||
|
* tabs read from. Used by the tabs tests below. Field shapes match
|
||||||
|
* `ClaimSummary` and `ActivityEvent` from `@/types`.
|
||||||
|
*/
|
||||||
|
const SAMPLE_PROVIDER_WITH_DETAILS: Provider = {
|
||||||
|
...SAMPLE_PROVIDER,
|
||||||
|
recent_claims: [
|
||||||
|
{
|
||||||
|
id: "CLM-0001",
|
||||||
|
state: "submitted",
|
||||||
|
billedAmount: 250,
|
||||||
|
patientName: "Jane Q Patient",
|
||||||
|
providerNpi: "1881068062",
|
||||||
|
payerName: "Aetna",
|
||||||
|
cptCode: "99213",
|
||||||
|
submissionDate: "2026-06-20",
|
||||||
|
parsedAt: "2026-06-20",
|
||||||
|
status: "submitted",
|
||||||
|
batchId: "batch-001",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
recent_activity: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
ts: "2026-06-20T15:30:00Z",
|
||||||
|
kind: "claim_submitted",
|
||||||
|
batchId: "batch-001",
|
||||||
|
claimId: "CLM-0001",
|
||||||
|
remittanceId: null,
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
ts: "2026-06-20T15:35:00Z",
|
||||||
|
kind: "claim_paid",
|
||||||
|
batchId: "batch-001",
|
||||||
|
claimId: "CLM-0001",
|
||||||
|
remittanceId: null,
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- Tabs (SP21 Task 3.1) -------------------------------------------------
|
||||||
|
//
|
||||||
|
// Radix Tabs only mounts the active `Tabs.Content` into the DOM (no
|
||||||
|
// `forceMount`), so clicking a tab trigger causes the previous panel to
|
||||||
|
// unmount and the new one to mount. The tests below rely on that —
|
||||||
|
// "panel X renders" is asserted by checking that unique text from that
|
||||||
|
// panel's component is present in `document.body` after the click.
|
||||||
|
|
||||||
|
it("test_renders_three_tabs_overview_claims_activity", () => {
|
||||||
|
mockDetail({ data: SAMPLE_PROVIDER_WITH_DETAILS });
|
||||||
|
render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
|
||||||
|
|
||||||
|
// All three tab triggers are present, with the spec-mandated labels
|
||||||
|
// in the spec-mandated order.
|
||||||
|
const triggers = Array.from(document.querySelectorAll('[role="tab"]'));
|
||||||
|
expect(triggers.length).toBe(3);
|
||||||
|
expect(triggers.map((t) => t.textContent)).toEqual([
|
||||||
|
"Overview",
|
||||||
|
"Claims",
|
||||||
|
"Activity",
|
||||||
|
]);
|
||||||
|
// Overview is the default; its trigger must be selected on mount.
|
||||||
|
expect(triggers[0]?.getAttribute("aria-selected")).toBe("true");
|
||||||
|
expect(triggers[0]?.getAttribute("data-state")).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_switches_to_claims_tab_and_shows_recent_claims", () => {
|
||||||
|
mockDetail({ data: SAMPLE_PROVIDER_WITH_DETAILS });
|
||||||
|
render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
|
||||||
|
|
||||||
|
const claimsTrigger = Array.from(document.querySelectorAll('[role="tab"]'))
|
||||||
|
.find((t) => t.textContent === "Claims");
|
||||||
|
expect(claimsTrigger).not.toBeNull();
|
||||||
|
// Radix Tabs wires `onMouseDown` (not `onClick`) to its
|
||||||
|
// `onValueChange` handler — fire the matching event so the tab
|
||||||
|
// actually activates under happy-dom.
|
||||||
|
fireEvent.mouseDown(claimsTrigger!);
|
||||||
|
|
||||||
|
// Claims tab is now selected — and ProviderRecentClaims content is in
|
||||||
|
// the DOM (claim id + patient name + the "View all claims" link).
|
||||||
|
expect(claimsTrigger?.getAttribute("aria-selected")).toBe("true");
|
||||||
|
expect(document.body.textContent).toContain("CLM-0001");
|
||||||
|
expect(document.body.textContent).toContain("Jane Q Patient");
|
||||||
|
expect(document.body.textContent).toContain("View all claims");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_switches_to_activity_tab_and_shows_recent_activity", () => {
|
||||||
|
mockDetail({ data: SAMPLE_PROVIDER_WITH_DETAILS });
|
||||||
|
render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
|
||||||
|
|
||||||
|
const activityTrigger = Array.from(document.querySelectorAll('[role="tab"]'))
|
||||||
|
.find((t) => t.textContent === "Activity");
|
||||||
|
expect(activityTrigger).not.toBeNull();
|
||||||
|
fireEvent.mouseDown(activityTrigger!);
|
||||||
|
|
||||||
|
// Activity tab is now selected — and ProviderRecentActivity content
|
||||||
|
// is in the DOM (both `kind` strings from the fixture).
|
||||||
|
expect(activityTrigger?.getAttribute("aria-selected")).toBe("true");
|
||||||
|
expect(document.body.textContent).toContain("claim_submitted");
|
||||||
|
expect(document.body.textContent).toContain("claim_paid");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
|
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 { ProviderRecentClaims } from "./ProviderRecentClaims";
|
||||||
|
import { ProviderRecentActivity } from "./ProviderRecentActivity";
|
||||||
|
import { ProviderDrawerError } from "./ProviderDrawerError";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
npi: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider drill-down drawer (SP21 Task 2.2 + 3.1).
|
||||||
|
*
|
||||||
|
* Side-panel shell that consumes ``useProviderDetail(npi)`` and renders
|
||||||
|
* a three-tab body:
|
||||||
|
*
|
||||||
|
* - Overview — base provider fields (identity + activity shape)
|
||||||
|
* - Claims — top-10 claims joined to this provider
|
||||||
|
* (extended `/api/config/providers/{npi}.recent_claims`,
|
||||||
|
* populated by Task 1.6)
|
||||||
|
* - Activity — top-10 events joined to this provider's claims
|
||||||
|
* (extended `/api/config/providers/{npi}.recent_activity`)
|
||||||
|
*
|
||||||
|
* 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}
|
||||||
|
/>
|
||||||
|
<Tabs.Root defaultValue="overview" className="px-6 py-4">
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="claims">Claims</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Content value="overview">
|
||||||
|
<ProviderOverview provider={data} />
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="claims">
|
||||||
|
<ProviderRecentClaims provider={data} />
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="activity">
|
||||||
|
<ProviderRecentActivity provider={data} />
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
</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,34 @@
|
|||||||
|
import type { Provider } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity tab content for the ProviderDrawer (SP21 Task 3.1).
|
||||||
|
*
|
||||||
|
* STUB — renders the top-10 `recent_activity` events as a flat list
|
||||||
|
* (`ts` + `kind`). Real activity-event routing (linking each event to
|
||||||
|
* its source claim/remit/provider) arrives in Phase 4 once
|
||||||
|
* `provider_added` and `remit_received` events get their drawer
|
||||||
|
* surfaces; for now the events aren't drillable from this view —
|
||||||
|
* clicking them does nothing. The ProviderDrawer's Overview already
|
||||||
|
* surfaces the provider's `provider_added` event via the Dashboard
|
||||||
|
* activity feed (Task 2.5).
|
||||||
|
*/
|
||||||
|
export function ProviderRecentActivity({ provider }: { provider: Provider }) {
|
||||||
|
const items = provider.recent_activity ?? [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground text-[13px]">No recent activity.</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1.5 text-[12.5px]">
|
||||||
|
{items.map((a) => (
|
||||||
|
<li key={a.id} className="flex items-center gap-2">
|
||||||
|
<span className="mono text-[10.5px] text-muted-foreground">
|
||||||
|
{new Date(a.ts).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span>{a.kind}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Provider } from "@/types";
|
||||||
|
import { fmt } from "@/lib/format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claims tab content for the ProviderDrawer (SP21 Task 3.1).
|
||||||
|
*
|
||||||
|
* Reads `provider.recent_claims` — the top-10 claims joined to this
|
||||||
|
* provider from the extended `GET /api/config/providers/{npi}` endpoint
|
||||||
|
* (Task 1.6). Renders one row per claim (`id` + `patientName` +
|
||||||
|
* `billedAmount`) with a "View all claims →" link to the global claims
|
||||||
|
* page at the bottom. Falls back to an empty-state line when the
|
||||||
|
* provider has no recent claims (e.g. legacy callers that hit the
|
||||||
|
* detail endpoint without the recent-claims slice).
|
||||||
|
*/
|
||||||
|
export function ProviderRecentClaims({ provider }: { provider: Provider }) {
|
||||||
|
const claims = provider.recent_claims ?? [];
|
||||||
|
if (claims.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground text-[13px]">No recent claims.</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{claims.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="flex items-center gap-3 py-2 border-b border-border/30 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="display mono text-[12.5px] w-32 shrink-0">{c.id}</div>
|
||||||
|
<div className="flex-1 min-w-0 text-[12.5px] text-muted-foreground truncate">
|
||||||
|
{c.patientName ?? "—"}
|
||||||
|
</div>
|
||||||
|
<div className="display mono text-[13px]">
|
||||||
|
{fmt.usd(c.billedAmount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<a href="/claims" className="text-[12.5px] text-accent hover:underline">
|
||||||
|
View all claims →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Barrel export for the ProviderDrawer module (SP21 Task 2.2).
|
||||||
|
|
||||||
|
export { ProviderDrawer } from "./ProviderDrawer";
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
/**
|
||||||
|
* Optional slot for a right-side action (e.g. "Download 999" on the
|
||||||
|
* AckDrawer, "Download 837" on the ClaimDrawer — added in SP21
|
||||||
|
* Phase 5 Task 5.2/5.10). Rendered to the left of the close
|
||||||
|
* button with a small visual gap. Anything goes — a button, a
|
||||||
|
* status pill, an icon link. Default `null` so existing callers
|
||||||
|
* (ProviderDrawer) render unchanged.
|
||||||
|
*/
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, action }: 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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{action}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,4 +34,27 @@ describe("DrillableCell", () => {
|
|||||||
expect(btn.disabled).toBe(true);
|
expect(btn.disabled).toBe(true);
|
||||||
expect(btn.classList.contains("drillable")).toBe(false);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { MouseEvent, ReactNode } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
onClick: () => void;
|
/**
|
||||||
|
* 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;
|
disabled?: boolean;
|
||||||
/** Optional aria-label; defaults to the visible text content. */
|
/** Optional aria-label; defaults to the visible text content. */
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
@@ -18,12 +24,21 @@ interface Props {
|
|||||||
* Renders as a <button> (disabled when `disabled`) so it gets keyboard
|
* Renders as a <button> (disabled when `disabled`) so it gets keyboard
|
||||||
* activation (Enter/Space) and the standard disabled-button semantics.
|
* activation (Enter/Space) and the standard disabled-button semantics.
|
||||||
* The `drillable` affordance class is omitted when disabled.
|
* 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) {
|
export function DrillableCell({ children, onClick, disabled, ariaLabel }: Props) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick(e);
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
interface Props {
|
||||||
|
/** The rule code (e.g. "R050_diagnosis_present" or just "R050"). */
|
||||||
|
rule: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuleDoc {
|
||||||
|
/** Short human title (e.g. "Diagnosis pointer present"). */
|
||||||
|
title: string;
|
||||||
|
/** Plain-English description of what the rule checks. */
|
||||||
|
description: string;
|
||||||
|
/** Why the rule matters — operator-facing rationale. */
|
||||||
|
whyItMatters: string;
|
||||||
|
/** How to fix — short, actionable. */
|
||||||
|
howToFix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SP21 Phase 5 Task 5.9: rule catalog used by ValidationRulePeekContent.
|
||||||
|
*
|
||||||
|
* The catalog is intentionally small — it covers the rules we actually
|
||||||
|
* emit today (R050_diagnosis_present, R200_units_recommended). For
|
||||||
|
* anything not in the catalog the peek still renders (with an "Unknown
|
||||||
|
* rule" note) — operators should still be able to open the peek for
|
||||||
|
* any rule code so they can see the originating message verbatim.
|
||||||
|
*
|
||||||
|
* Adding a new entry here is the source-of-truth for the rule's
|
||||||
|
* documentation. The ValidationPanel wires the peek by rule code; if
|
||||||
|
* we add new rules later (Phase 6+), add a new entry here.
|
||||||
|
*/
|
||||||
|
const RULE_CATALOG: Record<string, RuleDoc> = {
|
||||||
|
R050_diagnosis_present: {
|
||||||
|
title: "Diagnosis pointer present",
|
||||||
|
description:
|
||||||
|
"Each service line must point to at least one diagnosis code in the claim header (the HL segment's HI element). A missing pointer makes the line unprocessable on the payer side.",
|
||||||
|
whyItMatters:
|
||||||
|
"Payers reject claims with missing diagnosis pointers at the 999 stage, which would otherwise re-trigger the 999 rejection loop. Catching it here gives the operator a chance to attach the dx before submission.",
|
||||||
|
howToFix:
|
||||||
|
"Open the claim's Service Lines table and attach the relevant diagnosis code (e.g. E11.9) to the line. The pointer is the line's diagnosis pointer list.",
|
||||||
|
},
|
||||||
|
R200_units_recommended: {
|
||||||
|
title: "Service line units recommended",
|
||||||
|
description:
|
||||||
|
"Service lines that represent timed procedures (anesthesia, critical care, psychotherapy time-based codes) should carry an explicit units value. Defaulting to 1 is acceptable for most codes but flagged here for review.",
|
||||||
|
whyItMatters:
|
||||||
|
"Timed codes without units get under-reimbursed — payers default to 1 unit when the field is blank, even when the procedure took 45 minutes. The warning exists so an operator can verify the units are correct before submission.",
|
||||||
|
howToFix:
|
||||||
|
"Confirm the units value on the service line matches the documented encounter time. If the code is not time-based, no action is required.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peek body for a validation rule — opens on top of the ClaimDrawer
|
||||||
|
* via PeekModal when the operator clicks a rule code in the
|
||||||
|
* ValidationPanel. The body shows the rule's title, description, why
|
||||||
|
* it matters, and how to fix it.
|
||||||
|
*
|
||||||
|
* Unknown rules (codes not in the catalog) render a small "Unknown
|
||||||
|
* rule — see originating message" note rather than blowing up. The
|
||||||
|
* peek still renders so the operator can correlate the rule code to
|
||||||
|
* whatever they were just looking at.
|
||||||
|
*
|
||||||
|
* No fetch — the catalog is static and bundled. (A future phase
|
||||||
|
* could swap this for a backend-served catalog if rules become
|
||||||
|
* user-extensible.)
|
||||||
|
*/
|
||||||
|
export function ValidationRulePeekContent({ rule }: Props) {
|
||||||
|
// Normalize: the rule code in the validation payload is the full
|
||||||
|
// form (`R050_diagnosis_present`), but a future backend response
|
||||||
|
// might use the short form (`R050`). Look up both.
|
||||||
|
const doc =
|
||||||
|
RULE_CATALOG[rule] ??
|
||||||
|
RULE_CATALOG[rule.split("_")[0] ?? ""] ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="display text-[14px] text-foreground">
|
||||||
|
{rule}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-[12.5px]"
|
||||||
|
style={{ color: "hsl(var(--muted-foreground))" }}
|
||||||
|
>
|
||||||
|
Unknown rule. The originating message is the authoritative
|
||||||
|
description — this peek is a no-op for undocumented rule codes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="display text-[15px] text-foreground">
|
||||||
|
{doc.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mono text-[11px]"
|
||||||
|
style={{ color: "hsl(var(--muted-foreground))" }}
|
||||||
|
>
|
||||||
|
{rule}
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] leading-relaxed text-[color:var(--m-ink-primary)]">
|
||||||
|
{doc.description}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 pt-1">
|
||||||
|
<Section heading="Why it matters">{doc.whyItMatters}</Section>
|
||||||
|
<Section heading="How to fix">{doc.howToFix}</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
heading,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
heading: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-0.5"
|
||||||
|
style={{ color: "hsl(var(--muted-foreground))" }}
|
||||||
|
>
|
||||||
|
{heading}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12.5px] leading-relaxed text-[color:var(--m-ink-secondary)]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+75
-34
@@ -1,87 +1,128 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
// ---------------------------------------------------------------------------
|
||||||
({ className, ...props }, ref) => (
|
// Table
|
||||||
<div className="relative w-full overflow-auto">
|
//
|
||||||
<table
|
// Shared table primitive used by Claims, Remittances, Batches, Acks, etc.
|
||||||
ref={ref}
|
//
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
// `tone="paper"` swaps the dark-mode row chrome (muted/20 header, muted/30
|
||||||
{...props}
|
// 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>
|
</div>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Table.displayName = "Table";
|
Table.displayName = "Table";
|
||||||
|
|
||||||
const TableHeader = React.forwardRef<
|
type TableSectionProps = React.HTMLAttributes<HTMLTableSectionElement>;
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
const TableHeader = React.forwardRef<HTMLTableSectionElement, TableSectionProps>(
|
||||||
>(({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => {
|
||||||
|
// Paper-tone: cream-papered header band, soft border, no dark muted fill.
|
||||||
|
return (
|
||||||
<thead
|
<thead
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"[&_tr]:border-b [&_tr]:border-border/60 [&_tr]:bg-muted/20",
|
"[&_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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
TableHeader.displayName = "TableHeader";
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
const TableBody = React.forwardRef<
|
const TableBody = React.forwardRef<HTMLTableSectionElement, TableSectionProps>(
|
||||||
HTMLTableSectionElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tbody
|
<tbody
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
)
|
||||||
|
);
|
||||||
TableBody.displayName = "TableBody";
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
const TableRow = React.forwardRef<
|
type TableRowProps = React.HTMLAttributes<HTMLTableRowElement>;
|
||||||
HTMLTableRowElement,
|
|
||||||
React.HTMLAttributes<HTMLTableRowElement>
|
const TableRow = React.forwardRef<HTMLTableRowElement, TableRowProps>(
|
||||||
>(({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b border-border/40 transition-colors hover:bg-muted/30 focus-within:bg-muted/40 data-[state=selected]:bg-muted/50",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
)
|
||||||
|
);
|
||||||
TableRow.displayName = "TableRow";
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
const TableHead = React.forwardRef<
|
type TableHeadProps = React.ThHTMLAttributes<HTMLTableCellElement>;
|
||||||
HTMLTableCellElement,
|
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
const TableHead = React.forwardRef<HTMLTableCellElement, TableHeadProps>(
|
||||||
>(({ className, scope = "col", ...props }, ref) => (
|
({ className, scope = "col", ...props }, ref) => (
|
||||||
<th
|
<th
|
||||||
ref={ref}
|
ref={ref}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
)
|
||||||
|
);
|
||||||
TableHead.displayName = "TableHead";
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
const TableCell = React.forwardRef<
|
const TableCell = React.forwardRef<HTMLTableCellElement, TableHeadProps>(
|
||||||
HTMLTableCellElement,
|
({ className, ...props }, ref) => (
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<td
|
<td
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
)
|
||||||
|
);
|
||||||
TableCell.displayName = "TableCell";
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||||
|
export type { Tone as TableTone };
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tabs primitive — SP21 Universal Drill-Down (Task 3.1).
|
||||||
|
*
|
||||||
|
* Thin wrapper over `@radix-ui/react-tabs`. Mirrors the same re-export
|
||||||
|
* pattern used by `dialog.tsx`: the individual primitives are forwardRef'd
|
||||||
|
* styled components, and the `Tabs` namespace object lets callers use
|
||||||
|
* the spec's `<Tabs.Root>`, `<Tabs.List>`, `<Tabs.Trigger>`,
|
||||||
|
* `<Tabs.Content>` dot-notation directly:
|
||||||
|
*
|
||||||
|
* import { Tabs } from "@/components/ui/tabs";
|
||||||
|
* <Tabs.Root defaultValue="overview">
|
||||||
|
* <Tabs.List>
|
||||||
|
* <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
|
||||||
|
* ...
|
||||||
|
* </Tabs.List>
|
||||||
|
* <Tabs.Content value="overview">...</Tabs.Content>
|
||||||
|
* </Tabs.Root>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex gap-2 border-b border-border/30 mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 text-[12.5px] text-muted-foreground",
|
||||||
|
"data-[state=active]:text-foreground",
|
||||||
|
"data-[state=active]:border-b-2 data-[state=active]:border-accent",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Namespace export matching the spec's `import * as Tabs from ...`
|
||||||
|
* dot-notation. `Root` is the unstyled Radix primitive; the rest are the
|
||||||
|
* styled wrappers above.
|
||||||
|
*/
|
||||||
|
export const Tabs = {
|
||||||
|
Root: TabsPrimitive.Root,
|
||||||
|
List: TabsList,
|
||||||
|
Trigger: TabsTrigger,
|
||||||
|
Content: TabsContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -164,6 +164,12 @@ function buildActivity(claims: Claim[]): Activity[] {
|
|||||||
timestamp: c.submissionDate,
|
timestamp: c.submissionDate,
|
||||||
npi: c.providerNpi,
|
npi: c.providerNpi,
|
||||||
amount: c.billedAmount,
|
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(
|
return events.sort(
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api, ApiError } from "@/lib/api";
|
||||||
|
import type { Ack } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI-facing ack detail shape returned by `GET /api/acks/{id}`.
|
||||||
|
*
|
||||||
|
* Extends the base `Ack` shape with the `raw_999_text` field that
|
||||||
|
* `api.getAck` populates on top of the canonical row. The download
|
||||||
|
* button inside `AckDrawer` reads this string to hand the user the
|
||||||
|
* regenerated X12 file.
|
||||||
|
*/
|
||||||
|
export interface AckDetail extends Ack {
|
||||||
|
/**
|
||||||
|
* Full regenerated 999 X12 text. The backend re-emits the parsed
|
||||||
|
* transaction set so a user can grab the original file from the
|
||||||
|
* drawer without a second round-trip to `/api/acks/{id}/raw`.
|
||||||
|
*/
|
||||||
|
raw_999_text?: string;
|
||||||
|
/**
|
||||||
|
* Raw JSON envelope captured by the parser (the same dict the
|
||||||
|
* parser wrote into `raw_json`). Optional so older rows without
|
||||||
|
* it still typecheck.
|
||||||
|
*/
|
||||||
|
rawJson?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-ack detail drawer query (AckDrawer · SP21 Phase 5 Task 5.2).
|
||||||
|
*
|
||||||
|
* Twin of `useProviderDetail` and `useClaimDetail` — same return
|
||||||
|
* shape, same retry semantics, no in-memory fallback (the spec §5.2
|
||||||
|
* calls out that ACKs are backend-only; `useAcks` has no sample-data
|
||||||
|
* path so there's nothing to fall back on).
|
||||||
|
*
|
||||||
|
* Returns `{ data, isLoading, isError, error, refetch }`:
|
||||||
|
* - `ackId === 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.
|
||||||
|
* - `ackId` is set: fetches `GET /api/acks/{id}` via `api.getAck`.
|
||||||
|
* Cached 60 s — the underlying ack rows don't change after
|
||||||
|
* parse-time.
|
||||||
|
* - 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.
|
||||||
|
*
|
||||||
|
* The drawer accepts `ackId: string | null` (the URL keeps the id as
|
||||||
|
* a string for clean deep-link round-tripping) and does the
|
||||||
|
* `Number()` coercion here, matching how `useProviderDetail`
|
||||||
|
* (`string`) and `useRemitDetail` (`string`) already work.
|
||||||
|
*/
|
||||||
|
export function useAckDetail(ackId: string | null): {
|
||||||
|
data: AckDetail | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => void;
|
||||||
|
} {
|
||||||
|
const q = useQuery<AckDetail>({
|
||||||
|
queryKey: ["ack-detail", ackId],
|
||||||
|
queryFn: () => api.getAck(Number(ackId)),
|
||||||
|
enabled: ackId !== null,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
retry: (failureCount, error) => {
|
||||||
|
if (error instanceof ApiError && error.status === 404) return false;
|
||||||
|
return failureCount < 3;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ackId === 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,219 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// Mirror the IS_REACT_ACT_ENVIRONMENT setup from useProviderDrawerUrlState.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 { useAckDrawerUrlState } from "./useAckDrawerUrlState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `/acks?ack=42` etc.
|
||||||
|
*/
|
||||||
|
function setLocation(url: string): void {
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useAckDrawerUrlState", () => {
|
||||||
|
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 ?ack= param from window.location.search on mount", () => {
|
||||||
|
setLocation("http://localhost/acks?ack=42");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
expect(result.current?.ackId).toBe("42");
|
||||||
|
expect(typeof result.current?.open).toBe("function");
|
||||||
|
expect(typeof result.current?.close).toBe("function");
|
||||||
|
expect(typeof result.current?.setAckId).toBe("function");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null ackId when no ?ack= param is set", () => {
|
||||||
|
setLocation("http://localhost/acks");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
expect(result.current?.ackId).toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null ackId when ?ack= is present but empty", () => {
|
||||||
|
setLocation("http://localhost/acks?ack=");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
expect(result.current?.ackId).toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("open(id) pushes a new history entry containing ?ack=ID", () => {
|
||||||
|
setLocation("http://localhost/acks");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current?.open("42");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pushStateMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replaceStateMock).not.toHaveBeenCalled();
|
||||||
|
const urlArg = pushStateMock.mock.calls[0][2] as string;
|
||||||
|
expect(urlArg).toContain("?ack=42");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setAckId(id) replaces the current history entry (no new entry) and does NOT pushState", () => {
|
||||||
|
setLocation("http://localhost/acks?ack=42");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current?.setAckId("43");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replaceStateMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pushStateMock).not.toHaveBeenCalled();
|
||||||
|
const urlArg = replaceStateMock.mock.calls[0][2] as string;
|
||||||
|
expect(urlArg).toContain("?ack=43");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("open() and close() preserve other query params (only ?ack= is touched)", () => {
|
||||||
|
setLocation("http://localhost/acks?sort=date&ack=42");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current?.open("43");
|
||||||
|
});
|
||||||
|
const openUrl = pushStateMock.mock.calls[0][2] as string;
|
||||||
|
expect(openUrl).toContain("sort=date");
|
||||||
|
expect(openUrl).toMatch(/[?&]ack=43/);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current?.close();
|
||||||
|
});
|
||||||
|
const closeUrl = pushStateMock.mock.calls[1][2] as string;
|
||||||
|
expect(closeUrl).toContain("sort=date");
|
||||||
|
expect(closeUrl).not.toContain("ack=");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close() pushes a new history entry with the ?ack= param stripped", () => {
|
||||||
|
setLocation("http://localhost/acks?ack=42");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
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("?ack=");
|
||||||
|
expect(result.current?.ackId).toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates ackId in response to popstate (browser back/forward)", async () => {
|
||||||
|
setLocation("http://localhost/acks?ack=42");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
expect(result.current?.ackId).toBe("42");
|
||||||
|
|
||||||
|
setLocation("http://localhost/acks?ack=43");
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current?.ackId).toBe("43");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not collide with the existing ?claim=, ?remit=, or ?provider= params (orthogonal keys)", () => {
|
||||||
|
// The acks drawer is independent of the claim/remit/provider
|
||||||
|
// drawers — opening an ack must leave the others intact so a user
|
||||||
|
// can deep-link to multiple states simultaneously (though the UI
|
||||||
|
// currently only shows one drawer at a time, the URL params don't
|
||||||
|
// know that).
|
||||||
|
setLocation("http://localhost/?claim=CLM-1&remit=REM-1&provider=1881068062");
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current?.open("42");
|
||||||
|
});
|
||||||
|
const openUrl = pushStateMock.mock.calls[0][2] as string;
|
||||||
|
expect(openUrl).toContain("claim=CLM-1");
|
||||||
|
expect(openUrl).toContain("remit=REM-1");
|
||||||
|
expect(openUrl).toContain("provider=1881068062");
|
||||||
|
expect(openUrl).toMatch(/[?&]ack=42/);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current `?ack=…` 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 ack ids without surprises.
|
||||||
|
*
|
||||||
|
* Param name is `?ack=` — chosen to mirror the existing `?provider=…`,
|
||||||
|
* `?claim=…`, and `?remit=…` drilldown conventions (one-word token,
|
||||||
|
* alphabetical brevity, no collision with the existing `Acks` table
|
||||||
|
* columns). The id value is treated as a string in the URL so deep
|
||||||
|
* links (`/acks?ack=42`) round-trip identically across the app, even
|
||||||
|
* though the backend `Ack.id` is a numeric — `AckDrawer` does the
|
||||||
|
* `Number()` coercion when calling `api.getAck`.
|
||||||
|
*/
|
||||||
|
function readAckId(): string | null {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const value = params.get("ack");
|
||||||
|
return value === "" ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the URL we want to push/replace into history.
|
||||||
|
*
|
||||||
|
* - `ackId === null` → drop the `?ack=` param, preserving any
|
||||||
|
* other params (e.g. `?page=2&ack=…` keeps `page=2`).
|
||||||
|
* - `ackId !== null` → set the param to the new id, 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(ackId: string | null): string {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (ackId === null) {
|
||||||
|
url.searchParams.delete("ack");
|
||||||
|
} else {
|
||||||
|
url.searchParams.set("ack", ackId);
|
||||||
|
}
|
||||||
|
return url.pathname + url.search + url.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-ack detail drawer URL state (AckDrawer).
|
||||||
|
*
|
||||||
|
* Mirrors `useProviderDrawerUrlState` / `useRemitDrawerUrlState` but
|
||||||
|
* for the 999-ACK drawer — reads `?ack=` from the URL on mount and
|
||||||
|
* keeps the value in sync with history as the drawer is opened and
|
||||||
|
* closed.
|
||||||
|
*
|
||||||
|
* - `ackId`: the id parsed from the URL (or `null` when the param
|
||||||
|
* is absent). React state so consumers re-render on changes.
|
||||||
|
* - `open(id)`: pushes a NEW history entry with `?ack={id}` — so the
|
||||||
|
* browser Back button returns to the previous page (e.g. the
|
||||||
|
* acks list) and not just to the previously-open ack.
|
||||||
|
* - `setAckId(id)`: REPLACES the current history entry — kept for
|
||||||
|
* symmetry with the other drawer hooks so a future j/k nav
|
||||||
|
* handler doesn't have to special-case ack ids.
|
||||||
|
* - `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 id.
|
||||||
|
*/
|
||||||
|
export function useAckDrawerUrlState(): {
|
||||||
|
ackId: string | null;
|
||||||
|
open: (id: string) => void;
|
||||||
|
close: () => void;
|
||||||
|
setAckId: (id: string) => void;
|
||||||
|
} {
|
||||||
|
const [ackId, setAckIdState] = useState<string | null>(() => readAckId());
|
||||||
|
|
||||||
|
const open = useCallback((id: string) => {
|
||||||
|
window.history.pushState(null, "", buildUrl(id));
|
||||||
|
setAckIdState(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAckId = useCallback((id: string) => {
|
||||||
|
window.history.replaceState(null, "", buildUrl(id));
|
||||||
|
setAckIdState(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
window.history.pushState(null, "", buildUrl(null));
|
||||||
|
setAckIdState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPopState = () => {
|
||||||
|
setAckIdState(readAckId());
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", onPopState);
|
||||||
|
return () => window.removeEventListener("popstate", onPopState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ackId, open, close, setAckId };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
+31
-1
@@ -16,7 +16,8 @@
|
|||||||
* - `parse835(...)` mirrors the 837 shape for `/api/parse-835`.
|
* - `parse835(...)` mirrors the 837 shape for `/api/parse-835`.
|
||||||
* - `health()` GETs `/api/health` and returns `{ status, version }`.
|
* - `health()` GETs `/api/health` and returns `{ status, version }`.
|
||||||
* - `listBatches / getBatch / listClaims / listRemittances / listProviders
|
* - `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
|
* - `listUnmatched / matchRemit / unmatchClaim` hit the reconciliation
|
||||||
* surface. POSTs throw `ApiError` so callers can branch on `.status`.
|
* surface. POSTs throw `ApiError` so callers can branch on `.status`.
|
||||||
*/
|
*/
|
||||||
@@ -36,6 +37,7 @@ import type {
|
|||||||
Payer,
|
Payer,
|
||||||
Payer835,
|
Payer835,
|
||||||
Payee835,
|
Payee835,
|
||||||
|
Provider,
|
||||||
ReassociationTrace,
|
ReassociationTrace,
|
||||||
UnmatchedClaim,
|
UnmatchedClaim,
|
||||||
UnmatchedResponse,
|
UnmatchedResponse,
|
||||||
@@ -586,6 +588,33 @@ async function listProviders<T = unknown>(
|
|||||||
return (await res.json()) as PaginatedResponse<T>;
|
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 /
|
* Aggregate stats for one payer, used by the peek modal on Dashboard /
|
||||||
* Claims tables (SP21 universal drill-down). Drives
|
* Claims tables (SP21 universal drill-down). Drives
|
||||||
@@ -773,6 +802,7 @@ export const api = {
|
|||||||
listRemittances,
|
listRemittances,
|
||||||
getRemittance,
|
getRemittance,
|
||||||
listProviders,
|
listProviders,
|
||||||
|
getProvider,
|
||||||
getPayerSummary,
|
getPayerSummary,
|
||||||
listActivity,
|
listActivity,
|
||||||
listUnmatched,
|
listUnmatched,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
-2
@@ -9,13 +9,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { Acks } from "./Acks";
|
import { Acks } from "./Acks";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
vi.mock("@/lib/api", () => ({
|
vi.mock("@/lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
api: {
|
api: {
|
||||||
isConfigured: true,
|
isConfigured: true,
|
||||||
listAcks: vi.fn(),
|
listAcks: vi.fn(),
|
||||||
getAck: vi.fn(),
|
getAck: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function renderIntoContainer(element: React.ReactElement): {
|
function renderIntoContainer(element: React.ReactElement): {
|
||||||
container: HTMLDivElement;
|
container: HTMLDivElement;
|
||||||
@@ -105,6 +109,10 @@ function hasExactlyOneSelectedRow(): boolean {
|
|||||||
describe("Acks", () => {
|
describe("Acks", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Reset URL state between tests so a previous `?ack=` doesn't leak.
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
|
||||||
|
"http://localhost/acks"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a single ack row with counts and ack code", async () => {
|
it("renders a single ack row with counts and ack code", async () => {
|
||||||
@@ -448,4 +456,115 @@ describe("Acks", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("test_clicking_a_row_opens_the_ack_drawer", async () => {
|
||||||
|
// SP21 Phase 5 Task 5.3: clicking an acks row drills into the
|
||||||
|
// matching ack via `?ack=ID` URL state. The AckDrawer mounts
|
||||||
|
// but the actual content depends on `useAckDetail` — we don't
|
||||||
|
// need to verify drawer internals here, just that the URL got
|
||||||
|
// pushed and the drawer portal opens.
|
||||||
|
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
sourceBatchId: "b-uuid-1",
|
||||||
|
acceptedCount: 3,
|
||||||
|
rejectedCount: 1,
|
||||||
|
receivedCount: 4,
|
||||||
|
ackCode: "P",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
returned: 1,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
// Stub the per-ack fetch so `useAckDetail` resolves cleanly
|
||||||
|
// (avoids TanStack Query's "Query data cannot be undefined"
|
||||||
|
// warning). We only assert on the drawer's presence, so the
|
||||||
|
// shape doesn't need to be precise.
|
||||||
|
(api.getAck as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: 42,
|
||||||
|
sourceBatchId: "b-uuid-1",
|
||||||
|
acceptedCount: 3,
|
||||||
|
rejectedCount: 1,
|
||||||
|
receivedCount: 4,
|
||||||
|
ackCode: "P",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
raw_999_text: "ISA*~\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||||
|
await waitForText("b-uuid-1");
|
||||||
|
|
||||||
|
// No drawer yet.
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="ack-drawer"]')
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
// Click the row.
|
||||||
|
const row = rowAt(0);
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
row!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
await settle(
|
||||||
|
() => document.body.querySelector('[data-testid="ack-drawer"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
// The drawer is now in the DOM.
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="ack-drawer"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_deep_link_with_ack_param_opens_drawer_on_mount", async () => {
|
||||||
|
// /acks?ack=42 deep link → drawer opens on mount without a click.
|
||||||
|
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
sourceBatchId: "b-uuid-1",
|
||||||
|
acceptedCount: 3,
|
||||||
|
rejectedCount: 1,
|
||||||
|
receivedCount: 4,
|
||||||
|
ackCode: "P",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
returned: 1,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
(api.getAck as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: 42,
|
||||||
|
sourceBatchId: "b-uuid-1",
|
||||||
|
acceptedCount: 3,
|
||||||
|
rejectedCount: 1,
|
||||||
|
receivedCount: 4,
|
||||||
|
ackCode: "P",
|
||||||
|
parsedAt: "2026-06-20T12:00:00Z",
|
||||||
|
raw_999_text: "ISA*~\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
|
||||||
|
"http://localhost/acks?ack=42"
|
||||||
|
);
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||||
|
await waitForText("b-uuid-1");
|
||||||
|
await settle(
|
||||||
|
() => document.body.querySelector('[data-testid="ack-drawer"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
// The drawer is in the DOM on first render.
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="ack-drawer"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+22
-1
@@ -12,6 +12,8 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { ErrorState } from "@/components/ui/error-state";
|
import { ErrorState } from "@/components/ui/error-state";
|
||||||
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
||||||
|
import { AckDrawer } from "@/components/AckDrawer";
|
||||||
|
import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState";
|
||||||
import { useAcks } from "@/hooks/useAcks";
|
import { useAcks } from "@/hooks/useAcks";
|
||||||
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
@@ -91,7 +93,14 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={(e) => {
|
||||||
|
// The row's onClick drills into AckDrawer; this button is a
|
||||||
|
// nested control and we want the click to NOT bubble into the
|
||||||
|
// row's onClick handler (matches the precedent set by
|
||||||
|
// DrillableCell onClick in `src/components/drill/DrillableCell.tsx:39`).
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"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",
|
||||||
@@ -113,6 +122,10 @@ function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: stri
|
|||||||
export function Acks() {
|
export function Acks() {
|
||||||
const { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 });
|
const { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 });
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
|
// SP21 Phase 5 Task 5.3: drill-down from an acks row into
|
||||||
|
// AckDrawer. The hook reads `?ack=` off `window.location.search`
|
||||||
|
// so deep links restore the open ack on reload.
|
||||||
|
const { ackId, open, close } = useAckDrawerUrlState();
|
||||||
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
@@ -172,6 +185,12 @@ export function Acks() {
|
|||||||
open={helpOpen}
|
open={helpOpen}
|
||||||
onClose={() => setHelpOpen(false)}
|
onClose={() => setHelpOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
{/* SP21 Phase 5 Task 5.3: AckDrawer mount. Row click drills
|
||||||
|
into the matching ack; the drawer portals into document.body
|
||||||
|
(Radix Dialog), so the surrounding paper plane stays put
|
||||||
|
while the drawer is open. Deep links via /acks?ack=ID
|
||||||
|
restore the open ack on reload. */}
|
||||||
|
<AckDrawer ackId={ackId} onClose={close} />
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{/* =================================================================
|
{/* =================================================================
|
||||||
HERO — DARK EDITORIAL HEADER
|
HERO — DARK EDITORIAL HEADER
|
||||||
@@ -650,7 +669,9 @@ export function Acks() {
|
|||||||
data-row-index={idx}
|
data-row-index={idx}
|
||||||
data-state={isSelected ? "selected" : undefined}
|
data-state={isSelected ? "selected" : undefined}
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
|
onClick={() => open(String(a.id))}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
isSelected && [
|
isSelected && [
|
||||||
"ring-1 ring-inset",
|
"ring-1 ring-inset",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ vi.mock("@/lib/api", () => ({
|
|||||||
api: {
|
api: {
|
||||||
isConfigured: true,
|
isConfigured: true,
|
||||||
listActivity: vi.fn(),
|
listActivity: vi.fn(),
|
||||||
|
getRemittance: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -158,6 +159,14 @@ describe("ActivityLog page filters", () => {
|
|||||||
(api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
(api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
EMPTY,
|
EMPTY,
|
||||||
);
|
);
|
||||||
|
// Default for the per-remit detail fetch — the drawer fetches
|
||||||
|
// this whenever `?remit=` is in the URL or `remit_received`
|
||||||
|
// events drill in. Return a never-resolving promise so the
|
||||||
|
// drawer stays in the loading state; the smoke tests only
|
||||||
|
// assert the drawer mounts.
|
||||||
|
(
|
||||||
|
api.getRemittance as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(new Promise(() => {}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("test_renders_filter_controls_when_mounted", async () => {
|
it("test_renders_filter_controls_when_mounted", async () => {
|
||||||
@@ -447,3 +456,104 @@ describe("ActivityLog page filters", () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SP21 Task 4.7: ActivityLog → RemitDrawer wiring", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
(api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
|
EMPTY,
|
||||||
|
);
|
||||||
|
(
|
||||||
|
api.getRemittance as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(new Promise(() => {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a remit_received event opens the RemitDrawer", async () => {
|
||||||
|
(api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "A-1",
|
||||||
|
kind: "remit_received",
|
||||||
|
message: "Remit PCN-1 received",
|
||||||
|
timestamp: "2026-06-20T10:00:00Z",
|
||||||
|
remittanceId: "REM-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
returned: 1,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderActivity();
|
||||||
|
|
||||||
|
// Wait for the row to render so we can click it.
|
||||||
|
await settle(() =>
|
||||||
|
document.body.textContent?.includes("Remit PCN-1 received") ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drawer should not be mounted before the click.
|
||||||
|
expect(document.body.querySelector('[data-testid="remit-drawer"]')).toBeNull();
|
||||||
|
|
||||||
|
// Find the <li role="button"> row and click it. ActivityFeed renders
|
||||||
|
// each row as a button-role <li> when `onItemClick` is provided, with
|
||||||
|
// an aria-label like "View remit received: <message>".
|
||||||
|
const row = document.body.querySelector('[aria-label^="View remit received"]') as
|
||||||
|
| HTMLLIElement
|
||||||
|
| null;
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
row!.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// After click, the RemitDrawer should be mounted (with the skeleton,
|
||||||
|
// since the never-resolving getRemittance keeps it in loading state).
|
||||||
|
await settle(
|
||||||
|
() => document.body.querySelector('[data-testid="remit-drawer"]') !== null,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="remit-drawer"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
|
||||||
|
(api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "A-1",
|
||||||
|
kind: "remit_received",
|
||||||
|
message: "Remit PCN-1 received",
|
||||||
|
timestamp: "2026-06-20T10:00:00Z",
|
||||||
|
remittanceId: "REM-7",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
returned: 1,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The hook reads `?remit=` from `window.location.search`, so set
|
||||||
|
// BOTH the MemoryRouter initial entry (for the page's URL display)
|
||||||
|
// AND `window.happyDOM.setURL` (for the hook).
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
|
||||||
|
.happyDOM.setURL("http://localhost/activity?remit=REM-7");
|
||||||
|
|
||||||
|
const { unmount } = renderActivity({
|
||||||
|
initialEntries: ["/activity?remit=REM-7"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await settle(
|
||||||
|
() => document.body.querySelector('[data-testid="remit-drawer"]') !== null,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="remit-drawer"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// Reset URL so any subsequent tests see a clean /activity URL.
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
|
||||||
|
.happyDOM.setURL("http://localhost/activity");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useActivity } from "@/hooks/useActivity";
|
import { useActivity } from "@/hooks/useActivity";
|
||||||
import { useTailStream } from "@/hooks/useTailStream";
|
import { useTailStream } from "@/hooks/useTailStream";
|
||||||
import { useMergedTail } from "@/hooks/useMergedTail";
|
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||||
|
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||||
|
import { eventKindToUrl } from "@/lib/event-routing";
|
||||||
import { TailStatusPill } from "@/components/TailStatusPill";
|
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
import { ActivityFeed } from "@/components/ActivityFeed";
|
import { ActivityFeed } from "@/components/ActivityFeed";
|
||||||
import { ActivityFilters, type SinceValue } from "@/components/ActivityFilters";
|
import { ActivityFilters, type SinceValue } from "@/components/ActivityFilters";
|
||||||
|
import { RemitDrawer } from "@/components/RemitDrawer";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { ErrorState } from "@/components/ui/error-state";
|
import { ErrorState } from "@/components/ui/error-state";
|
||||||
@@ -35,6 +39,13 @@ function useSinceIso(since: SinceValue): string | undefined {
|
|||||||
|
|
||||||
export function ActivityLog() {
|
export function ActivityLog() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
// SP21 Phase 4 Task 4.7: `remit_received` events with a
|
||||||
|
// remittanceId drill into the RemitDrawer. Calling `open(id)`
|
||||||
|
// pushes `?remit=ID` onto the current URL (no navigation away
|
||||||
|
// from `/activity`), so the activity feed stays visible behind
|
||||||
|
// the drawer and the drawer portals in over it.
|
||||||
|
const { remitId, open, close } = useRemitDrawerUrlState();
|
||||||
|
|
||||||
const selectedKinds = useMemo<ActivityKind[]>(
|
const selectedKinds = useMemo<ActivityKind[]>(
|
||||||
() =>
|
() =>
|
||||||
@@ -163,9 +174,48 @@ export function ActivityLog() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ActivityFeed items={items} emptyMessage="No activity recorded yet." />
|
<ActivityFeed
|
||||||
|
items={items}
|
||||||
|
emptyMessage="No activity recorded yet."
|
||||||
|
onItemClick={(evt) => {
|
||||||
|
// SP21 Phase 4 Task 4.7: drill into the right surface
|
||||||
|
// based on event kind. `claim_*` and `provider_added`
|
||||||
|
// navigate away via `eventKindToUrl` (the Dashboard uses
|
||||||
|
// the same helper). `remit_received` events stay on
|
||||||
|
// `/activity` and open the RemitDrawer via `open(id)`
|
||||||
|
// — `eventKindToUrl` still returns `null` for that kind
|
||||||
|
// because cross-page navigation isn't the right UX here
|
||||||
|
// (we want to keep the activity feed as context behind
|
||||||
|
// the drawer). Anything else falls back to the
|
||||||
|
// "coming soon" toast so the click still gives feedback.
|
||||||
|
const url = eventKindToUrl(evt);
|
||||||
|
if (url) navigate(url);
|
||||||
|
else if (evt.kind === "remit_received" && evt.remittanceId) {
|
||||||
|
open(evt.remittanceId);
|
||||||
|
} else {
|
||||||
|
toast.info(
|
||||||
|
`Drill for ${evt.kind.replace(/_/g, " ")} coming in a later phase.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* SP21 Phase 4 Task 4.7: RemitDrawer mount. The activity
|
||||||
|
feed's `remit_received` rows drill into the drawer via
|
||||||
|
`open()`. `remits` is empty (the activity feed doesn't
|
||||||
|
keep a flat list of remits around), so j/k is a no-op
|
||||||
|
here — closing reverts the URL via `close()`. */}
|
||||||
|
<RemitDrawer
|
||||||
|
remitId={remitId}
|
||||||
|
remits={[]}
|
||||||
|
onClose={close}
|
||||||
|
onNavigate={open}
|
||||||
|
onToggleHelp={() => {
|
||||||
|
// ActivityLog has no cheatsheet; `?` is a no-op here.
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ vi.mock("@/lib/api", () => ({
|
|||||||
isConfigured: true,
|
isConfigured: true,
|
||||||
listBatches: vi.fn(),
|
listBatches: vi.fn(),
|
||||||
getBatchDiff: vi.fn(),
|
getBatchDiff: vi.fn(),
|
||||||
|
getRemittance: vi.fn(),
|
||||||
},
|
},
|
||||||
ApiError: class ApiError extends Error {
|
ApiError: class ApiError extends Error {
|
||||||
constructor(public status: number, message: string) {
|
constructor(public status: number, message: string) {
|
||||||
@@ -180,6 +181,13 @@ function makeDiffPayload(): BatchDiffResponse {
|
|||||||
describe("BatchDiff page", () => {
|
describe("BatchDiff page", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Default for the per-remit detail fetch — the drawer fetches
|
||||||
|
// this whenever `?remit=` is in the URL. Return a never-resolving
|
||||||
|
// promise so the drawer stays in the loading state; the smoke
|
||||||
|
// test only asserts the drawer mounts, not the loaded data.
|
||||||
|
(
|
||||||
|
api.getRemittance as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(new Promise(() => {}));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -189,6 +197,42 @@ describe("BatchDiff page", () => {
|
|||||||
.happyDOM.setURL("http://localhost/batch-diff");
|
.happyDOM.setURL("http://localhost/batch-diff");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 4.5: deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
|
||||||
|
// Pre-set the URL with `?remit=`. The page doesn't surface any
|
||||||
|
// per-remit rows (the diff payload is claim-level), but the
|
||||||
|
// drawer must still mount so the URL contract is honored.
|
||||||
|
//
|
||||||
|
// `useRemitDrawerUrlState` reads `window.location.search`
|
||||||
|
// (NOT React Router's search params) so we have to set the
|
||||||
|
// global window URL via happyDOM AND give MemoryRouter an
|
||||||
|
// initial entry — both stay in lockstep.
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
|
||||||
|
.happyDOM.setURL("http://localhost/batch-diff?remit=REM-7");
|
||||||
|
(
|
||||||
|
api.listBatches as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue([BATCH_A, BATCH_B]);
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<BatchDiff />,
|
||||||
|
["/batch-diff?remit=REM-7"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Page header must be present + drawer must be open.
|
||||||
|
await waitFor(
|
||||||
|
() => !!document.querySelector('[data-testid="batch-diff-page"]'),
|
||||||
|
"page header mounted",
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
() => !!document.querySelector('[data-testid="remit-drawer"]'),
|
||||||
|
"remit drawer mounted via deep link",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-testid="remit-drawer"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the awaiting-picks empty state when no batches are selected", async () => {
|
it("renders the awaiting-picks empty state when no batches are selected", async () => {
|
||||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
BATCH_A, BATCH_B,
|
BATCH_A, BATCH_B,
|
||||||
@@ -437,8 +481,184 @@ describe("BatchDiff page", () => {
|
|||||||
).toContain("pick a batch");
|
).toContain("pick a batch");
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.6: clicking an added-claim id navigates to /claims?claim=ID", async () => {
|
||||||
|
// Each ClaimIdCell wraps its id text with DrillableCell, whose
|
||||||
|
// onClick navigates to /claims?claim=ID. The MemoryRouter gives
|
||||||
|
// us a router context so the navigation completes; we observe
|
||||||
|
// it via the rendered pathname on a LocationTracker spy.
|
||||||
|
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
BATCH_A, BATCH_B,
|
||||||
|
]);
|
||||||
|
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
|
makeDiffPayload(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const captured: { pathname: string; search: string } = {
|
||||||
|
pathname: "/batch-diff",
|
||||||
|
search: "",
|
||||||
|
};
|
||||||
|
const Tracker = () => {
|
||||||
|
const loc = useLocationSafe();
|
||||||
|
React.useEffect(() => {
|
||||||
|
captured.pathname = loc.pathname;
|
||||||
|
captured.search = loc.search;
|
||||||
|
}, [loc.pathname, loc.search]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<>
|
||||||
|
<Tracker />
|
||||||
|
<BatchDiff />
|
||||||
|
</>,
|
||||||
|
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => !!document.querySelector('[data-testid="diff-added-row-CLM-3"]'),
|
||||||
|
"added row rendered",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cell = document.querySelector(
|
||||||
|
'[data-testid="diff-added-row-CLM-3"] [data-testid="diff-claim-id"]',
|
||||||
|
);
|
||||||
|
expect(cell).not.toBeNull();
|
||||||
|
// DrillableCell wraps the id text in its own <button>; click the
|
||||||
|
// nearest button ancestor so the drillable onClick fires.
|
||||||
|
const drillBtn =
|
||||||
|
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
|
||||||
|
expect(drillBtn).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
drillBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-3",
|
||||||
|
"navigation to /claims?claim=CLM-3",
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.6: clicking a removed-claim id still navigates (ClaimDrawer handles 404)", async () => {
|
||||||
|
// The diff can list claim ids that no longer exist in the DB
|
||||||
|
// (Removed from A means "was in A, not in B" — the claim may
|
||||||
|
// still exist or may have been purged). Either way, clicking
|
||||||
|
// the id drills to /claims?claim=ID; the ClaimDrawer's 404
|
||||||
|
// state (Phase 2) handles the missing-claim case. This test
|
||||||
|
// only verifies the click path, not the drawer's 404 surface.
|
||||||
|
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
BATCH_A, BATCH_B,
|
||||||
|
]);
|
||||||
|
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
|
makeDiffPayload(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const captured: { pathname: string; search: string } = {
|
||||||
|
pathname: "/batch-diff",
|
||||||
|
search: "",
|
||||||
|
};
|
||||||
|
const Tracker = () => {
|
||||||
|
const loc = useLocationSafe();
|
||||||
|
React.useEffect(() => {
|
||||||
|
captured.pathname = loc.pathname;
|
||||||
|
captured.search = loc.search;
|
||||||
|
}, [loc.pathname, loc.search]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<>
|
||||||
|
<Tracker />
|
||||||
|
<BatchDiff />
|
||||||
|
</>,
|
||||||
|
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => !!document.querySelector('[data-testid="diff-removed-row-CLM-2"]'),
|
||||||
|
"removed row rendered",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cell = document.querySelector(
|
||||||
|
'[data-testid="diff-removed-row-CLM-2"] [data-testid="diff-claim-id"]',
|
||||||
|
);
|
||||||
|
const drillBtn =
|
||||||
|
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
|
||||||
|
expect(drillBtn).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
drillBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-2",
|
||||||
|
"navigation to /claims?claim=CLM-2",
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.6: clicking a changed-claim id navigates to /claims?claim=ID", async () => {
|
||||||
|
// The Changed row uses the A-side id (per existing test
|
||||||
|
// assertions). Drill should fire on that id.
|
||||||
|
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
BATCH_A, BATCH_B,
|
||||||
|
]);
|
||||||
|
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
|
makeDiffPayload(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const captured: { pathname: string; search: string } = {
|
||||||
|
pathname: "/batch-diff",
|
||||||
|
search: "",
|
||||||
|
};
|
||||||
|
const Tracker = () => {
|
||||||
|
const loc = useLocationSafe();
|
||||||
|
React.useEffect(() => {
|
||||||
|
captured.pathname = loc.pathname;
|
||||||
|
captured.search = loc.search;
|
||||||
|
}, [loc.pathname, loc.search]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<>
|
||||||
|
<Tracker />
|
||||||
|
<BatchDiff />
|
||||||
|
</>,
|
||||||
|
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => !!document.querySelector('[data-testid="diff-changed-row-CLM-1"]'),
|
||||||
|
"changed row rendered",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cell = document.querySelector(
|
||||||
|
'[data-testid="diff-changed-row-CLM-1"] [data-testid="diff-claim-id"]',
|
||||||
|
);
|
||||||
|
const drillBtn =
|
||||||
|
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
|
||||||
|
expect(drillBtn).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
drillBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-1",
|
||||||
|
"navigation to /claims?claim=CLM-1",
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// SP21 Phase 5 Task 5.6: react-router-dom's `useLocation` hook, used
|
||||||
|
// by the LocationTracker test helper below. Aliased to keep the import
|
||||||
|
// block tidy alongside React + the page import.
|
||||||
|
import { useLocation as useLocationSafe } from "react-router-dom";
|
||||||
|
|
||||||
// Keep `ApiError` referenced so the import isn't tree-shaken by
|
// Keep `ApiError` referenced so the import isn't tree-shaken by
|
||||||
// vitest's transformer when the mock factory above is hoisted.
|
// vitest's transformer when the mock factory above is hoisted.
|
||||||
void ApiError;
|
void ApiError;
|
||||||
|
|||||||
+579
-31
@@ -1,6 +1,11 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
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 { ApiError } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -12,9 +17,14 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { ErrorState } from "@/components/ui/error-state";
|
import { ErrorState } from "@/components/ui/error-state";
|
||||||
import { BatchDiffView, BatchDiffViewSkeleton } from "@/components/BatchDiffView";
|
import {
|
||||||
|
BatchDiffView,
|
||||||
|
BatchDiffViewSkeleton,
|
||||||
|
} from "@/components/BatchDiffView";
|
||||||
|
import { RemitDrawer } from "@/components/RemitDrawer";
|
||||||
import { useBatches } from "@/hooks/useBatches";
|
import { useBatches } from "@/hooks/useBatches";
|
||||||
import { useBatchDiff } from "@/hooks/useBatchDiff";
|
import { useBatchDiff } from "@/hooks/useBatchDiff";
|
||||||
|
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||||
import type { BatchSummary as ApiBatchSummary } from "@/lib/api";
|
import type { BatchSummary as ApiBatchSummary } from "@/lib/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -38,9 +48,11 @@ function readIdsFromParams(params: URLSearchParams): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Batch picker — one Select dropdown for each side. Keeps the same
|
// Kind badge — small inline label so the operator can spot which batch
|
||||||
// kind-colored badge inline so the operator can spot which batch is
|
// is which at a glance. Identical contract to the one in
|
||||||
// which at a glance.
|
// `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"] }) {
|
function KindBadge({ kind }: { kind: ApiBatchSummary["kind"] }) {
|
||||||
@@ -63,6 +75,7 @@ function KindBadge({ kind }: { kind: ApiBatchSummary["kind"] }) {
|
|||||||
|
|
||||||
function BatchPicker({
|
function BatchPicker({
|
||||||
label,
|
label,
|
||||||
|
side,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
items,
|
items,
|
||||||
@@ -70,6 +83,7 @@ function BatchPicker({
|
|||||||
testid,
|
testid,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
|
side: "A" | "B";
|
||||||
value: string | null;
|
value: string | null;
|
||||||
onChange: (id: string) => void;
|
onChange: (id: string) => void;
|
||||||
items: ApiBatchSummary[];
|
items: ApiBatchSummary[];
|
||||||
@@ -85,10 +99,54 @@ function BatchPicker({
|
|||||||
[items, excludeId],
|
[items, excludeId],
|
||||||
);
|
);
|
||||||
const selected = items.find((it) => it.id === value);
|
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 (
|
return (
|
||||||
<div className="space-y-1.5" data-testid={testid}>
|
<div
|
||||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
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}
|
{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>
|
</div>
|
||||||
<Select value={value ?? ""} onValueChange={(v) => onChange(v)}>
|
<Select value={value ?? ""} onValueChange={(v) => onChange(v)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -96,7 +154,10 @@ function BatchPicker({
|
|||||||
<span className="flex items-center gap-2 truncate">
|
<span className="flex items-center gap-2 truncate">
|
||||||
<KindBadge kind={selected.kind} />
|
<KindBadge kind={selected.kind} />
|
||||||
<span className="font-mono num text-[12.5px]">{selected.id}</span>
|
<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}
|
· {selected.inputFilename}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -157,6 +218,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
|
// Page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -166,6 +271,14 @@ export function BatchDiff() {
|
|||||||
// BrowserRouter (production) symmetric: both update the
|
// BrowserRouter (production) symmetric: both update the
|
||||||
// useSearchParams hook on every navigation.
|
// useSearchParams hook on every navigation.
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
// SP21 Phase 4 Task 4.5: mount the RemitDrawer so a deep link to
|
||||||
|
// /batch-diff?remit=REM-1 opens the drawer in-place. The BatchDiff
|
||||||
|
// data model (BatchClaimDiffSummary) is a claim-level projection —
|
||||||
|
// there are no per-remit IDs in the diff payload to wrap, so no
|
||||||
|
// click-to-drill is wired here. The drawer still mounts so the
|
||||||
|
// `?remit=` URL contract is honored when the user lands on this
|
||||||
|
// page with the param set (e.g. from an external link).
|
||||||
|
const { remitId, open, close } = useRemitDrawerUrlState();
|
||||||
const { a, b } = useMemo(
|
const { a, b } = useMemo(
|
||||||
() => readIdsFromParams(searchParams),
|
() => readIdsFromParams(searchParams),
|
||||||
[searchParams],
|
[searchParams],
|
||||||
@@ -193,8 +306,13 @@ export function BatchDiff() {
|
|||||||
);
|
);
|
||||||
const reset = useCallback(() => updateIds({ a: null, b: null }), [updateIds]);
|
const reset = useCallback(() => updateIds({ a: null, b: null }), [updateIds]);
|
||||||
|
|
||||||
const { data: batches, isLoading: loadingBatches, isError: batchesError, error: batchesErrorMsg, refetch: refetchBatches } =
|
const {
|
||||||
useBatches(100);
|
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
|
// 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
|
// gates on this internally (defense-in-depth), so a half-picked URL
|
||||||
@@ -209,27 +327,312 @@ export function BatchDiff() {
|
|||||||
: "network"
|
: "network"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Stagger choreography
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
const heroDelay = 0;
|
||||||
|
const foldDelay = 220;
|
||||||
|
const titleDelay = 320;
|
||||||
|
const picksDelay = 460;
|
||||||
|
const diffDelay = 600;
|
||||||
|
|
||||||
// --- render -------------------------------------------------------
|
// --- render -------------------------------------------------------
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 md:space-y-8 animate-fade-in" data-testid="batch-diff-page">
|
<div
|
||||||
<header>
|
className="space-y-0 animate-fade-in"
|
||||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
data-testid="batch-diff-page"
|
||||||
<span className="inline-block h-px w-6 bg-border" />
|
>
|
||||||
Diff
|
{/* =================================================================
|
||||||
|
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>
|
</div>
|
||||||
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
|
|
||||||
Batch diff
|
<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>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
</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
|
Pick two parsed batches — typically a submitted 837P and its
|
||||||
corrected follow-up — and see what was added, removed, or changed
|
corrected follow-up — and see what was added, removed, or changed
|
||||||
between them.
|
between them.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="surface rounded-xl p-4 grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
|
{/* =================================================================
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* =================================================================
|
||||||
|
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)" }}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* § 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)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
<BatchPicker
|
||||||
label="A · left"
|
label="A · left"
|
||||||
|
side="A"
|
||||||
value={a}
|
value={a}
|
||||||
onChange={setA}
|
onChange={setA}
|
||||||
items={batches ?? []}
|
items={batches ?? []}
|
||||||
@@ -238,6 +641,7 @@ export function BatchDiff() {
|
|||||||
/>
|
/>
|
||||||
<BatchPicker
|
<BatchPicker
|
||||||
label="B · right"
|
label="B · right"
|
||||||
|
side="B"
|
||||||
value={b}
|
value={b}
|
||||||
onChange={setB}
|
onChange={setB}
|
||||||
items={batches ?? []}
|
items={batches ?? []}
|
||||||
@@ -245,8 +649,65 @@ export function BatchDiff() {
|
|||||||
testid="diff-picker-b"
|
testid="diff-picker-b"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 ? (
|
{batchesError ? (
|
||||||
|
<div
|
||||||
|
className="rounded-md border p-5"
|
||||||
|
style={{
|
||||||
|
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||||
|
backgroundColor: "hsl(36 22% 96%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="Couldn't load batches from the backend."
|
message="Couldn't load batches from the backend."
|
||||||
detail={
|
detail={
|
||||||
@@ -256,12 +717,24 @@ export function BatchDiff() {
|
|||||||
}
|
}
|
||||||
onRetry={() => refetchBatches()}
|
onRetry={() => refetchBatches()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
</div>
|
||||||
|
) : !ready ? (
|
||||||
{!ready ? (
|
<div
|
||||||
<div className="surface rounded-xl">
|
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
|
<EmptyState
|
||||||
icon={<GitCompareArrows className="h-4 w-4" strokeWidth={1.5} />}
|
icon={
|
||||||
|
<GitCompareArrows
|
||||||
|
className="h-4 w-4"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
eyebrow="Batch diff · awaiting picks"
|
eyebrow="Batch diff · awaiting picks"
|
||||||
message="Choose one batch for A and one for B to compute the diff."
|
message="Choose one batch for A and one for B to compute the diff."
|
||||||
/>
|
/>
|
||||||
@@ -271,6 +744,13 @@ export function BatchDiff() {
|
|||||||
) : errKind === "not_found" ? (
|
) : errKind === "not_found" ? (
|
||||||
<NotFoundState a={a as string} b={b as string} onReset={reset} />
|
<NotFoundState a={a as string} b={b as string} onReset={reset} />
|
||||||
) : diff.isError ? (
|
) : diff.isError ? (
|
||||||
|
<div
|
||||||
|
className="rounded-md border p-5"
|
||||||
|
style={{
|
||||||
|
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||||
|
backgroundColor: "hsl(36 22% 96%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="Couldn't compute the diff between these two batches."
|
message="Couldn't compute the diff between these two batches."
|
||||||
detail={
|
detail={
|
||||||
@@ -280,22 +760,57 @@ export function BatchDiff() {
|
|||||||
}
|
}
|
||||||
onRetry={() => diff.refetch()}
|
onRetry={() => diff.refetch()}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : diff.isLoading || !diff.data ? (
|
) : diff.isLoading || !diff.data ? (
|
||||||
<BatchDiffViewSkeleton />
|
<BatchDiffViewSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<BatchDiffView data={diff.data} />
|
<BatchDiffView data={diff.data} />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Action footer — always visible so the operator can clear or
|
{/* Action footer — always visible when ready so the operator can
|
||||||
force-refresh without scrolling back to the picker. */}
|
clear or force-refresh without scrolling back to the picker. */}
|
||||||
{ready ? (
|
{ready ? (
|
||||||
<div className="flex items-center justify-end gap-2 pt-2">
|
<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 ? (
|
{diff.isFetching ? (
|
||||||
<span className="text-[12px] text-muted-foreground inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<CircleDashed className="h-3 w-3 animate-spin" strokeWidth={1.75} />
|
<CircleDashed
|
||||||
|
className="h-3 w-3 animate-spin"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
/>
|
||||||
Refreshing…
|
Refreshing…
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : (
|
||||||
|
<>
|
||||||
|
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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -314,7 +829,40 @@ export function BatchDiff() {
|
|||||||
Clear picks
|
Clear picks
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SP21 Phase 4 Task 4.5: RemitDrawer mount. There are no
|
||||||
|
per-remit rows in the diff payload (the page is a
|
||||||
|
claim-level projection), so this drawer is for deep-link
|
||||||
|
support only — clicking a claim row does not open it.
|
||||||
|
When the user lands on /batch-diff?remit=REM-1, the drawer
|
||||||
|
opens in-place. The `remits` list is empty so j/k is a
|
||||||
|
no-op while the drawer is open. */}
|
||||||
|
<RemitDrawer
|
||||||
|
remitId={remitId}
|
||||||
|
remits={[]}
|
||||||
|
onClose={close}
|
||||||
|
onNavigate={open}
|
||||||
|
onToggleHelp={() => {
|
||||||
|
// BatchDiff has no cheatsheet surface; `?` is a no-op
|
||||||
|
// here, but the prop is required by the drawer's contract.
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+546
-11
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Files, GitBranch, Layers, Receipt } from "lucide-react";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { ErrorState } from "@/components/ui/error-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 { BatchesList, BatchesListSkeleton } from "@/components/BatchesList";
|
||||||
import {
|
import {
|
||||||
BatchDetailContent,
|
BatchDetailContent,
|
||||||
@@ -118,6 +119,14 @@ function useBatchDetail(id: string | null): {
|
|||||||
|
|
||||||
const HELP_NOOP = () => {};
|
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() {
|
export function Batches() {
|
||||||
const { data, isLoading, isError, error, refetch } = useBatches(100);
|
const { data, isLoading, isError, error, refetch } = useBatches(100);
|
||||||
const items: BatchSummary[] = data ?? [];
|
const items: BatchSummary[] = data ?? [];
|
||||||
@@ -156,6 +165,28 @@ export function Batches() {
|
|||||||
const dimBackground = batchId !== null;
|
const dimBackground = batchId !== null;
|
||||||
const errKind = batchErrorKind(detailError);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -188,37 +219,541 @@ export function Batches() {
|
|||||||
<div
|
<div
|
||||||
data-testid="batches-page-body"
|
data-testid="batches-page-body"
|
||||||
className={cn(
|
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",
|
dimBackground && "pointer-events-none opacity-60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PageHeader
|
{/* =================================================================
|
||||||
eyebrow="Batches"
|
HERO — DARK EDITORIAL HEADER
|
||||||
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."
|
<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>
|
||||||
|
|
||||||
|
<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)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* =================================================================
|
||||||
|
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)" }}
|
||||||
|
/>
|
||||||
|
<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 ? (
|
{isError ? (
|
||||||
|
<div
|
||||||
|
className="rounded-md border p-5"
|
||||||
|
style={{
|
||||||
|
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||||
|
backgroundColor: "hsl(36 22% 96%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="Couldn't load batches from the backend."
|
message="Couldn't load batches from the backend."
|
||||||
detail={error instanceof Error ? error.message : String(error)}
|
detail={error instanceof Error ? error.message : String(error)}
|
||||||
onRetry={() => refetch()}
|
onRetry={() => refetch()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
<div className="surface rounded-xl overflow-hidden">
|
<div
|
||||||
{isLoading ? (
|
className="rounded-md border p-4 space-y-2"
|
||||||
|
style={{
|
||||||
|
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||||
|
backgroundColor: "hsl(36 22% 96%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<BatchesListSkeleton />
|
<BatchesListSkeleton />
|
||||||
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-md border p-10 text-center"
|
||||||
|
style={{
|
||||||
|
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||||
|
borderStyle: "dashed",
|
||||||
|
backgroundColor: "hsl(36 22% 96%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
eyebrow="Batches · awaiting first ingest"
|
eyebrow="Batches · awaiting first ingest"
|
||||||
message="Upload an 837P or 835 file on the Upload page to populate this list."
|
message="Upload an 837P or 835 file on the Upload page to populate this list."
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<BatchesList items={items} openId={batchId} onOpen={open} />
|
<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>
|
</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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+94
-14
@@ -10,6 +10,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Claims } from "./Claims";
|
import { Claims } from "./Claims";
|
||||||
|
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useTailStore } from "@/store/tail-store";
|
import { useTailStore } from "@/store/tail-store";
|
||||||
import type { Claim, ClaimDetail } from "@/types";
|
import type { Claim, ClaimDetail } from "@/types";
|
||||||
@@ -194,9 +195,18 @@ function renderClaimsAt(initialEntries: string[]): {
|
|||||||
React.createElement(
|
React.createElement(
|
||||||
MemoryRouter,
|
MemoryRouter,
|
||||||
{ initialEntries },
|
{ initialEntries },
|
||||||
|
// SP21 Phase 5 Task 5.8/5.9: the ClaimDrawer mounted by
|
||||||
|
// Claims now uses useDrillStack() in PartiesGrid +
|
||||||
|
// ValidationPanel. Wrap in a DrillStackProvider so the
|
||||||
|
// hook has a context (the provider is also mounted at
|
||||||
|
// the App root in production).
|
||||||
|
React.createElement(
|
||||||
|
DrillStackProvider,
|
||||||
|
null,
|
||||||
React.createElement(Claims),
|
React.createElement(Claims),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -313,17 +323,19 @@ describe("Claims page drawer wiring", () => {
|
|||||||
document.body.querySelector('[data-testid="claim-drawer"]')
|
document.body.querySelector('[data-testid="claim-drawer"]')
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
|
|
||||||
// Header shows the claim id — proves the id propagated end-to-end
|
// SP21 Phase 5 Task 5.10: the claim id is now the title of the
|
||||||
// (URL → useDrawerUrlState → ClaimDrawer prop → useClaimDetail →
|
// shared DrillDrawerHeader (rendered as an <h2>). Find the h2
|
||||||
// header render), not just that some drawer is mounted.
|
// inside the drawer and assert its text matches the claim id —
|
||||||
await settle(
|
// proves the id propagated end-to-end (URL → useDrawerUrlState
|
||||||
() =>
|
// → ClaimDrawer prop → useClaimDetail → header render), not
|
||||||
document.body.querySelector('[data-testid="header-id"]')?.textContent ===
|
// just that some drawer is mounted.
|
||||||
"CLM-1"
|
await settle(() => {
|
||||||
);
|
const drawer = document.body.querySelector('[data-testid="claim-drawer"]');
|
||||||
expect(
|
const h2 = drawer?.querySelector("h2");
|
||||||
document.body.querySelector('[data-testid="header-id"]')?.textContent
|
return h2?.textContent === "CLM-1";
|
||||||
).toBe("CLM-1");
|
});
|
||||||
|
const drawer = document.body.querySelector('[data-testid="claim-drawer"]');
|
||||||
|
expect(drawer?.querySelector("h2")?.textContent).toBe("CLM-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("test_escape_closes_the_drawer", async () => {
|
it("test_escape_closes_the_drawer", async () => {
|
||||||
@@ -360,9 +372,14 @@ describe("Claims page drawer wiring", () => {
|
|||||||
|
|
||||||
// Wait for the header to render — the close button only mounts once
|
// Wait for the header to render — the close button only mounts once
|
||||||
// `useClaimDetail` resolves with a real ClaimDetail (the skeleton /
|
// `useClaimDetail` resolves with a real ClaimDetail (the skeleton /
|
||||||
// error states don't render the header).
|
// error states don't render the header). SP21 Phase 5 Task 5.10:
|
||||||
|
// the close button is now inside the shared DrillDrawerHeader;
|
||||||
|
// find it via its accessible name.
|
||||||
await settle(
|
await settle(
|
||||||
() => document.body.querySelector('[data-testid="header-close"]') !== null
|
() =>
|
||||||
|
document.body.querySelector(
|
||||||
|
'[data-testid="claim-drawer"] button[aria-label="Close drawer"]'
|
||||||
|
) !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
// URL currently carries the claim.
|
// URL currently carries the claim.
|
||||||
@@ -372,7 +389,7 @@ describe("Claims page drawer wiring", () => {
|
|||||||
// onClose → useDrawerUrlState.close(), which pushState's a URL with
|
// onClose → useDrawerUrlState.close(), which pushState's a URL with
|
||||||
// the ?claim= param stripped.
|
// the ?claim= param stripped.
|
||||||
const closeBtn = document.body.querySelector(
|
const closeBtn = document.body.querySelector(
|
||||||
'[data-testid="header-close"]'
|
'[data-testid="claim-drawer"] button[aria-label="Close drawer"]'
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
expect(closeBtn).not.toBeNull();
|
expect(closeBtn).not.toBeNull();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -699,4 +716,67 @@ describe("Claims page drawer wiring", () => {
|
|||||||
|
|
||||||
unmount();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-1
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +29,7 @@ import { Pagination } from "@/components/ui/pagination";
|
|||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
import { useClaims } from "@/hooks/useClaims";
|
import { useClaims } from "@/hooks/useClaims";
|
||||||
import { useDrawerUrlState } from "@/hooks/useDrawerUrlState";
|
import { useDrawerUrlState } from "@/hooks/useDrawerUrlState";
|
||||||
|
import { DrillableCell } from "@/components/drill/DrillableCell";
|
||||||
import { useTailStream } from "@/hooks/useTailStream";
|
import { useTailStream } from "@/hooks/useTailStream";
|
||||||
import { useMergedTail } from "@/hooks/useMergedTail";
|
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||||
import { TailStatusPill } from "@/components/TailStatusPill";
|
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||||
@@ -78,6 +79,7 @@ export function Claims() {
|
|||||||
// using a separate `?order=` param.
|
// using a separate `?order=` param.
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const rawStatus = searchParams.get("status");
|
const rawStatus = searchParams.get("status");
|
||||||
const status: ClaimStatus | typeof ALL =
|
const status: ClaimStatus | typeof ALL =
|
||||||
@@ -366,10 +368,27 @@ export function Claims() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium text-[13px]">{c.patientName}</TableCell>
|
<TableCell className="font-medium text-[13px]">{c.patientName}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
<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="text-[13px]">{provider?.name ?? "Unknown"}</div>
|
||||||
<div className="mono text-[10.5px] text-muted-foreground">
|
<div className="mono text-[10.5px] text-muted-foreground">
|
||||||
{c.providerNpi}
|
{c.providerNpi}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</DrillableCell>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground text-[13px]">
|
<TableCell className="text-muted-foreground text-[13px]">
|
||||||
{c.payerName}
|
{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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
+20
-1
@@ -16,7 +16,9 @@ import { ActivityFeed } from "@/components/ActivityFeed";
|
|||||||
import { AnimatedNumber } from "@/components/AnimatedNumber";
|
import { AnimatedNumber } from "@/components/AnimatedNumber";
|
||||||
import { DrillableCell } from "@/components/drill/DrillableCell";
|
import { DrillableCell } from "@/components/drill/DrillableCell";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
|
import { eventKindToUrl } from "@/lib/event-routing";
|
||||||
import { useAppStore } from "@/store";
|
import { useAppStore } from "@/store";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const MONTHS_BACK = 6;
|
const MONTHS_BACK = 6;
|
||||||
|
|
||||||
@@ -275,7 +277,24 @@ export function Dashboard() {
|
|||||||
</span>
|
</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
+301
-4
@@ -1,10 +1,60 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import Inbox from "./Inbox";
|
import Inbox from "./Inbox";
|
||||||
import * as inboxApi from "@/lib/inbox-api";
|
import * as inboxApi from "@/lib/inbox-api";
|
||||||
import * as downloadModule from "@/lib/download";
|
import * as downloadModule from "@/lib/download";
|
||||||
|
|
||||||
|
// SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched
|
||||||
|
// claim drilldown to /claims?claim=ID), so the render harness needs
|
||||||
|
// a Router context. We use a fresh QueryClient per test so the
|
||||||
|
// drawer's per-remit query doesn't leak cache between cases, and a
|
||||||
|
// MemoryRouter so `useNavigate` has a router to push into.
|
||||||
|
//
|
||||||
|
// Phase 5 Task 5.4: MemoryRouter doesn't update window.location, so
|
||||||
|
// the navigation assertion uses a small LocationTracker component
|
||||||
|
// mounted under the same router that records the current pathname +
|
||||||
|
// search after each render. Tests then read `tracker.last` instead
|
||||||
|
// of `window.location`.
|
||||||
|
function LocationTracker({ on }: { on: (pathname: string, search: string) => void }) {
|
||||||
|
const loc = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
on(loc.pathname, loc.search);
|
||||||
|
}, [loc.pathname, loc.search, on]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInbox() {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
|
||||||
|
});
|
||||||
|
// Capture the last location seen so tests can assert on navigation
|
||||||
|
// without poking at window.location (MemoryRouter doesn't touch it).
|
||||||
|
const captured = { pathname: "/inbox", search: "" };
|
||||||
|
const view = render(
|
||||||
|
<MemoryRouter initialEntries={["/inbox"]}>
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<LocationTracker
|
||||||
|
on={(pathname, search) => {
|
||||||
|
captured.pathname = pathname;
|
||||||
|
captured.search = search;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Inbox />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
// Attach the tracker so individual tests can read it. Note: the
|
||||||
|
// captured value updates asynchronously after navigation, but since
|
||||||
|
// LocationTracker runs in the same render pass as the navigate,
|
||||||
|
// tests should `waitFor` it.
|
||||||
|
(view as unknown as { tracker: typeof captured }).tracker = captured;
|
||||||
|
return view as ReturnType<typeof render> & { tracker: typeof captured };
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
@@ -29,7 +79,7 @@ describe("Inbox page", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const { container } = render(<Inbox />);
|
const { container } = renderInbox();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(container.textContent).toContain("REJECTED");
|
expect(container.textContent).toContain("REJECTED");
|
||||||
expect(container.textContent).toContain("PAYER REJECTED");
|
expect(container.textContent).toContain("PAYER REJECTED");
|
||||||
@@ -65,7 +115,7 @@ describe("Inbox page", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const { container } = render(<Inbox />);
|
const { container } = renderInbox();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(container.textContent).toContain("1");
|
expect(container.textContent).toContain("1");
|
||||||
expect(container.textContent).toContain("items need eyes");
|
expect(container.textContent).toContain("items need eyes");
|
||||||
@@ -112,7 +162,7 @@ describe("Inbox page", () => {
|
|||||||
});
|
});
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
const { container, getByTestId } = render(<Inbox />);
|
const { container, getByTestId } = renderInbox();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(container.textContent).toContain("PR1");
|
expect(container.textContent).toContain("PR1");
|
||||||
});
|
});
|
||||||
@@ -202,7 +252,7 @@ describe("Inbox page", () => {
|
|||||||
.spyOn(downloadModule, "downloadBlob")
|
.spyOn(downloadModule, "downloadBlob")
|
||||||
.mockImplementation(() => {});
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
const { container, getByText } = render(<Inbox />);
|
const { container, getByText } = renderInbox();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(container.textContent).toContain("C1");
|
expect(container.textContent).toContain("C1");
|
||||||
expect(container.textContent).toContain("C2");
|
expect(container.textContent).toContain("C2");
|
||||||
@@ -245,4 +295,251 @@ describe("Inbox page", () => {
|
|||||||
expect(filename).toBe("resubmit-2-claims.zip");
|
expect(filename).toBe("resubmit-2-claims.zip");
|
||||||
expect(blob).toBeInstanceOf(Blob);
|
expect(blob).toBeInstanceOf(Blob);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 4.4: clicking a candidate row opens the RemitDrawer", async () => {
|
||||||
|
// Candidates are remits (payer_claim_control_number-keyed) — the
|
||||||
|
// row's `id` is the remit id, so clicking drills into the
|
||||||
|
// RemitDrawer via `?remit=ID`.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
rejected: [],
|
||||||
|
payer_rejected: [],
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
id: "REM-7",
|
||||||
|
kind: "remit",
|
||||||
|
payer_claim_control_number: "REM-7",
|
||||||
|
charge_amount: 200,
|
||||||
|
payer_id: "P1",
|
||||||
|
rendering_provider_npi: "1234567890",
|
||||||
|
service_date: "2026-06-19",
|
||||||
|
candidates: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unmatched: [],
|
||||||
|
done_today: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container } = renderInbox();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.textContent).toContain("REM-7");
|
||||||
|
});
|
||||||
|
|
||||||
|
// No drawer yet — the URL is `/inbox` with no `?remit=`.
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="remit-drawer"]')
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
// Click the candidate row. The InboxRow renders as a <tr> with
|
||||||
|
// the remit id in its first cell; clicking that row bubbles to
|
||||||
|
// the Lane's onRowClick handler we wired in Task 4.4.
|
||||||
|
const cell = Array.from(container.querySelectorAll("td")).find(
|
||||||
|
(td) => td.textContent === "REM-7",
|
||||||
|
);
|
||||||
|
const row = cell?.closest("tr");
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(row as HTMLElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drawer portals into document.body — check there, not container.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="remit-drawer"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 4.4: deep-link ?remit=ID opens the drawer on mount", async () => {
|
||||||
|
// Pre-set the URL so the hook reads REM-7 off `window.location.search`
|
||||||
|
// during its `useState` initializer — no click needed. The inbox
|
||||||
|
// already mounts the drawer, so a deep link to /inbox?remit=REM-7
|
||||||
|
// should land with the drawer open.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
rejected: [],
|
||||||
|
payer_rejected: [],
|
||||||
|
candidates: [],
|
||||||
|
unmatched: [],
|
||||||
|
done_today: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
|
||||||
|
"http://localhost/inbox?remit=REM-7",
|
||||||
|
);
|
||||||
|
|
||||||
|
renderInbox();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="remit-drawer"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.4: clicking a rejected claim row navigates to /claims?claim=ID", async () => {
|
||||||
|
// Task 5.4 wires the rejected lane's onRowClick to navigate to
|
||||||
|
// the ClaimDrawer via `?claim=ID` on the /claims route. The
|
||||||
|
// MemoryRouter (initial /inbox) lets us observe the URL change
|
||||||
|
// via the LocationTracker helper mounted in the render harness.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
rejected: [
|
||||||
|
{
|
||||||
|
id: "REJ1",
|
||||||
|
kind: "claim",
|
||||||
|
payer_claim_control_number: "REJ1",
|
||||||
|
charge_amount: 175,
|
||||||
|
payer_id: "P1",
|
||||||
|
provider_npi: "1234567890",
|
||||||
|
state: "rejected",
|
||||||
|
rejection_reason: "999 reject",
|
||||||
|
service_date: null,
|
||||||
|
score: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
payer_rejected: [],
|
||||||
|
candidates: [],
|
||||||
|
unmatched: [],
|
||||||
|
done_today: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = renderInbox();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.container.textContent).toContain("REJ1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the rejected row. The InboxRow renders the claim id as
|
||||||
|
// its primary text cell; clicking that row bubbles up to the
|
||||||
|
// Lane's onRowClick handler we wired in Task 5.4.
|
||||||
|
const cell = Array.from(view.container.querySelectorAll("td")).find(
|
||||||
|
(td) => td.textContent === "REJ1",
|
||||||
|
);
|
||||||
|
const row = cell?.closest("tr");
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(row as HTMLElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
// MemoryRouter navigates to /claims?claim=REJ1 — verify the
|
||||||
|
// tracker observed the new pathname + search.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.tracker.pathname).toBe("/claims");
|
||||||
|
expect(view.tracker.search).toBe("?claim=REJ1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.4: clicking a done_today claim row navigates to /claims?claim=ID", async () => {
|
||||||
|
// done_today rows are claim-shaped — same drill pattern as the
|
||||||
|
// rejected lane. Verifies that the wiring covers the trailing
|
||||||
|
// "shipped today" lane too, not just the error lanes.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
rejected: [],
|
||||||
|
payer_rejected: [],
|
||||||
|
candidates: [],
|
||||||
|
unmatched: [],
|
||||||
|
done_today: [
|
||||||
|
{
|
||||||
|
id: "DONE1",
|
||||||
|
kind: "claim",
|
||||||
|
patient_control_number: "DONE1",
|
||||||
|
charge_amount: 88,
|
||||||
|
payer_id: "P1",
|
||||||
|
provider_npi: "1234567890",
|
||||||
|
state: "submitted",
|
||||||
|
service_date_from: "2026-06-21",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = renderInbox();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.container.textContent).toContain("DONE1");
|
||||||
|
});
|
||||||
|
|
||||||
|
const cell = Array.from(view.container.querySelectorAll("td")).find(
|
||||||
|
(td) => td.textContent === "DONE1",
|
||||||
|
);
|
||||||
|
const row = cell?.closest("tr");
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(row as HTMLElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.tracker.pathname).toBe("/claims");
|
||||||
|
expect(view.tracker.search).toBe("?claim=DONE1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.4: clicking the row checkbox does not bubble to onRowClick", async () => {
|
||||||
|
// Per the plan's §self-review #5, the Lane's RowCheckbox already
|
||||||
|
// calls e.stopPropagation() — verify that's still the case by
|
||||||
|
// clicking the checkbox and confirming the URL didn't change to
|
||||||
|
// /claims. (If stopPropagation regressed, the row click handler
|
||||||
|
// would fire and navigate.)
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
rejected: [
|
||||||
|
{
|
||||||
|
id: "REJ1",
|
||||||
|
kind: "claim",
|
||||||
|
payer_claim_control_number: "REJ1",
|
||||||
|
charge_amount: 175,
|
||||||
|
payer_id: "P1",
|
||||||
|
provider_npi: "1234567890",
|
||||||
|
state: "rejected",
|
||||||
|
rejection_reason: "999 reject",
|
||||||
|
service_date: null,
|
||||||
|
score: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
payer_rejected: [],
|
||||||
|
candidates: [],
|
||||||
|
unmatched: [],
|
||||||
|
done_today: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = renderInbox();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(view.container.textContent).toContain("REJ1");
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkbox = view.container.querySelector(
|
||||||
|
'input[type="checkbox"][aria-label="Select REJ1"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(checkbox).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
checkbox.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// URL should still be /inbox — clicking the checkbox selected
|
||||||
|
// the row but did not drill into the claim drawer.
|
||||||
|
expect(view.tracker.pathname).toBe("/inbox");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+69
-5
@@ -13,10 +13,13 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Lane, type LaneRow } from "@/components/inbox/Lane";
|
import { Lane, type LaneRow } from "@/components/inbox/Lane";
|
||||||
import { InboxHeader } from "@/components/inbox/InboxHeader";
|
import { InboxHeader } from "@/components/inbox/InboxHeader";
|
||||||
import { BulkBar } from "@/components/inbox/BulkBar";
|
import { BulkBar } from "@/components/inbox/BulkBar";
|
||||||
|
import { RemitDrawer } from "@/components/RemitDrawer";
|
||||||
|
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||||
import { useInboxLanes } from "@/hooks/useInboxLanes";
|
import { useInboxLanes } from "@/hooks/useInboxLanes";
|
||||||
import {
|
import {
|
||||||
exportInboxCsvUrl,
|
exportInboxCsvUrl,
|
||||||
@@ -40,6 +43,12 @@ function rowKey(row: LaneRow): string {
|
|||||||
|
|
||||||
export default function Inbox() {
|
export default function Inbox() {
|
||||||
const { lanes, loading, error, refetch } = useInboxLanes();
|
const { lanes, loading, error, refetch } = useInboxLanes();
|
||||||
|
// SP21 Phase 4 Task 4.4: drill-down from inbox rows. The hook reads
|
||||||
|
// `?remit=` off `window.location.search` so opening a row from the
|
||||||
|
// /inbox URL pushes `?remit=ID` onto /inbox itself (it doesn't
|
||||||
|
// navigate to /remittances). The drawer just opens in-place.
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { remitId, open, close } = useRemitDrawerUrlState();
|
||||||
const [selected, setSelected] = useState<Record<LaneKey, string[]>>({
|
const [selected, setSelected] = useState<Record<LaneKey, string[]>>({
|
||||||
rejected: [],
|
rejected: [],
|
||||||
payer_rejected: [],
|
payer_rejected: [],
|
||||||
@@ -218,6 +227,22 @@ export default function Inbox() {
|
|||||||
className="min-h-screen"
|
className="min-h-screen"
|
||||||
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
||||||
>
|
>
|
||||||
|
{/* SP21 Phase 4 Task 4.4: RemitDrawer mounts here so a row click
|
||||||
|
in the candidates / unmatched lanes drills into the parent
|
||||||
|
remit. The drawer portals into document.body (Radix Dialog),
|
||||||
|
so the surrounding dark surface is decorative — the drawer
|
||||||
|
overlays it when open. The hook reads `?remit=` off the URL,
|
||||||
|
so deep links restore the open remit on reload. */}
|
||||||
|
<RemitDrawer
|
||||||
|
remitId={remitId}
|
||||||
|
remits={[]}
|
||||||
|
onClose={close}
|
||||||
|
onNavigate={open}
|
||||||
|
onToggleHelp={() => {
|
||||||
|
// No cheatsheet on the inbox surface — `?` is a no-op
|
||||||
|
// here, but the prop is required by the drawer's contract.
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
|
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
|
||||||
|
|
||||||
{/* Fold — a thin amber rule + italic serif annotation that
|
{/* Fold — a thin amber rule + italic serif annotation that
|
||||||
@@ -270,7 +295,16 @@ export default function Inbox() {
|
|||||||
name="REJECTED"
|
name="REJECTED"
|
||||||
accent="oxblood"
|
accent="oxblood"
|
||||||
rows={lanes.rejected}
|
rows={lanes.rejected}
|
||||||
onRowClick={() => {}}
|
// SP21 Phase 5 Task 5.4: rejected claims drill into the
|
||||||
|
// ClaimDrawer. All rows here are claims (kind === "claim"),
|
||||||
|
// so the branch is straightforward — the type union still
|
||||||
|
// requires the defensive check, but a row click on a claim
|
||||||
|
// here is always a claim drill.
|
||||||
|
onRowClick={(row) => {
|
||||||
|
if (row.kind === "claim") {
|
||||||
|
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onSelectionChange={(ids) => setLaneSelected("rejected", ids)}
|
onSelectionChange={(ids) => setLaneSelected("rejected", ids)}
|
||||||
/>
|
/>
|
||||||
{/*
|
{/*
|
||||||
@@ -283,28 +317,58 @@ export default function Inbox() {
|
|||||||
name="PAYER REJECTED"
|
name="PAYER REJECTED"
|
||||||
accent="oxblood"
|
accent="oxblood"
|
||||||
rows={lanes.payer_rejected}
|
rows={lanes.payer_rejected}
|
||||||
onRowClick={() => {}}
|
// SP21 Phase 5 Task 5.4: payer-rejected claims also drill
|
||||||
|
// into the ClaimDrawer — same shape as the rejected lane.
|
||||||
|
onRowClick={(row) => {
|
||||||
|
if (row.kind === "claim") {
|
||||||
|
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
|
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
|
||||||
/>
|
/>
|
||||||
<Lane
|
<Lane
|
||||||
name="CANDIDATES"
|
name="CANDIDATES"
|
||||||
accent="amber"
|
accent="amber"
|
||||||
rows={lanes.candidates}
|
rows={lanes.candidates}
|
||||||
onRowClick={() => {}}
|
// Candidates are remits waiting for a claim match — drill
|
||||||
|
// straight into the RemitDrawer for the source remit.
|
||||||
|
onRowClick={(row) => open(row.id)}
|
||||||
onSelectionChange={(ids) => setLaneSelected("candidates", ids)}
|
onSelectionChange={(ids) => setLaneSelected("candidates", ids)}
|
||||||
/>
|
/>
|
||||||
<Lane
|
<Lane
|
||||||
name="UNMATCHED"
|
name="UNMATCHED"
|
||||||
accent="ink-blue"
|
accent="ink-blue"
|
||||||
rows={lanes.unmatched}
|
rows={lanes.unmatched}
|
||||||
onRowClick={() => {}}
|
// Unmatched is a mixed bucket (kind === "claim" | "remit" per
|
||||||
|
// the InboxClaimRow union). Defensive branch — today the
|
||||||
|
// backend only emits "claim" rows here, but the type allows
|
||||||
|
// both, and the next data-source change shouldn't require a
|
||||||
|
// page edit.
|
||||||
|
onRowClick={(row) => {
|
||||||
|
if (row.kind === "remit") {
|
||||||
|
open(row.id);
|
||||||
|
} else if (row.kind === "claim") {
|
||||||
|
navigate(
|
||||||
|
`/claims?claim=${encodeURIComponent(row.id)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onSelectionChange={(ids) => setLaneSelected("unmatched", ids)}
|
onSelectionChange={(ids) => setLaneSelected("unmatched", ids)}
|
||||||
/>
|
/>
|
||||||
<Lane
|
<Lane
|
||||||
name="DONE"
|
name="DONE"
|
||||||
accent="muted"
|
accent="muted"
|
||||||
rows={lanes.done_today}
|
rows={lanes.done_today}
|
||||||
onRowClick={() => {}}
|
// SP21 Phase 5 Task 5.4: done_today rows are also claim
|
||||||
|
// drills — same as rejected / payer_rejected. The drawer
|
||||||
|
// surfaces the claim's current state + recent history,
|
||||||
|
// which is exactly what an operator wants when reviewing
|
||||||
|
// "what got shipped today".
|
||||||
|
onRowClick={(row) => {
|
||||||
|
if (row.kind === "claim") {
|
||||||
|
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onSelectionChange={(ids) => setLaneSelected("done_today", ids)}
|
onSelectionChange={(ids) => setLaneSelected("done_today", ids)}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+13
-1
@@ -3,12 +3,15 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { ErrorState } from "@/components/ui/error-state";
|
import { ErrorState } from "@/components/ui/error-state";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { ProviderDrawer } from "@/components/ProviderDrawer";
|
||||||
|
import { useProviderDrawerUrlState } from "@/hooks/useProviderDrawerUrlState";
|
||||||
import { useProviders } from "@/hooks/useProviders";
|
import { useProviders } from "@/hooks/useProviders";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
|
|
||||||
export function Providers() {
|
export function Providers() {
|
||||||
const { data, isLoading, isError, error, refetch } = useProviders();
|
const { data, isLoading, isError, error, refetch } = useProviders();
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
|
const { providerNpi, open, close } = useProviderDrawerUrlState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 lg:space-y-8 animate-fade-in">
|
<div className="space-y-6 lg:space-y-8 animate-fade-in">
|
||||||
@@ -44,7 +47,14 @@ export function Providers() {
|
|||||||
{items.map((p) => (
|
{items.map((p) => (
|
||||||
<article
|
<article
|
||||||
key={p.npi}
|
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="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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ProviderDrawer npi={providerNpi} onClose={close} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||||
true;
|
true;
|
||||||
|
|
||||||
import React, { act } from "react";
|
import React, { act, useEffect } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReconciliationPage } from "./Reconciliation";
|
import { ReconciliationPage } from "./Reconciliation";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
@@ -19,6 +20,7 @@ vi.mock("@/lib/api", () => ({
|
|||||||
listUnmatched: vi.fn(),
|
listUnmatched: vi.fn(),
|
||||||
matchRemit: vi.fn(),
|
matchRemit: vi.fn(),
|
||||||
unmatchClaim: vi.fn(),
|
unmatchClaim: vi.fn(),
|
||||||
|
getRemittance: vi.fn(),
|
||||||
},
|
},
|
||||||
ApiError: class ApiError extends Error {
|
ApiError: class ApiError extends Error {
|
||||||
constructor(public status: number, message: string) {
|
constructor(public status: number, message: string) {
|
||||||
@@ -27,6 +29,23 @@ vi.mock("@/lib/api", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the current MemoryRouter location so tests can assert on
|
||||||
|
* navigation without poking at window.location (MemoryRouter doesn't
|
||||||
|
* touch it). Mounted as a sibling under the same router.
|
||||||
|
*/
|
||||||
|
function LocationTracker({
|
||||||
|
on,
|
||||||
|
}: {
|
||||||
|
on: (pathname: string, search: string) => void;
|
||||||
|
}) {
|
||||||
|
const loc = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
on(loc.pathname, loc.search);
|
||||||
|
}, [loc.pathname, loc.search, on]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal `render` helper using react-dom/client + act(). Mirrors the
|
* Minimal `render` helper using react-dom/client + act(). Mirrors the
|
||||||
* `renderHook` helper in `useReconciliation.test.ts` — see that file's
|
* `renderHook` helper in `useReconciliation.test.ts` — see that file's
|
||||||
@@ -34,22 +53,50 @@ vi.mock("@/lib/api", () => ({
|
|||||||
* yet, and adding one just for these tests would inflate the dev-deps
|
* yet, and adding one just for these tests would inflate the dev-deps
|
||||||
* tree). Returns the rendered container so tests can assert against
|
* tree). Returns the rendered container so tests can assert against
|
||||||
* the live DOM via `container.textContent`.
|
* the live DOM via `container.textContent`.
|
||||||
|
*
|
||||||
|
* SP21 Phase 5 Task 5.5: optionally wraps with a MemoryRouter so the
|
||||||
|
* `useNavigate()` call (claim drill to /claims?claim=ID) has a router
|
||||||
|
* context. Without this, `useNavigate()` would throw in tests that
|
||||||
|
* trigger a claim-card click. Tests that don't need a router can keep
|
||||||
|
* using the default (no router) path.
|
||||||
*/
|
*/
|
||||||
function renderIntoContainer(element: React.ReactElement): {
|
function renderIntoContainer(
|
||||||
|
element: React.ReactElement,
|
||||||
|
options: { withRouter?: boolean; initialEntries?: string[] } = {},
|
||||||
|
): {
|
||||||
container: HTMLDivElement;
|
container: HTMLDivElement;
|
||||||
unmount: () => void;
|
unmount: () => void;
|
||||||
|
tracker?: { pathname: string; search: string };
|
||||||
} {
|
} {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
let tracker: { pathname: string; search: string } | undefined;
|
||||||
|
const track = (pathname: string, search: string) => {
|
||||||
|
if (!tracker) tracker = { pathname, search };
|
||||||
|
tracker.pathname = pathname;
|
||||||
|
tracker.search = search;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inner = options.withRouter
|
||||||
|
? React.createElement(
|
||||||
|
MemoryRouter,
|
||||||
|
{
|
||||||
|
initialEntries: options.initialEntries ?? ["/reconciliation"],
|
||||||
|
},
|
||||||
|
React.createElement(LocationTracker, { on: track }),
|
||||||
|
element,
|
||||||
|
)
|
||||||
|
: element;
|
||||||
|
|
||||||
const root: Root = createRoot(container);
|
const root: Root = createRoot(container);
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(
|
React.createElement(
|
||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
{ client: qc },
|
{ client: qc },
|
||||||
element
|
inner
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -60,6 +107,7 @@ function renderIntoContainer(element: React.ReactElement): {
|
|||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
container.remove();
|
container.remove();
|
||||||
},
|
},
|
||||||
|
tracker,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +134,101 @@ async function waitForText(
|
|||||||
describe("ReconciliationPage", () => {
|
describe("ReconciliationPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Default for the per-remit detail fetch — the drawer fetches
|
||||||
|
// this whenever `?remit=` is in the URL. Return a never-resolving
|
||||||
|
// promise so the drawer stays in the loading state; the smoke
|
||||||
|
// test only asserts the drawer mounts.
|
||||||
|
(
|
||||||
|
api.getRemittance as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(new Promise(() => {}));
|
||||||
|
// SP21 Phase 5 Task 5.5: reset window.location to a clean
|
||||||
|
// /reconciliation URL so `useRemitDrawerUrlState`'s mount-time
|
||||||
|
// read doesn't see a `?remit=REM-DRILL` from the previous
|
||||||
|
// test's `open()` history push. Without this, a test that
|
||||||
|
// asserts the drawer isn't open still finds the drawer
|
||||||
|
// mounted on initial render (driven by history state, not by
|
||||||
|
// a click).
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
|
||||||
|
.happyDOM.setURL("http://localhost/reconciliation");
|
||||||
|
// SP21 Phase 5 Task 5.5: the previous test may have left a Radix
|
||||||
|
// portal in document.body. Clear them before each test so a
|
||||||
|
// "drawer not in DOM" assertion isn't polluted by a stale portal.
|
||||||
|
document.body
|
||||||
|
.querySelectorAll('[role="dialog"]')
|
||||||
|
.forEach((node) => node.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// SP21 Phase 5 Task 5.5: the RemitDrawer portals into
|
||||||
|
// document.body. Each test renders + unmounts, but happy-dom's
|
||||||
|
// document.body persists across tests within the file, so we
|
||||||
|
// explicitly remove any lingering Radix portals here. Without
|
||||||
|
// this, a test that asserts `drawer not in DOM` would still
|
||||||
|
// find a leftover portal from the previous test.
|
||||||
|
document.body
|
||||||
|
.querySelectorAll('[role="dialog"]')
|
||||||
|
.forEach((node) => node.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 4.6: deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
|
||||||
|
// Non-empty unmatched payload so the page renders the two-column
|
||||||
|
// matching surface (the "Pair them." branch). The pre-existing
|
||||||
|
// empty-state branch has a flaky happy-dom/race that's unrelated
|
||||||
|
// to Phase 4 — using non-empty data sidesteps that bug and still
|
||||||
|
// proves the deep-link → drawer mount works. We pre-set
|
||||||
|
// `window.location` (which `useRemitDrawerUrlState` reads on
|
||||||
|
// mount) so `?remit=REM-7` resolves to a truthy `remitId`.
|
||||||
|
(
|
||||||
|
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
claims: [
|
||||||
|
{
|
||||||
|
id: "CLM-1",
|
||||||
|
patientName: "Patient A",
|
||||||
|
billedAmount: 100,
|
||||||
|
providerNpi: "1234567890",
|
||||||
|
serviceDate: "2026-06-01",
|
||||||
|
payerId: "P1",
|
||||||
|
state: "submitted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
remittances: [
|
||||||
|
{
|
||||||
|
id: "REM-7",
|
||||||
|
payerClaimControlNumber: "PCN-A",
|
||||||
|
status: "received",
|
||||||
|
paidAmount: 100,
|
||||||
|
adjustmentAmount: 0,
|
||||||
|
receivedDate: "2026-06-01",
|
||||||
|
isReversal: false,
|
||||||
|
totalCharge: 100,
|
||||||
|
serviceDate: "2026-06-01",
|
||||||
|
batchId: "b1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
|
||||||
|
.happyDOM.setURL("http://localhost/reconciliation?remit=REM-7");
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for the loaded two-column view (the "Pair them." headline
|
||||||
|
// is the clearest signal that listUnmatched has resolved and the
|
||||||
|
// page is past the loading + empty branches), then assert the
|
||||||
|
// drawer is mounted.
|
||||||
|
await waitForText("Pair them.");
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="remit-drawer"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// Reset URL so the next test sees a clean /reconciliation URL.
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
|
||||||
|
.happyDOM.setURL("http://localhost/reconciliation");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders both columns with unmatched rows when api returns data", async () => {
|
it("renders both columns with unmatched rows when api returns data", async () => {
|
||||||
@@ -120,7 +263,8 @@ describe("ReconciliationPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { unmount } = renderIntoContainer(
|
const { unmount } = renderIntoContainer(
|
||||||
React.createElement(ReconciliationPage)
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true },
|
||||||
);
|
);
|
||||||
await waitForText("CLM-1");
|
await waitForText("CLM-1");
|
||||||
|
|
||||||
@@ -138,7 +282,8 @@ describe("ReconciliationPage", () => {
|
|||||||
).mockResolvedValue({ claims: [], remittances: [] });
|
).mockResolvedValue({ claims: [], remittances: [] });
|
||||||
|
|
||||||
const { unmount } = renderIntoContainer(
|
const { unmount } = renderIntoContainer(
|
||||||
React.createElement(ReconciliationPage)
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true },
|
||||||
);
|
);
|
||||||
await waitForText("nothing pending");
|
await waitForText("nothing pending");
|
||||||
|
|
||||||
@@ -148,4 +293,213 @@ describe("ReconciliationPage", () => {
|
|||||||
expect(document.body.textContent).not.toContain("Unmatched claims (");
|
expect(document.body.textContent).not.toContain("Unmatched claims (");
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.5: clicking the claim card body drills to /claims?claim=ID", async () => {
|
||||||
|
// Task 5.5 splits the gesture: clicking the card body drills into
|
||||||
|
// the ClaimDrawer via /claims?claim=ID, while a separate
|
||||||
|
// "Select for match" button toggles the row's selection. The
|
||||||
|
// card is no longer a single <button>; the body region is a
|
||||||
|
// button with its own onClick that calls navigate().
|
||||||
|
(
|
||||||
|
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
claims: [
|
||||||
|
{
|
||||||
|
id: "CLM-DRILL",
|
||||||
|
patientName: "Patient Drill",
|
||||||
|
billedAmount: 250,
|
||||||
|
providerNpi: "1234567890",
|
||||||
|
serviceDate: "2026-06-19",
|
||||||
|
payerId: "P1",
|
||||||
|
state: "submitted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
remittances: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount, tracker } = renderIntoContainer(
|
||||||
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true, initialEntries: ["/reconciliation"] },
|
||||||
|
);
|
||||||
|
await waitForText("CLM-DRILL");
|
||||||
|
|
||||||
|
// Click the claim card body (the body region is the inner <button>
|
||||||
|
// that contains the id). It's not the "Select for match" toggle
|
||||||
|
// — the body region has aria-label="View claim CLM-DRILL in detail".
|
||||||
|
const bodyBtn = document.body.querySelector(
|
||||||
|
'button[aria-label="View claim CLM-DRILL in detail"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(bodyBtn).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
bodyBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tracker?.pathname).toBe("/claims");
|
||||||
|
expect(tracker?.search).toBe("?claim=CLM-DRILL");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.5: clicking the 'Select for match' button toggles selection without drilling", async () => {
|
||||||
|
// The select button calls e.stopPropagation() before setSelectedClaim,
|
||||||
|
// so the row body click handler doesn't fire. Verify the URL stays
|
||||||
|
// on /reconciliation and the "Match selected" button becomes enabled
|
||||||
|
// (or at least that selection state advances).
|
||||||
|
(
|
||||||
|
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
claims: [
|
||||||
|
{
|
||||||
|
id: "CLM-SEL",
|
||||||
|
patientName: "Patient Select",
|
||||||
|
billedAmount: 175,
|
||||||
|
providerNpi: "1234567890",
|
||||||
|
serviceDate: "2026-06-19",
|
||||||
|
payerId: "P1",
|
||||||
|
state: "submitted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
remittances: [
|
||||||
|
{
|
||||||
|
id: "REM-SEL",
|
||||||
|
payerClaimControlNumber: "PCN-SEL",
|
||||||
|
status: "received",
|
||||||
|
paidAmount: 175,
|
||||||
|
adjustmentAmount: 0,
|
||||||
|
receivedDate: "2026-06-19",
|
||||||
|
isReversal: false,
|
||||||
|
totalCharge: 175,
|
||||||
|
serviceDate: "2026-06-19",
|
||||||
|
batchId: "b1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount, tracker } = renderIntoContainer(
|
||||||
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true },
|
||||||
|
);
|
||||||
|
await waitForText("CLM-SEL");
|
||||||
|
|
||||||
|
// Click the claim "Select for match" toggle. It has
|
||||||
|
// data-testid="recon-claim-select-CLM-SEL" and should NOT drill.
|
||||||
|
const selectBtn = document.body.querySelector(
|
||||||
|
'[data-testid="recon-claim-select-CLM-SEL"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(selectBtn).not.toBeNull();
|
||||||
|
expect(selectBtn!.textContent).toContain("Select for match");
|
||||||
|
await act(async () => {
|
||||||
|
selectBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// URL stays on /reconciliation — selection didn't drill.
|
||||||
|
expect(tracker?.pathname).toBe("/reconciliation");
|
||||||
|
// The toggle button now reads "Unselect" (clicked once).
|
||||||
|
const updatedBtn = document.body.querySelector(
|
||||||
|
'[data-testid="recon-claim-select-CLM-SEL"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(updatedBtn?.textContent).toContain("Unselect");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.5: clicking the remit card body opens the RemitDrawer", async () => {
|
||||||
|
// Task 5.5 also restructures the remits column — the body is no
|
||||||
|
// longer role="button" with a select-onClick handler. The body
|
||||||
|
// drills into the RemitDrawer via open(r.id); selection moves to
|
||||||
|
// a dedicated "Select for match" toggle.
|
||||||
|
(
|
||||||
|
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
claims: [],
|
||||||
|
remittances: [
|
||||||
|
{
|
||||||
|
id: "REM-DRILL",
|
||||||
|
payerClaimControlNumber: "PCN-DRILL",
|
||||||
|
status: "received",
|
||||||
|
paidAmount: 100,
|
||||||
|
adjustmentAmount: 0,
|
||||||
|
receivedDate: "2026-06-19",
|
||||||
|
isReversal: false,
|
||||||
|
totalCharge: 100,
|
||||||
|
serviceDate: "2026-06-19",
|
||||||
|
batchId: "b1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true },
|
||||||
|
);
|
||||||
|
await waitForText("PCN-DRILL");
|
||||||
|
|
||||||
|
// Click the remit card body (inner <button> with aria-label).
|
||||||
|
const bodyBtn = document.body.querySelector(
|
||||||
|
'button[aria-label="View remittance PCN-DRILL in detail"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(bodyBtn).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
bodyBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// RemitDrawer should be mounted in document.body.
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="remit-drawer"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SP21 Task 5.5: clicking the remit 'Select for match' button toggles selection without opening drawer", async () => {
|
||||||
|
// The remit select toggle has e.stopPropagation() — clicking it
|
||||||
|
// shouldn't open the RemitDrawer (which used to be a side effect
|
||||||
|
// when the outer card was role="button" with onClick=select).
|
||||||
|
(
|
||||||
|
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({
|
||||||
|
claims: [],
|
||||||
|
remittances: [
|
||||||
|
{
|
||||||
|
id: "REM-SEL",
|
||||||
|
payerClaimControlNumber: "PCN-SEL2",
|
||||||
|
status: "received",
|
||||||
|
paidAmount: 100,
|
||||||
|
adjustmentAmount: 0,
|
||||||
|
receivedDate: "2026-06-19",
|
||||||
|
isReversal: false,
|
||||||
|
totalCharge: 100,
|
||||||
|
serviceDate: "2026-06-19",
|
||||||
|
batchId: "b1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
React.createElement(ReconciliationPage),
|
||||||
|
{ withRouter: true },
|
||||||
|
);
|
||||||
|
await waitForText("PCN-SEL2");
|
||||||
|
|
||||||
|
const selectBtn = document.body.querySelector(
|
||||||
|
'[data-testid="recon-remit-select-REM-SEL"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(selectBtn).not.toBeNull();
|
||||||
|
expect(selectBtn!.textContent).toContain("Select for match");
|
||||||
|
await act(async () => {
|
||||||
|
selectBtn!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// No drawer.
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="remit-drawer"]'),
|
||||||
|
).toBeNull();
|
||||||
|
// Toggle flipped.
|
||||||
|
const updated = document.body.querySelector(
|
||||||
|
'[data-testid="recon-remit-select-REM-SEL"]',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(updated?.textContent).toContain("Unselect");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import {
|
|||||||
GitMerge,
|
GitMerge,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useReconciliation } from "@/hooks/useReconciliation";
|
import { useReconciliation } from "@/hooks/useReconciliation";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { ErrorState } from "@/components/ui/error-state";
|
import { ErrorState } from "@/components/ui/error-state";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { RemitDrawer } from "@/components/RemitDrawer";
|
||||||
|
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +29,16 @@ import { cn } from "@/lib/utils";
|
|||||||
*/
|
*/
|
||||||
export function ReconciliationPage() {
|
export function ReconciliationPage() {
|
||||||
const { unmatched, match } = useReconciliation();
|
const { unmatched, match } = useReconciliation();
|
||||||
|
// SP21 Phase 4 Task 4.6: drill into the RemitDrawer from the
|
||||||
|
// remits column. The hook reads `?remit=` from the URL so deep
|
||||||
|
// links land with the drawer open. Selection state stays local
|
||||||
|
// — the drill is a separate gesture from the match selection.
|
||||||
|
const { remitId, open, close } = useRemitDrawerUrlState();
|
||||||
|
// SP21 Phase 5 Task 5.5: claim drill from the card body navigates
|
||||||
|
// to /claims?claim=ID so the ClaimDrawer mounts in-place on the
|
||||||
|
// /claims route. Selection for matching stays local — the drill
|
||||||
|
// gesture is separate from the match selection gesture.
|
||||||
|
const navigate = useNavigate();
|
||||||
const [selectedClaim, setSelectedClaim] = useState<string | null>(null);
|
const [selectedClaim, setSelectedClaim] = useState<string | null>(null);
|
||||||
const [selectedRemit, setSelectedRemit] = useState<string | null>(null);
|
const [selectedRemit, setSelectedRemit] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -650,17 +663,40 @@ export function ReconciliationPage() {
|
|||||||
{claims.map((c) => {
|
{claims.map((c) => {
|
||||||
const active = selectedClaim === c.id;
|
const active = selectedClaim === c.id;
|
||||||
return (
|
return (
|
||||||
<button
|
// SP21 Phase 5 Task 5.5: card body drills into the
|
||||||
|
// ClaimDrawer; selection for matching lives on a
|
||||||
|
// dedicated button inside the card. The card is no
|
||||||
|
// longer a single <button> — it's a <div> with a
|
||||||
|
// clickable body region (cursor-pointer) and a
|
||||||
|
// separate "Select for match" toggle. We split the
|
||||||
|
// gestures so the operator can review the claim
|
||||||
|
// without accidentally queuing it for pairing.
|
||||||
|
<div
|
||||||
key={c.id}
|
key={c.id}
|
||||||
type="button"
|
data-testid="recon-claim-card"
|
||||||
onClick={() => setSelectedClaim(c.id)}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
|
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
|
||||||
active
|
active
|
||||||
? "border-[hsl(212_100%_45%)] bg-[hsl(212_85%_95%)] ring-1 ring-inset ring-[hsl(212_100%_45%_/_0.30)]"
|
? "border-[hsl(212_100%_45%)] bg-[hsl(212_85%_95%)] ring-1 ring-inset ring-[hsl(212_100%_45%_/_0.30)]"
|
||||||
: "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]"
|
: "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]"
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{/* Body — drillable into /claims?claim=ID. We
|
||||||
|
use a button (not an <a>) because we're
|
||||||
|
staying on the SPA; the ClaimDrawer mounts
|
||||||
|
when Claims.tsx reads ?claim= off the URL.
|
||||||
|
e.stopPropagation in DrillableCell prevents
|
||||||
|
the click from reaching the select button
|
||||||
|
below. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/claims?claim=${encodeURIComponent(c.id)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label={`View claim ${c.id} in detail`}
|
||||||
|
className="w-full text-left cursor-pointer rounded-sm p-0 m-0 border-0 bg-transparent"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="display mono text-[13.5px]"
|
className="display mono text-[13.5px]"
|
||||||
@@ -683,6 +719,33 @@ export function ReconciliationPage() {
|
|||||||
{c.providerNpi ?? "—"}
|
{c.providerNpi ?? "—"}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{/* Selection toggle — separate gesture so a drill
|
||||||
|
doesn't auto-select the row for matching. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedClaim(active ? null : c.id);
|
||||||
|
}}
|
||||||
|
aria-pressed={active}
|
||||||
|
data-testid={`recon-claim-select-${c.id}`}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm text-[10.5px] mono uppercase tracking-[0.16em] font-semibold transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-[hsl(212_100%_45%)] text-white"
|
||||||
|
: "bg-[hsl(36_22%_92%)] text-[hsl(var(--surface-ink-2))] hover:bg-[hsl(212_85%_92%)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<X className="h-3 w-3" strokeWidth={2.5} />
|
||||||
|
Unselect
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Select for match</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</PairColumn>
|
</PairColumn>
|
||||||
@@ -735,23 +798,39 @@ export function ReconciliationPage() {
|
|||||||
{remittances.map((r) => {
|
{remittances.map((r) => {
|
||||||
const active = selectedRemit === r.id;
|
const active = selectedRemit === r.id;
|
||||||
return (
|
return (
|
||||||
<button
|
// SP21 Phase 5 Task 5.5: card body drills into the
|
||||||
|
// RemitDrawer; selection for matching lives on a
|
||||||
|
// dedicated button. Same gesture-split as the
|
||||||
|
// claims column — body = drill, button = select.
|
||||||
|
<div
|
||||||
key={r.id}
|
key={r.id}
|
||||||
type="button"
|
data-testid="recon-remit-card"
|
||||||
onClick={() => setSelectedRemit(r.id)}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
|
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
|
||||||
active
|
active
|
||||||
? "border-[hsl(36_92%_50%)] bg-[hsl(36_82%_92%)] ring-1 ring-inset ring-[hsl(36_92%_50%_/_0.30)]"
|
? "border-[hsl(36_92%_50%)] bg-[hsl(36_82%_92%)] ring-1 ring-inset ring-[hsl(36_92%_50%_/_0.30)]"
|
||||||
: "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]"
|
: "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]"
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{/* Body — drillable. The whole body is one
|
||||||
|
<button>; no nested DrillableCell (a <button>
|
||||||
|
inside a <button> is invalid HTML and warns
|
||||||
|
in the console). The PCN still gets the
|
||||||
|
chevron affordance via the `drillable` class
|
||||||
|
added directly to the inner span. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => open(r.id)}
|
||||||
|
aria-label={`View remittance ${r.payerClaimControlNumber} in detail`}
|
||||||
|
className="w-full text-left cursor-pointer rounded-sm p-0 m-0 border-0 bg-transparent"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mono text-[13.5px] flex items-center gap-2"
|
className="mono text-[13.5px] flex items-center gap-2"
|
||||||
style={{ color: "hsl(var(--surface-ink))" }}
|
style={{ color: "hsl(var(--surface-ink))" }}
|
||||||
>
|
>
|
||||||
<span className="display">{r.payerClaimControlNumber}</span>
|
<span className="display drillable inline-flex items-center gap-0 rounded-sm">
|
||||||
|
{r.payerClaimControlNumber}
|
||||||
|
</span>
|
||||||
{r.isReversal ? (
|
{r.isReversal ? (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] uppercase tracking-[0.18em] mono font-semibold"
|
className="text-[10px] uppercase tracking-[0.18em] mono font-semibold"
|
||||||
@@ -769,6 +848,33 @@ export function ReconciliationPage() {
|
|||||||
{r.adjustmentAmount.toFixed(2)} adj
|
{r.adjustmentAmount.toFixed(2)} adj
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{/* Selection toggle — separate gesture from
|
||||||
|
drill. Same pattern as the claims column. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedRemit(active ? null : r.id);
|
||||||
|
}}
|
||||||
|
aria-pressed={active}
|
||||||
|
data-testid={`recon-remit-select-${r.id}`}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm text-[10.5px] mono uppercase tracking-[0.16em] font-semibold transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-[hsl(36_92%_50%)] text-[hsl(30_14%_14%)]"
|
||||||
|
: "bg-[hsl(36_22%_92%)] text-[hsl(var(--surface-ink-2))] hover:bg-[hsl(36_82%_88%)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<X className="h-3 w-3" strokeWidth={2.5} />
|
||||||
|
Unselect
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Select for match</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</PairColumn>
|
</PairColumn>
|
||||||
@@ -881,6 +987,22 @@ export function ReconciliationPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* SP21 Phase 4 Task 4.6: RemitDrawer mount. The remits column
|
||||||
|
drills into the parent remit via the PCN text (DrillableCell
|
||||||
|
+ open). The drawer portals into document.body, so the
|
||||||
|
surrounding paper plane stays put while the drawer is open.
|
||||||
|
`remits` is empty (we don't keep a list of all remits on
|
||||||
|
this page), so j/k is a no-op while the drawer is open. */}
|
||||||
|
<RemitDrawer
|
||||||
|
remitId={remitId}
|
||||||
|
remits={[]}
|
||||||
|
onClose={close}
|
||||||
|
onNavigate={open}
|
||||||
|
onToggleHelp={() => {
|
||||||
|
// Reconciliation has no cheatsheet; `?` is a no-op here.
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ vi.mock("@/lib/api", () => ({
|
|||||||
listRemittances: vi.fn(),
|
listRemittances: vi.fn(),
|
||||||
getRemittance: vi.fn(),
|
getRemittance: vi.fn(),
|
||||||
},
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
constructor(public status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the live-tail hook so the page renders the pill in the settled
|
// Mock the live-tail hook so the page renders the pill in the settled
|
||||||
@@ -128,6 +133,17 @@ function rowAt(idx: number): HTMLTableRowElement | null {
|
|||||||
) as HTMLTableRowElement | null;
|
) as HTMLTableRowElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 page at `/remittances?remit=PCN-1` etc.
|
||||||
|
* Same helper used by Claims.test.tsx and useRemitDrawerUrlState.test.ts.
|
||||||
|
*/
|
||||||
|
function setLocation(url: string): void {
|
||||||
|
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
/** True iff exactly one row carries `data-state="selected"`. */
|
/** True iff exactly one row carries `data-state="selected"`. */
|
||||||
function hasExactlyOneSelectedRow(): boolean {
|
function hasExactlyOneSelectedRow(): boolean {
|
||||||
const selected = document.querySelectorAll(
|
const selected = document.querySelectorAll(
|
||||||
@@ -191,6 +207,11 @@ const SAMPLE_REMITS = [
|
|||||||
describe("Remittances", () => {
|
describe("Remittances", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Reset URL to the bare remittances page between tests so a
|
||||||
|
// `?remit=` leaked from a prior test (via pushState) doesn't
|
||||||
|
// bleed into the next. happy-dom's URL survives across tests in
|
||||||
|
// the same file unless explicitly reset.
|
||||||
|
setLocation("http://localhost/remittances");
|
||||||
// Singleton tail-store: clear the remittances slice between tests
|
// Singleton tail-store: clear the remittances slice between tests
|
||||||
// so a tail-arrival case (if added later) doesn't see rows from a
|
// so a tail-arrival case (if added later) doesn't see rows from a
|
||||||
// previous test.
|
// previous test.
|
||||||
@@ -203,18 +224,26 @@ describe("Remittances", () => {
|
|||||||
returned: SAMPLE_REMITS.length,
|
returned: SAMPLE_REMITS.length,
|
||||||
has_more: false,
|
has_more: false,
|
||||||
});
|
});
|
||||||
|
// Default for the per-remit detail fetch — the drawer fetches
|
||||||
|
// this whenever `?remit=` is in the URL. Return a never-resolving
|
||||||
|
// promise so the drawer stays in the loading state; the smoke
|
||||||
|
// tests only assert the drawer mounts, not the loaded data.
|
||||||
|
(
|
||||||
|
api.getRemittance as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(new Promise(() => {}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a CAS adjustment label inside the expanded detail row", async () => {
|
it("clicking a row opens the RemitDrawer (no more inline expand)", async () => {
|
||||||
|
// SP21 Phase 4 Task 4.3: the inline CAS expansion is gone — the
|
||||||
|
// whole row now drills into the RemitDrawer via `?remit=ID`. The
|
||||||
|
// CAS panel is now inside the drawer, not in a second <tr>.
|
||||||
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
||||||
await waitForText("PCN-1");
|
await waitForText("PCN-1");
|
||||||
|
|
||||||
// The chevron + "Adjustments" header should not yet be visible because
|
// No drawer in the DOM yet — the URL has no `?remit=`.
|
||||||
// the row hasn't been expanded yet.
|
expect(document.body.querySelector('[data-testid="remit-drawer"]')).toBeNull();
|
||||||
expect(document.body.textContent).not.toContain("Adjustments (2)");
|
|
||||||
|
|
||||||
// Expand the row by clicking on the remit ID cell. We click the parent
|
// Click the row containing PCN-1.
|
||||||
// row by selecting the cell containing "PCN-1" and bubbling up.
|
|
||||||
const cell = Array.from(document.querySelectorAll("td")).find(
|
const cell = Array.from(document.querySelectorAll("td")).find(
|
||||||
(td) => td.textContent === "PCN-1"
|
(td) => td.textContent === "PCN-1"
|
||||||
);
|
);
|
||||||
@@ -223,16 +252,36 @@ describe("Remittances", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
(row as HTMLTableRowElement).click();
|
(row as HTMLTableRowElement).click();
|
||||||
});
|
});
|
||||||
await waitForText("Adjustments (2)");
|
|
||||||
|
|
||||||
// Both CAS labels must surface (not the raw codes alone).
|
// The drawer must mount into document.body via Radix's portal.
|
||||||
expect(document.body.textContent).toContain(
|
await settle(
|
||||||
"Charge exceeds fee schedule/maximum allowable"
|
() => document.body.querySelector('[data-testid="remit-drawer"]') !== null
|
||||||
);
|
);
|
||||||
expect(document.body.textContent).toContain("Deductible amount");
|
expect(
|
||||||
// Group/reason pills show the CARC code alongside.
|
document.body.querySelector('[data-testid="remit-drawer"]')
|
||||||
expect(document.body.textContent).toContain("CO-45");
|
).not.toBeNull();
|
||||||
expect(document.body.textContent).toContain("PR-1");
|
|
||||||
|
// URL must reflect the open remit.
|
||||||
|
expect(window.location.search).toContain("remit=PCN-1");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deep-link ?remit=ID opens the drawer on mount", async () => {
|
||||||
|
// Pre-set the URL so the hook reads PCN-1 off `window.location.search`
|
||||||
|
// during its `useState` initializer — no click needed.
|
||||||
|
setLocation("http://localhost/remittances?remit=PCN-1");
|
||||||
|
|
||||||
|
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
||||||
|
await settle(
|
||||||
|
() => document.body.querySelector('[data-testid="remit-drawer"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drawer should appear immediately, without user interaction.
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="remit-drawer"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+39
-97
@@ -1,5 +1,4 @@
|
|||||||
import { Fragment, useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -17,13 +16,15 @@ import { Pagination } from "@/components/ui/pagination";
|
|||||||
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
import { TailStatusPill } from "@/components/TailStatusPill";
|
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||||
|
import { RemitDrawer } from "@/components/RemitDrawer";
|
||||||
import { useRemittances } from "@/hooks/useRemittances";
|
import { useRemittances } from "@/hooks/useRemittances";
|
||||||
|
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||||
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
||||||
import { useTailStream } from "@/hooks/useTailStream";
|
import { useTailStream } from "@/hooks/useTailStream";
|
||||||
import { useMergedTail } from "@/hooks/useMergedTail";
|
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CasAdjustment, Remittance, RemittanceStatus } from "@/types";
|
import type { Remittance, RemittanceStatus } from "@/types";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -33,39 +34,20 @@ const STATUS_OPTIONS: FilterChipOption[] = [
|
|||||||
{ value: "reconciled", label: "Reconciled" },
|
{ value: "reconciled", label: "Reconciled" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* One persisted CAS row, rendered as a "code — label" pair plus the
|
|
||||||
* dollar amount. Lives inside the expanded detail row of a remit so
|
|
||||||
* the operator can see exactly why the payer adjusted the claim.
|
|
||||||
*/
|
|
||||||
function AdjustmentRow({ adj }: { adj: CasAdjustment }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start justify-between gap-4 py-2 border-b border-border/30 last:border-0">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="mono text-[10.5px] text-muted-foreground">
|
|
||||||
{adj.group}-{adj.reason}
|
|
||||||
{adj.quantity !== null ? (
|
|
||||||
<span className="ml-2 text-muted-foreground/60">
|
|
||||||
qty {adj.quantity}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="text-[12.5px] text-foreground/90 truncate">{adj.label}</div>
|
|
||||||
</div>
|
|
||||||
<div className="display mono text-[12.5px] tabular-nums whitespace-nowrap text-muted-foreground">
|
|
||||||
{fmt.usdPrecise(adj.amount)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Remittances() {
|
export function Remittances() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [status, setStatus] = useState<RemittanceStatus | null>(null);
|
const [status, setStatus] = useState<RemittanceStatus | null>(null);
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
|
||||||
|
// SP21 Phase 4 Task 4.3: row click → RemitDrawer. The drawer is
|
||||||
|
// URL-driven (`?remit=ID`) so deep links restore the open remit
|
||||||
|
// on reload — same pattern as the ClaimDrawer on /claims.
|
||||||
|
// `remits` is the j/k navigation list (the current page of rows).
|
||||||
|
// `setRemitId` (REPLACE history, not push) is what j/k uses so a
|
||||||
|
// single keypress doesn't add a history entry.
|
||||||
|
const { remitId, open, close, setRemitId } = useRemitDrawerUrlState();
|
||||||
|
|
||||||
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({
|
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({
|
||||||
sort: "receivedDate",
|
sort: "receivedDate",
|
||||||
order: "desc",
|
order: "desc",
|
||||||
@@ -93,15 +75,6 @@ export function Remittances() {
|
|||||||
{ paid: 0, adjustments: 0 }
|
{ paid: 0, adjustments: 0 }
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleExpand = (id: string) => {
|
|
||||||
setExpanded((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveNext = useCallback(() => {
|
const moveNext = useCallback(() => {
|
||||||
setSelectedIndex((i) => {
|
setSelectedIndex((i) => {
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
@@ -119,19 +92,42 @@ export function Remittances() {
|
|||||||
}, [items.length]);
|
}, [items.length]);
|
||||||
|
|
||||||
useRowKeyboard({
|
useRowKeyboard({
|
||||||
enabled: !helpOpen && items.length > 0,
|
// Page-level j/k only fires when the drawer is closed — once
|
||||||
|
// `?remit=` is set, the drawer's own `useDrawerKeyboard` listener
|
||||||
|
// owns the j/k keys (with its own wrap-around semantics over
|
||||||
|
// `remits`). Letting the page-level listener stay active here
|
||||||
|
// would mean a single `j` keypress both advances the drawer's
|
||||||
|
// remittance AND bumps the page-level selectedIndex — exactly
|
||||||
|
// the "double navigation" surprise we want to avoid.
|
||||||
|
enabled: !helpOpen && items.length > 0 && remitId === null,
|
||||||
onNext: moveNext,
|
onNext: moveNext,
|
||||||
onPrev: movePrev,
|
onPrev: movePrev,
|
||||||
onClose: () => setHelpOpen(false),
|
onClose: () => setHelpOpen(false),
|
||||||
onToggleHelp: () => setHelpOpen((v) => !v),
|
onToggleHelp: () => setHelpOpen((v) => !v),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// j/k navigation through the remits list. The drawer's own keyboard
|
||||||
|
// handler (useDrawerKeyboard, only attached while the drawer is
|
||||||
|
// open) uses the same keys with its own wrap-around semantics, so
|
||||||
|
// page-level nav only fires when the drawer is closed.
|
||||||
|
const drawerRemits = useMemo(
|
||||||
|
() => items.map((r) => ({ id: r.id })),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<KeyboardCheatsheet
|
<KeyboardCheatsheet
|
||||||
open={helpOpen}
|
open={helpOpen}
|
||||||
onClose={() => setHelpOpen(false)}
|
onClose={() => setHelpOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
<RemitDrawer
|
||||||
|
remitId={remitId}
|
||||||
|
remits={drawerRemits}
|
||||||
|
onClose={close}
|
||||||
|
onNavigate={setRemitId}
|
||||||
|
onToggleHelp={() => setHelpOpen((v) => !v)}
|
||||||
|
/>
|
||||||
<div className="space-y-6 lg:space-y-8 animate-fade-in">
|
<div className="space-y-6 lg:space-y-8 animate-fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Remittances"
|
eyebrow="Remittances"
|
||||||
@@ -204,7 +200,6 @@ export function Remittances() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-8" aria-label="Expand" />
|
|
||||||
<TableHead>Remit</TableHead>
|
<TableHead>Remit</TableHead>
|
||||||
<TableHead>Claim</TableHead>
|
<TableHead>Claim</TableHead>
|
||||||
<TableHead>Payer</TableHead>
|
<TableHead>Payer</TableHead>
|
||||||
@@ -216,45 +211,21 @@ export function Remittances() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((r, idx) => {
|
{items.map((r, idx) => {
|
||||||
const isOpen = expanded.has(r.id);
|
|
||||||
const hasAdjustments =
|
|
||||||
!!r.adjustments && r.adjustments.length > 0;
|
|
||||||
const isSelected = selectedIndex === idx;
|
const isSelected = selectedIndex === idx;
|
||||||
return (
|
return (
|
||||||
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
|
|
||||||
<TableRow
|
<TableRow
|
||||||
|
key={`${r.id}-${dataUpdatedAt}`}
|
||||||
data-row-index={idx}
|
data-row-index={idx}
|
||||||
data-state={isSelected ? "selected" : undefined}
|
data-state={isSelected ? "selected" : undefined}
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
className={cn(
|
className={cn(
|
||||||
"animate-row-flash",
|
"animate-row-flash cursor-pointer drillable",
|
||||||
isSelected && [
|
isSelected && [
|
||||||
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
|
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
|
||||||
],
|
],
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() => open(r.id)}
|
||||||
hasAdjustments ? toggleExpand(r.id) : undefined
|
|
||||||
}
|
|
||||||
aria-expanded={hasAdjustments ? isOpen : undefined}
|
|
||||||
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
|
|
||||||
>
|
>
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{hasAdjustments ? (
|
|
||||||
isOpen ? (
|
|
||||||
<ChevronDown
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
strokeWidth={1.75}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChevronRight
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
strokeWidth={1.75}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="display mono text-[12.5px]">{r.id}</TableCell>
|
<TableCell className="display mono text-[12.5px]">{r.id}</TableCell>
|
||||||
<TableCell className="display mono text-[12.5px] text-muted-foreground">
|
<TableCell className="display mono text-[12.5px] text-muted-foreground">
|
||||||
{r.claimId}
|
{r.claimId}
|
||||||
@@ -273,35 +244,6 @@ export function Remittances() {
|
|||||||
{fmt.dateShort(r.receivedDate)}
|
{fmt.dateShort(r.receivedDate)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{isOpen && hasAdjustments ? (
|
|
||||||
<TableRow
|
|
||||||
key={`${r.id}-${dataUpdatedAt}-detail`}
|
|
||||||
className="bg-muted/20 hover:bg-muted/20"
|
|
||||||
>
|
|
||||||
<TableCell />
|
|
||||||
<TableCell colSpan={7} className="py-3">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Receipt
|
|
||||||
className="h-3.5 w-3.5 text-muted-foreground"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<div className="eyebrow">
|
|
||||||
Adjustments ({r.adjustments!.length})
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pl-5">
|
|
||||||
{r.adjustments!.map((adj, i) => (
|
|
||||||
<AdjustmentRow
|
|
||||||
key={`${adj.group}-${adj.reason}-${i}`}
|
|
||||||
adj={adj}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : null}
|
|
||||||
</Fragment>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// Tell React this is an `act`-aware test environment so react-query's
|
||||||
|
// internal state updates flush through without noisy console warnings.
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||||
|
true;
|
||||||
|
|
||||||
|
import React, { act, useEffect } from "react";
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { ClaimCard837, ClaimCard835 } from "./Upload";
|
||||||
|
import { useAppStore } from "@/store";
|
||||||
|
import type { ClaimOutput, ClaimPayment, ParsedBatch } from "@/types";
|
||||||
|
|
||||||
|
// Fixtures — kept tiny, just enough to exercise the drill logic.
|
||||||
|
const CLAIM_837: ClaimOutput = {
|
||||||
|
claim_id: "CLM-PERSISTED",
|
||||||
|
subscriber: {
|
||||||
|
first_name: "Jane",
|
||||||
|
last_name: "Doe",
|
||||||
|
member_id: "MEM-1",
|
||||||
|
},
|
||||||
|
payer: { name: "Test Payer", id: "P1" },
|
||||||
|
billing_provider: { npi: "1234567890" },
|
||||||
|
claim: {
|
||||||
|
total_charge: 100,
|
||||||
|
place_of_service: "11",
|
||||||
|
frequency_code: "1",
|
||||||
|
prior_auth: null,
|
||||||
|
},
|
||||||
|
service_lines: [
|
||||||
|
{
|
||||||
|
line_number: 1,
|
||||||
|
procedure: { qualifier: "HC", code: "99213", modifiers: [] },
|
||||||
|
charge: "100",
|
||||||
|
units: "1",
|
||||||
|
unit_type: "UN",
|
||||||
|
service_date: "2026-06-01",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnoses: [{ qualifier: "ABK", code: "J20.9" }],
|
||||||
|
validation: { passed: true, errors: [], warnings: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLAIM_835: ClaimPayment = {
|
||||||
|
payer_claim_control_number: "PCN-PERSISTED",
|
||||||
|
status_code: "1",
|
||||||
|
status_label: "Processed as Primary",
|
||||||
|
claim_filing_indicator: "CI",
|
||||||
|
facility_type: "11",
|
||||||
|
frequency_code: "1",
|
||||||
|
total_charge: "100",
|
||||||
|
total_paid: "80",
|
||||||
|
patient_responsibility: "20",
|
||||||
|
service_payments: [
|
||||||
|
{
|
||||||
|
line_number: 1,
|
||||||
|
procedure_qualifier: "HC",
|
||||||
|
procedure_code: "99213",
|
||||||
|
modifiers: [],
|
||||||
|
service_date: "2026-06-01",
|
||||||
|
units: "1",
|
||||||
|
unit_type: "UN",
|
||||||
|
charge: "100",
|
||||||
|
payment: "80",
|
||||||
|
adjustments: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
original_claim_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mount the card inside a MemoryRouter so the useNavigate call has
|
||||||
|
// a router context (without this, clicking the drill link would
|
||||||
|
// throw "useNavigate may be used only in a Router").
|
||||||
|
function renderCard(
|
||||||
|
element: React.ReactElement,
|
||||||
|
initialEntries: string[] = ["/upload"],
|
||||||
|
): {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
unmount: () => void;
|
||||||
|
tracker: { pathname: string; search: string };
|
||||||
|
} {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const tracker = { pathname: "/upload", search: "" };
|
||||||
|
const Tracker = () => {
|
||||||
|
const loc = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
tracker.pathname = loc.pathname;
|
||||||
|
tracker.search = loc.search;
|
||||||
|
}, [loc.pathname, loc.search]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const root: Root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(
|
||||||
|
MemoryRouter,
|
||||||
|
{ initialEntries },
|
||||||
|
React.createElement(Tracker, null),
|
||||||
|
element,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
tracker,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ClaimCard837 / ClaimCard835 drill link (SP21 Phase 5 Task 5.7)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset parsedBatches to empty by default — individual tests
|
||||||
|
// populate it as needed.
|
||||||
|
useAppStore.setState({ parsedBatches: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useAppStore.setState({ parsedBatches: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ClaimCard837: renders the drill link when the claim_id is in a persisted batch", async () => {
|
||||||
|
// Pre-populate the store with a parsed batch that contains this
|
||||||
|
// claim id, then expand the card and verify the link is present.
|
||||||
|
const persisted: ParsedBatch = {
|
||||||
|
id: "batch-1",
|
||||||
|
kind: "837p",
|
||||||
|
inputFilename: "test.837",
|
||||||
|
parsedAt: "2026-06-21T12:00:00Z",
|
||||||
|
claimCount: 1,
|
||||||
|
passed: 1,
|
||||||
|
failed: 0,
|
||||||
|
claimIds: ["CLM-PERSISTED"],
|
||||||
|
summary: { total_claims: 1, passed: 1, failed: 0 },
|
||||||
|
};
|
||||||
|
useAppStore.setState({ parsedBatches: [persisted] });
|
||||||
|
|
||||||
|
const { container, unmount } = renderCard(
|
||||||
|
React.createElement(ClaimCard837, { claim: CLAIM_837 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expand the card.
|
||||||
|
const header = container.querySelector("button[aria-expanded]");
|
||||||
|
await act(async () => {
|
||||||
|
(header as HTMLButtonElement).click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The drill link should now be visible.
|
||||||
|
const link = container.querySelector('[data-testid="upload-claim-drill"]');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link?.textContent).toContain("See claim in detail");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ClaimCard837: does NOT render the drill link when the claim_id is not persisted", async () => {
|
||||||
|
// Streaming-only claim — the parsedBatches slice is empty, so
|
||||||
|
// the link must be absent (clicking would 404 ClaimDrawer).
|
||||||
|
const { container, unmount } = renderCard(
|
||||||
|
React.createElement(ClaimCard837, { claim: CLAIM_837 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = container.querySelector("button[aria-expanded]");
|
||||||
|
await act(async () => {
|
||||||
|
(header as HTMLButtonElement).click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="upload-claim-drill"]'),
|
||||||
|
).toBeNull();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ClaimCard837: clicking the drill link navigates to /claims?claim=ID", async () => {
|
||||||
|
const persisted: ParsedBatch = {
|
||||||
|
id: "batch-1",
|
||||||
|
kind: "837p",
|
||||||
|
inputFilename: "test.837",
|
||||||
|
parsedAt: "2026-06-21T12:00:00Z",
|
||||||
|
claimCount: 1,
|
||||||
|
passed: 1,
|
||||||
|
failed: 0,
|
||||||
|
claimIds: ["CLM-PERSISTED"],
|
||||||
|
summary: { total_claims: 1, passed: 1, failed: 0 },
|
||||||
|
};
|
||||||
|
useAppStore.setState({ parsedBatches: [persisted] });
|
||||||
|
|
||||||
|
const { container, unmount, tracker } = renderCard(
|
||||||
|
React.createElement(ClaimCard837, { claim: CLAIM_837 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = container.querySelector("button[aria-expanded]");
|
||||||
|
await act(async () => {
|
||||||
|
(header as HTMLButtonElement).click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = container.querySelector(
|
||||||
|
'[data-testid="upload-claim-drill"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
await act(async () => {
|
||||||
|
link.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tracker.pathname).toBe("/claims");
|
||||||
|
expect(tracker.search).toBe("?claim=CLM-PERSISTED");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ClaimCard835: renders the drill link when the PCN is persisted", async () => {
|
||||||
|
const persisted: ParsedBatch = {
|
||||||
|
id: "batch-1",
|
||||||
|
kind: "835",
|
||||||
|
inputFilename: "test.835",
|
||||||
|
parsedAt: "2026-06-21T12:00:00Z",
|
||||||
|
claimCount: 1,
|
||||||
|
passed: 1,
|
||||||
|
failed: 0,
|
||||||
|
claimIds: ["PCN-PERSISTED"],
|
||||||
|
summary: { total_claims: 1, passed: 1, failed: 0 },
|
||||||
|
};
|
||||||
|
useAppStore.setState({ parsedBatches: [persisted] });
|
||||||
|
|
||||||
|
const { container, unmount, tracker } = renderCard(
|
||||||
|
React.createElement(ClaimCard835, { claim: CLAIM_835 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = container.querySelector("button[aria-expanded]");
|
||||||
|
await act(async () => {
|
||||||
|
(header as HTMLButtonElement).click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = container.querySelector('[data-testid="upload-remit-drill"]');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
(link as HTMLButtonElement).click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 835 cards drill to /remittances?remit=PCN (not /claims), since
|
||||||
|
// the RemitDrawer is the right surface for the payment side.
|
||||||
|
expect(tracker.pathname).toBe("/remittances");
|
||||||
|
expect(tracker.search).toBe("?remit=PCN-PERSISTED");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ClaimCard835: does NOT render the drill link when the PCN is not persisted", async () => {
|
||||||
|
const { container, unmount } = renderCard(
|
||||||
|
React.createElement(ClaimCard835, { claim: CLAIM_835 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = container.querySelector("button[aria-expanded]");
|
||||||
|
await act(async () => {
|
||||||
|
(header as HTMLButtonElement).click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="upload-remit-drill"]'),
|
||||||
|
).toBeNull();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
+75
-2
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -138,10 +139,22 @@ function StatPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
export function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const passed = claim.validation.passed;
|
const passed = claim.validation.passed;
|
||||||
const hasWarnings = claim.validation.warnings.length > 0;
|
const hasWarnings = claim.validation.warnings.length > 0;
|
||||||
|
// SP21 Phase 5 Task 5.7: a "See claim in detail →" link drills to
|
||||||
|
// /claims?claim=ID — but ONLY when this streamed claim_id has
|
||||||
|
// actually been persisted to a parsed batch. The Upload page
|
||||||
|
// streams claims as the parser emits them; until the user clicks
|
||||||
|
// "Save batch" the claim isn't visible to ClaimDrawer.
|
||||||
|
const parsedBatches = useAppStore((s) => s.parsedBatches);
|
||||||
|
const persistedClaimIds = useMemo(
|
||||||
|
() => new Set(parsedBatches.flatMap((b) => b.claimIds)),
|
||||||
|
[parsedBatches],
|
||||||
|
);
|
||||||
|
const canDrill = persistedClaimIds.has(claim.claim_id);
|
||||||
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg overflow-hidden border"
|
className="rounded-lg overflow-hidden border"
|
||||||
@@ -331,6 +344,31 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* SP21 Phase 5 Task 5.7: drill to the persisted claim. Only
|
||||||
|
renders when this streamed claim_id has been saved into
|
||||||
|
a parsed batch (so ClaimDrawer can find it). Before
|
||||||
|
"Save batch" the claim is streaming-only and the link
|
||||||
|
would 404. */}
|
||||||
|
{canDrill ? (
|
||||||
|
<div className="pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/claims?claim=${encodeURIComponent(claim.claim_id)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
data-testid="upload-claim-drill"
|
||||||
|
aria-label={`See claim ${claim.claim_id} in detail`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] mono font-semibold cursor-pointer rounded-sm px-1 -mx-1 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||||
|
style={{ color: "hsl(var(--surface-ink))" }}
|
||||||
|
>
|
||||||
|
See claim in detail
|
||||||
|
<ArrowRight className="h-3 w-3" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -385,9 +423,19 @@ function ServiceLine837Row({ line }: { line: ServiceLine }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClaimCard835({ claim }: { claim: ClaimPayment }) {
|
export function ClaimCard835({ claim }: { claim: ClaimPayment }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const passed = claim.service_payments.length > 0;
|
const passed = claim.service_payments.length > 0;
|
||||||
|
// SP21 Phase 5 Task 5.7: same drill gate as ClaimCard837. Only show
|
||||||
|
// "See claim in detail" once the claim_id is in the persisted
|
||||||
|
// batches (otherwise ClaimDrawer would 404).
|
||||||
|
const parsedBatches = useAppStore((s) => s.parsedBatches);
|
||||||
|
const persistedClaimIds = useMemo(
|
||||||
|
() => new Set(parsedBatches.flatMap((b) => b.claimIds)),
|
||||||
|
[parsedBatches],
|
||||||
|
);
|
||||||
|
const canDrill = persistedClaimIds.has(claim.payer_claim_control_number);
|
||||||
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg overflow-hidden border"
|
className="rounded-lg overflow-hidden border"
|
||||||
@@ -518,6 +566,31 @@ function ClaimCard835({ claim }: { claim: ClaimPayment }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* SP21 Phase 5 Task 5.7: drill to the persisted remit. The
|
||||||
|
835 cards have no separate "claim" (they're the payment
|
||||||
|
side), so the link goes to /remittances?remit=PCN which
|
||||||
|
opens the RemitDrawer for this payment. Same persisted-
|
||||||
|
gate: only show when the PCN is in a saved batch. */}
|
||||||
|
{canDrill ? (
|
||||||
|
<div className="pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/remittances?remit=${encodeURIComponent(claim.payer_claim_control_number)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
data-testid="upload-remit-drill"
|
||||||
|
aria-label={`See remittance ${claim.payer_claim_control_number} in detail`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] mono font-semibold cursor-pointer rounded-sm px-1 -mx-1 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||||
|
style={{ color: "hsl(var(--surface-ink))" }}
|
||||||
|
>
|
||||||
|
See claim in detail
|
||||||
|
<ArrowRight className="h-3 w-3" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
timestamp: claim.submissionDate,
|
timestamp: claim.submissionDate,
|
||||||
npi: claim.providerNpi,
|
npi: claim.providerNpi,
|
||||||
amount: claim.billedAmount,
|
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,
|
...s.activity,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -155,6 +155,23 @@ export interface Activity {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
npi?: string;
|
npi?: string;
|
||||||
amount?: number;
|
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