Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bca4b608a | |||
| 35298907bc | |||
| 22720168c4 | |||
| 1c0d855b8e | |||
| 8db5db7610 | |||
| 786ead8c94 | |||
| 6c773c1159 | |||
| 5c7e9b6168 | |||
| ac87ed4908 | |||
| 4a8ce1a524 | |||
| 5053a1ea8e | |||
| 33fa899217 | |||
| 6fdbceefc2 | |||
| 7290cac643 | |||
| 5e8c7b11ea | |||
| 9c0cec8f0c | |||
| 9a313d2c1b | |||
| 2eb61f16ff | |||
| 12c2913ba1 | |||
| 54440da2cd | |||
| 7427838292 | |||
| b606e8c9a2 | |||
| ac709c07c3 | |||
| 08e83da91d |
+233
@@ -0,0 +1,233 @@
|
||||
// UI/UX Score Loop — pass 1 driver.
|
||||
// Loads each route at three sizes in Chrome Canary, captures screenshots,
|
||||
// logs console errors, runs a small interaction probe per flow, and
|
||||
// writes a JSON report. Does not modify any source files.
|
||||
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
|
||||
const BASE = "http://127.0.0.1:5173";
|
||||
const SHOTS = "/tmp/cyclone-uiux/shots";
|
||||
const REPORT = "/tmp/cyclone-uiux/report.json";
|
||||
|
||||
const SIZES = [
|
||||
{ name: "desktop", w: 1440, h: 900 },
|
||||
{ name: "tablet", w: 768, h: 1024 },
|
||||
{ name: "mobile", w: 375, h: 812 },
|
||||
];
|
||||
|
||||
// Routes to load. path = the route; name = the flow label; ready = a
|
||||
// selector we wait for to consider the page "rendered".
|
||||
const FLOWS = [
|
||||
{ name: "dashboard", path: "/", ready: "aside nav, h1, h2" },
|
||||
{ name: "upload", path: "/upload", ready: "section[aria-label='File upload']" },
|
||||
{ name: "inbox", path: "/inbox", ready: "main, section[aria-label='Queue summary']" },
|
||||
{ name: "claims", path: "/claims", ready: "table, [data-testid='claims-page-body']" },
|
||||
{ name: "claims-denied", path: "/claims?status=denied", ready: "table, [data-testid='claims-page-body']" },
|
||||
{ name: "remittances", path: "/remittances", ready: "main, table" },
|
||||
{ name: "providers", path: "/providers", ready: "main, table" },
|
||||
{ name: "reconciliation",path: "/reconciliation", ready: "main" },
|
||||
{ name: "acks", path: "/acks", ready: "main, table" },
|
||||
{ name: "batches", path: "/batches", ready: "main, table" },
|
||||
{ name: "batch-diff", path: "/batch-diff", ready: "main" },
|
||||
{ name: "activity", path: "/activity", ready: "main" },
|
||||
{ name: "404", path: "/does-not-exist", ready: "main" },
|
||||
];
|
||||
|
||||
async function setupViewports(browser) {
|
||||
const pages = [];
|
||||
for (const size of SIZES) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 });
|
||||
pages.push({ page, size });
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
async function probeFlow(page, flow) {
|
||||
const consoleErrors = [];
|
||||
const pageErrors = [];
|
||||
const failedRequests = [];
|
||||
|
||||
const onConsole = (msg) => {
|
||||
if (msg.type() === "error") consoleErrors.push(msg.text());
|
||||
};
|
||||
const onPageError = (err) => pageErrors.push(err.message);
|
||||
const onRequestFailed = (req) => failedRequests.push(`${req.method()} ${req.url()} :: ${req.failure()?.errorText}`);
|
||||
|
||||
page.on("console", onConsole);
|
||||
page.on("pageerror", onPageError);
|
||||
page.on("requestfailed", onRequestFailed);
|
||||
|
||||
const t0 = Date.now();
|
||||
let rendered = false;
|
||||
let readyError = null;
|
||||
try {
|
||||
await page.goto(`${BASE}${flow.path}`, { waitUntil: "networkidle2", timeout: 15000 });
|
||||
if (flow.ready) {
|
||||
try {
|
||||
await page.waitForSelector(flow.ready, { timeout: 5000 });
|
||||
rendered = true;
|
||||
} catch (e) {
|
||||
readyError = e.message;
|
||||
}
|
||||
} else {
|
||||
rendered = true;
|
||||
}
|
||||
} catch (e) {
|
||||
readyError = e.message;
|
||||
}
|
||||
const loadMs = Date.now() - t0;
|
||||
|
||||
page.off("console", onConsole);
|
||||
page.off("pageerror", onPageError);
|
||||
page.off("requestfailed", onRequestFailed);
|
||||
|
||||
return { rendered, loadMs, readyError, consoleErrors, pageErrors, failedRequests };
|
||||
}
|
||||
|
||||
async function probeInteractions(page, flow) {
|
||||
const findings = [];
|
||||
// Generic a11y / structural probes per flow.
|
||||
try {
|
||||
// Sidebar visible? (md+ shows it; < md hides it)
|
||||
const aside = await page.$("aside");
|
||||
findings.push({ check: "sidebar-present", pass: !!aside });
|
||||
} catch (e) {
|
||||
findings.push({ check: "sidebar-present", pass: false, err: e.message });
|
||||
}
|
||||
try {
|
||||
// Top bar present?
|
||||
const main = await page.$("main#main-content");
|
||||
findings.push({ check: "main-present", pass: !!main });
|
||||
} catch (e) {
|
||||
findings.push({ check: "main-present", pass: false, err: e.message });
|
||||
}
|
||||
try {
|
||||
// H1 or page heading?
|
||||
const heading = await page.evaluate(() => {
|
||||
const h = document.querySelector("h1, h2");
|
||||
return h ? h.textContent?.trim().slice(0, 60) : null;
|
||||
});
|
||||
findings.push({ check: "heading-present", pass: !!heading, value: heading });
|
||||
} catch (e) {
|
||||
findings.push({ check: "heading-present", pass: false, err: e.message });
|
||||
}
|
||||
|
||||
// Flow-specific probes.
|
||||
if (flow.name === "claims" || flow.name === "claims-denied") {
|
||||
try {
|
||||
const chips = await page.$$("[role='radio'], button[role='radio']");
|
||||
findings.push({ check: "status-chips", pass: chips.length >= 1, count: chips.length });
|
||||
} catch (e) {
|
||||
findings.push({ check: "status-chips", pass: false, err: e.message });
|
||||
}
|
||||
try {
|
||||
const search = await page.$("input[placeholder*='Search']");
|
||||
findings.push({ check: "search-input", pass: !!search });
|
||||
} catch (e) {
|
||||
findings.push({ check: "search-input", pass: false, err: e.message });
|
||||
}
|
||||
}
|
||||
if (flow.name === "upload") {
|
||||
try {
|
||||
const dropzone = await page.$("section[aria-label='File upload']");
|
||||
findings.push({ check: "dropzone-present", pass: !!dropzone });
|
||||
const selects = await page.$$("button[role='combobox']");
|
||||
findings.push({ check: "payer-kind-selects", pass: selects.length >= 2, count: selects.length });
|
||||
} catch (e) {
|
||||
findings.push({ check: "upload-elements", pass: false, err: e.message });
|
||||
}
|
||||
}
|
||||
if (flow.name === "inbox") {
|
||||
try {
|
||||
const lanes = await page.$$("main > div > div");
|
||||
findings.push({ check: "lane-cards", pass: lanes.length >= 1, count: lanes.length });
|
||||
} catch (e) {
|
||||
findings.push({ check: "lane-cards", pass: false, err: e.message });
|
||||
}
|
||||
}
|
||||
if (flow.name === "404") {
|
||||
try {
|
||||
const text = await page.evaluate(() => document.body.innerText);
|
||||
findings.push({ check: "404-text", pass: text.includes("404") || text.toLowerCase().includes("doesn't exist") });
|
||||
} catch (e) {
|
||||
findings.push({ check: "404-text", pass: false, err: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(SHOTS, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
headless: "new",
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
const startedAt = new Date().toISOString();
|
||||
const results = [];
|
||||
|
||||
for (const size of SIZES) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 });
|
||||
|
||||
for (const flow of FLOWS) {
|
||||
const probe = await probeFlow(page, flow);
|
||||
const interactions = await probeInteractions(page, flow);
|
||||
const shot = `${SHOTS}/${flow.name}--${size.name}.png`;
|
||||
try {
|
||||
await page.screenshot({ path: shot, fullPage: false });
|
||||
} catch (e) {
|
||||
// ignore — recording in result
|
||||
}
|
||||
results.push({
|
||||
flow: flow.name,
|
||||
path: flow.path,
|
||||
size: size.name,
|
||||
viewport: { w: size.w, h: size.h },
|
||||
probe,
|
||||
interactions,
|
||||
shot,
|
||||
});
|
||||
console.log(
|
||||
`${size.name.padEnd(7)} ${flow.name.padEnd(20)} ` +
|
||||
`render=${probe.rendered} load=${probe.loadMs}ms ` +
|
||||
`consoleErr=${probe.consoleErrors.length} pageErr=${probe.pageErrors.length}`
|
||||
);
|
||||
}
|
||||
|
||||
await page.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Aggregate.
|
||||
const summary = {
|
||||
startedAt,
|
||||
endedAt: new Date().toISOString(),
|
||||
flows: results.length,
|
||||
sizes: SIZES.map((s) => s.name),
|
||||
renderedOk: results.filter((r) => r.probe.rendered).length,
|
||||
withConsoleErrors: results.filter((r) => r.probe.consoleErrors.length > 0).length,
|
||||
withPageErrors: results.filter((r) => r.probe.pageErrors.length > 0).length,
|
||||
withFailedRequests: results.filter((r) => r.probe.failedRequests.length > 0).length,
|
||||
results,
|
||||
};
|
||||
await writeFile(REPORT, JSON.stringify(summary, null, 2));
|
||||
console.log("\nSummary:", JSON.stringify({
|
||||
flows: summary.flows,
|
||||
renderedOk: summary.renderedOk,
|
||||
withConsoleErrors: summary.withConsoleErrors,
|
||||
withPageErrors: summary.withPageErrors,
|
||||
withFailedRequests: summary.withFailedRequests,
|
||||
}, null, 2));
|
||||
console.log("\nReport:", REPORT);
|
||||
console.log("Shots:", SHOTS);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("FATAL", e);
|
||||
process.exit(1);
|
||||
});
|
||||
+316
-11
@@ -35,7 +35,7 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cyclone import __version__, db
|
||||
from cyclone.db import Claim, ClaimState, Remittance
|
||||
from cyclone.db import Batch, Claim, ClaimState, Remittance
|
||||
from sqlalchemy import desc, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from cyclone.inbox_state import apply_999_rejections
|
||||
@@ -420,11 +420,18 @@ async def parse_837(
|
||||
ack_body = _build_and_persist_ack(rec.id)
|
||||
if ack_body is not None:
|
||||
body["ack"] = ack_body
|
||||
# Surface the server-side batch id so the frontend can call
|
||||
# /api/batches/{id}/export-837 (and any other batch-scoped
|
||||
# endpoint) without a separate listBatches round-trip.
|
||||
body["batch_id"] = rec.id
|
||||
return JSONResponse(content=body)
|
||||
|
||||
# Default: NDJSON stream.
|
||||
# Default: NDJSON stream. Pass the server-side batch id so the
|
||||
# streaming client (the React Upload page) can call batch-scoped
|
||||
# endpoints like /api/batches/{id}/export-837 without a separate
|
||||
# GET /api/batches round-trip.
|
||||
return StreamingResponse(
|
||||
_ndjson_stream(result),
|
||||
_ndjson_stream(result, batch_id=rec.id),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
@@ -606,9 +613,12 @@ async def parse_835_endpoint(
|
||||
body["reconciliation"] = _reconciliation_summary_for_batch(rec.id)
|
||||
return JSONResponse(content=body)
|
||||
|
||||
# Default: NDJSON stream.
|
||||
# Default: NDJSON stream. Pass the server-side batch id so the
|
||||
# streaming client can call batch-scoped endpoints without a
|
||||
# separate GET /api/batches round-trip (see /api/parse-837 for the
|
||||
# parallel change).
|
||||
return StreamingResponse(
|
||||
_ndjson_stream_835(result),
|
||||
_ndjson_stream_835(result, batch_id=rec.id),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
@@ -1295,6 +1305,215 @@ def inbox_resubmit_rejected(
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/batches/{batch_id}/export-837")
|
||||
def export_batch_837(request: Request, batch_id: str, body: dict):
|
||||
"""Download a ZIP of regenerated X12 837 files for the requested claim_ids.
|
||||
|
||||
Body shape: ``{"claim_ids": [str, ...]}``.
|
||||
|
||||
Each successfully serialized claim becomes an entry in the ZIP named
|
||||
per the HCPF X12 File Naming Standards:
|
||||
``tp{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (with a per-claim
|
||||
millisecond offset so every file in the bundle has a unique name).
|
||||
The ``serialize_837_for_resubmit`` serializer is used so every file
|
||||
gets a unique interchange / group control number — back-to-back
|
||||
exports of the same set must produce different envelopes (required
|
||||
by X12).
|
||||
|
||||
The submitter block (Loop 1000A — NM1*41 + PER) is populated from
|
||||
the clearhouse singleton (dzinesco's identity in the seeded config)
|
||||
and the receiver block (NM1*40) is populated from the per-payer
|
||||
config. Without this wiring, the serializer falls back to
|
||||
``CYCLONE`` / ``RECEIVER`` placeholders and HCPF rejects the file.
|
||||
|
||||
No DB state is mutated by this endpoint — it is read-only. Compare
|
||||
with ``/api/inbox/rejected/resubmit?download=true`` which ALSO flips
|
||||
``ClaimState.REJECTED → SUBMITTED``; the two endpoints are
|
||||
intentionally separate.
|
||||
|
||||
Responses:
|
||||
200 — ``application/zip`` with the .x12 entries. Per-claim failures
|
||||
are surfaced via the ``X-Cyclone-Serialize-Errors`` header
|
||||
(JSON-encoded array of ``{claim_id, reason}``).
|
||||
400 — ``claim_ids`` missing or empty.
|
||||
404 — ``batch_id`` unknown.
|
||||
422 — every claim failed to serialize; body is JSON listing all
|
||||
failures (``{"detail": {"serialize_errors": [...]}}``).
|
||||
"""
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from cyclone.edi.filenames import build_outbound_filename
|
||||
|
||||
ids = body.get("claim_ids") or []
|
||||
if not ids:
|
||||
raise HTTPException(400, "claim_ids required")
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
batch = s.get(Batch, batch_id)
|
||||
if batch is None:
|
||||
raise HTTPException(404, f"unknown batch: {batch_id}")
|
||||
|
||||
serialize_errors: list[dict] = []
|
||||
ordered_rows: list[tuple[str, "Claim"]] = []
|
||||
for cid in ids:
|
||||
c = s.get(Claim, cid)
|
||||
if c is None:
|
||||
serialize_errors.append({"claim_id": cid, "reason": "unknown claim_id"})
|
||||
continue
|
||||
ordered_rows.append((cid, c))
|
||||
|
||||
# Pull clearhouse identity (submitter). If unseeded, the serializer
|
||||
# falls back to placeholder defaults — degraded but not a hard error.
|
||||
ch = store.get_clearhouse()
|
||||
submitter_kwargs: dict = {}
|
||||
if ch is not None:
|
||||
submitter_kwargs = {
|
||||
"sender_id": ch.tpid,
|
||||
"submitter_name": ch.submitter_name,
|
||||
"submitter_contact_name": ch.submitter_contact_name,
|
||||
"submitter_contact_email": ch.submitter_contact_email,
|
||||
}
|
||||
# Submitter phone is not in the clearhouse config today, but if
|
||||
# it ever is, wire it here. Email is the canonical contact
|
||||
# channel for HCPF submissions per the SP9 spec.
|
||||
if getattr(ch, "submitter_contact_phone", None):
|
||||
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
|
||||
|
||||
# Resolve per-claim payer config so each file's receiver (NM1*40)
|
||||
# and SBR09 are correct. Cache so we don't re-query the same payer.
|
||||
from cyclone.db import PayerConfigORM as _PayerConfigORM
|
||||
_payer_cache: dict[str, dict | None] = {}
|
||||
|
||||
def _resolve_payer_cfg(claim_obj: ClaimOutput) -> dict | None:
|
||||
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
|
||||
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
|
||||
cache_key = pid or pname
|
||||
if cache_key in _payer_cache:
|
||||
return _payer_cache[cache_key]
|
||||
cfg: dict | None = None
|
||||
with db.SessionLocal()() as ss:
|
||||
# 1. Exact match on (payer_id, "837P")
|
||||
if pid:
|
||||
row = ss.get(_PayerConfigORM, (pid, "837P"))
|
||||
if row is not None:
|
||||
cfg = dict(row.config_json)
|
||||
# 2. Fallback: any row whose payer_id matches the parsed payer.name
|
||||
# (HCPF files emit "SKCO0" in NM109 but the canonical
|
||||
# payer_id in the DB is "CO_TXIX" — name-matching is the
|
||||
# pragmatic lookup for that case).
|
||||
if cfg is None and pname:
|
||||
row = (
|
||||
ss.query(_PayerConfigORM)
|
||||
.filter(_PayerConfigORM.transaction_type == "837P")
|
||||
.all()
|
||||
)
|
||||
for r in row:
|
||||
cj = dict(r.config_json)
|
||||
if cj.get("submitter_name") and pname.lower() in str(cj).lower():
|
||||
cfg = cj
|
||||
break
|
||||
if (r.payer_id or "").upper() == pname.upper():
|
||||
cfg = cj
|
||||
break
|
||||
# 3. Last resort: first 837P row in the table.
|
||||
if cfg is None:
|
||||
row = (
|
||||
ss.query(_PayerConfigORM)
|
||||
.filter(_PayerConfigORM.transaction_type == "837P")
|
||||
.first()
|
||||
)
|
||||
if row is not None:
|
||||
cfg = dict(row.config_json)
|
||||
_payer_cache[cache_key] = cfg
|
||||
return cfg
|
||||
|
||||
# Build per-claim kwargs (receiver + SBR09) lazily. Receiver
|
||||
# defaults to the parsed payer name/ID if no config row matches.
|
||||
def _serialize_kwargs(claim_obj: ClaimOutput) -> dict:
|
||||
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
|
||||
receiver_id = (
|
||||
payer_cfg.get("receiver_id")
|
||||
or (claim_obj.payer.id if claim_obj.payer else None)
|
||||
or "RECEIVER"
|
||||
)
|
||||
receiver_name = (
|
||||
payer_cfg.get("receiver_name")
|
||||
or (claim_obj.payer.name if claim_obj.payer else None)
|
||||
or receiver_id
|
||||
)
|
||||
sbr09 = payer_cfg.get("sbr09_default") or "MC"
|
||||
return {
|
||||
"receiver_id": receiver_id,
|
||||
"receiver_name": receiver_name,
|
||||
"claim_filing_indicator_code": sbr09,
|
||||
}
|
||||
|
||||
# Base MT timestamp for HCPF filenames. We add a per-claim
|
||||
# millisecond offset so each file in the ZIP has a unique 17-digit
|
||||
# ts (HCPF requires that; the spec also enforces "1of1" for the
|
||||
# sequence element).
|
||||
base_ts = datetime.now(ZoneInfo("America/Denver"))
|
||||
|
||||
def _per_claim_filename(idx: int, cid: str) -> str:
|
||||
if ch is None:
|
||||
# No clearhouse — fall back to a per-claim friendly name.
|
||||
return f"claim-{cid}.x12"
|
||||
# Millisecond offset, with second/minute rollover.
|
||||
offset_ms = (idx - 1) * 1 # 1 ms per claim is enough within an export
|
||||
ts_mt = base_ts.fromtimestamp(
|
||||
base_ts.timestamp() + offset_ms / 1000.0, tz=ZoneInfo("America/Denver")
|
||||
)
|
||||
return build_outbound_filename(ch.tpid, "837P", now_mt=ts_mt)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for idx, (cid, c) in enumerate(ordered_rows, start=1):
|
||||
if not c.raw_json:
|
||||
serialize_errors.append({"claim_id": cid, "reason": "no raw_json"})
|
||||
continue
|
||||
try:
|
||||
claim_obj = ClaimOutput.model_validate(c.raw_json)
|
||||
except Exception as exc:
|
||||
serialize_errors.append(
|
||||
{"claim_id": cid, "reason": f"raw_json invalid: {exc}"}
|
||||
)
|
||||
continue
|
||||
try:
|
||||
kwargs = {**submitter_kwargs, **_serialize_kwargs(claim_obj)}
|
||||
text = serialize_837_for_resubmit(
|
||||
claim_obj, interchange_index=idx, **kwargs
|
||||
)
|
||||
except SerializeError837 as exc:
|
||||
serialize_errors.append({"claim_id": cid, "reason": str(exc)})
|
||||
continue
|
||||
zf.writestr(_per_claim_filename(idx, cid), text)
|
||||
|
||||
success_count = len(ids) - len(serialize_errors)
|
||||
if serialize_errors and success_count == 0:
|
||||
# Every claim failed — surface the failure list in the body so the
|
||||
# UI can render a useful error toast (the response is not a ZIP).
|
||||
raise HTTPException(
|
||||
422,
|
||||
detail={"serialize_errors": serialize_errors},
|
||||
)
|
||||
|
||||
buf.seek(0)
|
||||
headers = {
|
||||
"Content-Disposition": (
|
||||
f'attachment; filename="batch-{batch_id}-{success_count}-claims.zip"'
|
||||
),
|
||||
}
|
||||
if serialize_errors:
|
||||
headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors)
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/zip",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/inbox/export.csv")
|
||||
def inbox_export_csv(lane: str):
|
||||
"""Stream a CSV for a single lane."""
|
||||
@@ -2321,6 +2540,51 @@ def submit_to_clearhouse(body: dict):
|
||||
if ch is None:
|
||||
raise HTTPException(status_code=500, detail="clearhouse not seeded")
|
||||
|
||||
# Submitter (Loop 1000A) comes from the clearhouse config. The
|
||||
# receiver (NM1*40) and SBR09 come from the per-payer config and
|
||||
# are resolved per-claim below. Without this wiring, the
|
||||
# serializer would emit "CYCLONE" / "RECEIVER" placeholders and
|
||||
# the file would be rejected by HCPF.
|
||||
submitter_kwargs = {
|
||||
"sender_id": ch.tpid,
|
||||
"submitter_name": ch.submitter_name,
|
||||
"submitter_contact_name": ch.submitter_contact_name,
|
||||
"submitter_contact_email": ch.submitter_contact_email,
|
||||
}
|
||||
if getattr(ch, "submitter_contact_phone", None):
|
||||
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
|
||||
|
||||
# Build a payer_id → PayerConfig837 map once so we can look up the
|
||||
# receiver + SBR09 default for each claim.
|
||||
from cyclone.db import PayerConfigORM as _PayerConfigORM
|
||||
|
||||
def _resolve_payer_cfg(claim_obj) -> dict | None:
|
||||
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
|
||||
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
|
||||
with db.SessionLocal()() as ss:
|
||||
if pid:
|
||||
row = ss.get(_PayerConfigORM, (pid, "837P"))
|
||||
if row is not None:
|
||||
return dict(row.config_json)
|
||||
if pname:
|
||||
rows = (
|
||||
ss.query(_PayerConfigORM)
|
||||
.filter(_PayerConfigORM.transaction_type == "837P")
|
||||
.all()
|
||||
)
|
||||
for r in rows:
|
||||
cj = dict(r.config_json)
|
||||
if (r.payer_id or "").upper() == pname.upper():
|
||||
return cj
|
||||
if pname.lower() in str(cj).lower():
|
||||
return cj
|
||||
row = (
|
||||
ss.query(_PayerConfigORM)
|
||||
.filter(_PayerConfigORM.transaction_type == "837P")
|
||||
.first()
|
||||
)
|
||||
return dict(row.config_json) if row else None
|
||||
|
||||
client = make_client(ch.sftp_block)
|
||||
results = []
|
||||
for cid in claim_ids:
|
||||
@@ -2329,6 +2593,34 @@ def submit_to_clearhouse(body: dict):
|
||||
except Exception as exc: # noqa: BLE001
|
||||
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
|
||||
continue
|
||||
# Re-resolve the claim so we can look up its payer config. We
|
||||
# re-parse the stored x12_text to get a ClaimOutput (same path
|
||||
# the serializer uses).
|
||||
try:
|
||||
from cyclone.parsers.parse_837 import parse as _parse837
|
||||
claim_row_obj = _load_claim_row(cid)
|
||||
if claim_row_obj is None or not (claim_row_obj.raw_json or {}).get("x12_text"):
|
||||
raise RuntimeError("no stored x12_text for claim")
|
||||
parsed = _parse837(claim_row_obj.raw_json["x12_text"])
|
||||
claim_obj = parsed.claims[0] if parsed.claims else None
|
||||
except Exception:
|
||||
claim_obj = None
|
||||
if claim_obj is not None:
|
||||
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
|
||||
receiver_id = payer_cfg.get("receiver_id") or (claim_obj.payer.id if claim_obj.payer else None) or "RECEIVER"
|
||||
receiver_name = payer_cfg.get("receiver_name") or (claim_obj.payer.name if claim_obj.payer else None) or receiver_id
|
||||
sbr09 = payer_cfg.get("sbr09_default") or "MC"
|
||||
# Re-serialize with the proper envelope values.
|
||||
try:
|
||||
x12_text = _serialize_claim_for_submit(
|
||||
cid,
|
||||
**{**submitter_kwargs, "receiver_id": receiver_id,
|
||||
"receiver_name": receiver_name,
|
||||
"claim_filing_indicator_code": sbr09},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
|
||||
continue
|
||||
filename = build_outbound_filename(ch.tpid, "837P")
|
||||
remote = f"{ch.sftp_block.paths['outbound']}/{filename}"
|
||||
staging_path = client.write_file(remote, x12_text.encode("utf-8"))
|
||||
@@ -2357,9 +2649,20 @@ def submit_to_clearhouse(body: dict):
|
||||
return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}
|
||||
|
||||
|
||||
def _serialize_claim_for_submit(claim_id: str) -> str:
|
||||
def _load_claim_row(claim_id: str):
|
||||
"""Helper: load a Claim row by id (or return None)."""
|
||||
with db.SessionLocal()() as s:
|
||||
return s.get(Claim, claim_id)
|
||||
|
||||
|
||||
def _serialize_claim_for_submit(claim_id: str, **kwargs) -> str:
|
||||
"""Serialize a claim to X12 for SFTP submission. Lazy import of the
|
||||
serializer to avoid pulling FastAPI machinery at module import time."""
|
||||
serializer to avoid pulling FastAPI machinery at module import time.
|
||||
|
||||
Optional ``**kwargs`` are forwarded to the serializer — used to
|
||||
pass through clearhouse submitter info and per-payer receiver info
|
||||
so the regenerated file matches what the HCPF MFT expects.
|
||||
"""
|
||||
from cyclone.parsers.serialize_837 import serialize_837
|
||||
from cyclone import db
|
||||
with db.SessionLocal()() as s:
|
||||
@@ -2371,15 +2674,17 @@ def _serialize_claim_for_submit(claim_id: str) -> str:
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
raw = row.raw_json or {}
|
||||
# Reconstruct minimal ClaimOutput from raw_json; this is best-effort.
|
||||
return _serialize_claim_from_raw(row, raw)
|
||||
return _serialize_claim_from_raw(row, raw, **kwargs)
|
||||
|
||||
|
||||
def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
|
||||
def _serialize_claim_from_raw(claim_row, raw: dict, **kwargs) -> str:
|
||||
"""Best-effort serializer that uses the stored raw_json to emit a fresh 837.
|
||||
|
||||
For SP9 this delegates to the existing serialize_837 helper if the
|
||||
claim has a complete raw_segments array. Otherwise it returns a
|
||||
minimal placeholder.
|
||||
minimal placeholder. ``**kwargs`` are forwarded to the serializer
|
||||
so callers can pass through submitter / receiver / SBR09 values
|
||||
from the clearhouse and per-payer configs.
|
||||
"""
|
||||
from cyclone.parsers.serialize_837 import serialize_837
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
@@ -2389,7 +2694,7 @@ def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
|
||||
if isinstance(raw, dict) and raw.get("x12_text"):
|
||||
result = parse(raw["x12_text"])
|
||||
if result.claims:
|
||||
return serialize_837(result.claims[0])
|
||||
return serialize_837(result.claims[0], **kwargs)
|
||||
# Fallback: raise so the caller sees an error.
|
||||
raise RuntimeError(
|
||||
f"claim {claim_row.id!r} cannot be re-serialized: no stored x12_text"
|
||||
|
||||
@@ -93,19 +93,40 @@ def ndjson_stream_list(
|
||||
}) + "\n"
|
||||
|
||||
|
||||
def ndjson_stream_837(result: ParseResult) -> Iterator[bytes]:
|
||||
"""Yield one JSON object per line: envelope → claims → summary."""
|
||||
def ndjson_stream_837(
|
||||
result: ParseResult, batch_id: str | None = None,
|
||||
) -> Iterator[bytes]:
|
||||
"""Yield one JSON object per line: envelope → claims → summary.
|
||||
|
||||
The ``batch_id`` is the server-side UUID assigned by the persistence
|
||||
layer when the batch is ingested; the JSON response path exposes it
|
||||
as the top-level ``batch_id`` field, but the NDJSON stream needs it
|
||||
inline on the summary event so streaming clients can call
|
||||
batch-scoped endpoints (``/api/batches/{id}/export-837``, …) without
|
||||
a separate ``GET /api/batches`` round-trip. When ``batch_id`` is not
|
||||
supplied the summary omits the field, preserving backward compat
|
||||
with clients that don't expect it.
|
||||
"""
|
||||
envelope_obj = (
|
||||
result.envelope.model_dump() if result.envelope is not None else None
|
||||
)
|
||||
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
|
||||
for claim in result.claims:
|
||||
yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
||||
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
|
||||
summary_data = json.loads(result.summary.model_dump_json())
|
||||
if batch_id is not None:
|
||||
summary_data["batch_id"] = batch_id
|
||||
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
|
||||
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary."""
|
||||
def ndjson_stream_835(
|
||||
result: ParseResult835, batch_id: str | None = None,
|
||||
) -> Iterator[bytes]:
|
||||
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary.
|
||||
|
||||
See ``ndjson_stream_837`` for why the optional ``batch_id`` is
|
||||
merged into the summary event.
|
||||
"""
|
||||
yield (json.dumps({"type": "envelope", "data": json.loads(result.envelope.model_dump_json())}) + "\n").encode("utf-8")
|
||||
yield (json.dumps({"type": "financial_info", "data": json.loads(result.financial_info.model_dump_json())}) + "\n").encode("utf-8")
|
||||
yield (json.dumps({"type": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8")
|
||||
@@ -113,7 +134,10 @@ def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
|
||||
yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
|
||||
for claim in result.claims:
|
||||
yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
||||
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
|
||||
summary_data = json.loads(result.summary.model_dump_json())
|
||||
if batch_id is not None:
|
||||
summary_data["batch_id"] = batch_id
|
||||
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def strict_rewrite_837(result: ParseResult) -> ParseResult:
|
||||
|
||||
@@ -4,8 +4,8 @@ SP9. Source-of-truth spec:
|
||||
https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide)
|
||||
|
||||
Outbound (we send):
|
||||
{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
|
||||
Example: 11525703-837P-20260620132243505-1of1.x12
|
||||
tp{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
|
||||
Example: tp11525703-837P-20260620132243505-1of1.x12
|
||||
|
||||
Inbound (HPE sends to our ToHPE):
|
||||
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
|
||||
@@ -28,14 +28,15 @@ from cyclone.providers import InboundFilename
|
||||
# Regexes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Outbound: 11525703-837P-20260620132243505-1of1.x12
|
||||
# Outbound: tp11525703-837P-20260620132243505-1of1.x12
|
||||
# - tp: literal "tp" prefix
|
||||
# - tpid: 1+ digits
|
||||
# - tx: 1+ alnum
|
||||
# - ts: 17 digits (yyyymmddhhmmssSSS)
|
||||
# - seq: literal "1of1"
|
||||
# - ext: 1+ alnum
|
||||
OUTBOUND_RE = re.compile(
|
||||
r"^(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
|
||||
r"^tp(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
|
||||
)
|
||||
|
||||
# Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
|
||||
@@ -79,7 +80,8 @@ def build_outbound_filename(
|
||||
time in ``America/Denver`` is used.
|
||||
|
||||
Returns:
|
||||
Filename like "11525703-837P-20260620132243505-1of1.x12"
|
||||
Filename like "tp11525703-837P-20260620132243505-1of1.x12"
|
||||
(note the ``tp`` prefix per HCPF outbound spec).
|
||||
|
||||
Raises:
|
||||
ValueError: If tpid is non-numeric, tx contains invalid chars, or
|
||||
@@ -99,7 +101,10 @@ def build_outbound_filename(
|
||||
# Format: yyyymmddhhmmssSSS — 17 digits total
|
||||
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
|
||||
assert len(ts) == 17
|
||||
return f"{tpid}-{tx}-{ts}-1of1.{ext}"
|
||||
# Per HCPF outbound spec, prefix is "tp" + tpid. Matches the format
|
||||
# we receive from HPE inbound (which uses uppercase TP) and the
|
||||
# historical outbound prodfile naming (e.g. tp11525703-837P-...).
|
||||
return f"tp{tpid}-{tx}-{ts}-1of1.{ext}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -63,6 +63,7 @@ class ClaimHeader(_Base):
|
||||
frequency_code: str | None = None
|
||||
provider_signature: str | None = None
|
||||
assignment: str | None = None
|
||||
benefits_assignment_certification: str | None = None # CLM08 (Y/N)
|
||||
release_of_info: str | None = None
|
||||
prior_auth: str | None = None
|
||||
|
||||
@@ -87,6 +88,14 @@ class ServiceLine(_Base):
|
||||
place_of_service: str | None = None
|
||||
service_date: date | None = None
|
||||
provider_reference: str | None = None
|
||||
# SV1-07 — Diagnosis Code Pointer. Points to one or more
|
||||
# diagnosis codes in the parent claim's HI segment ("1".. "12",
|
||||
# space-separated when multiple). For 837P with a non-empty HI
|
||||
# segment, SV1-07 is required by HCPF / Gainwell. The parser
|
||||
# captures it from the source; the serializer defaults to "1"
|
||||
# when the claim has at least one diagnosis and no explicit
|
||||
# pointer was captured (matches the common single-dx case).
|
||||
dx_pointer: str | None = None
|
||||
|
||||
|
||||
class ValidationIssue(_Base):
|
||||
|
||||
@@ -202,6 +202,7 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
|
||||
frequency_code=freq or None,
|
||||
provider_signature=clm[6] if len(clm) > 6 else None,
|
||||
assignment=clm[7] if len(clm) > 7 else None,
|
||||
benefits_assignment_certification=clm[8] if len(clm) > 8 else None,
|
||||
release_of_info=clm[9] if len(clm) > 9 else None,
|
||||
)
|
||||
|
||||
@@ -285,6 +286,12 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
|
||||
except Exception:
|
||||
units = None
|
||||
place_of_service = seg[5] if len(seg) > 5 else None
|
||||
# SV1-06 (Unit Basis of Measurement) is X12 "UN" for "units" — we
|
||||
# already use unit_type in SV1-03; SV1-06 is rarely populated and
|
||||
# is not required by HCPF.
|
||||
# SV1-07 — Diagnosis Code Pointer (e.g. "1" for the first HI
|
||||
# diagnosis). Required by HCPF when the claim has diagnoses.
|
||||
dx_pointer = seg[7] if len(seg) > 7 and seg[7] else None
|
||||
service_date: date | None = None
|
||||
provider_ref: str | None = None
|
||||
|
||||
@@ -311,6 +318,7 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
|
||||
place_of_service=place_of_service,
|
||||
service_date=service_date,
|
||||
provider_reference=provider_ref,
|
||||
dx_pointer=dx_pointer,
|
||||
),
|
||||
idx,
|
||||
)
|
||||
|
||||
@@ -112,7 +112,10 @@ def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> st
|
||||
_FUNCTIONAL_ID_HEALTH_CARE,
|
||||
sender_id,
|
||||
receiver_id,
|
||||
_today_yymmdd(),
|
||||
# GS-04 must be CCYYMMDD (8 digits) per X12 — ISA uses YYMMDD
|
||||
# (6 digits) for the older format, but the GS segment is the
|
||||
# newer ANSI X12 format and requires the full year.
|
||||
_today_yyyymmdd(),
|
||||
_today_hhmm(),
|
||||
group_control_number,
|
||||
"X",
|
||||
@@ -186,17 +189,30 @@ def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_per(contact_name: str | None, contact_phone: str | None) -> str:
|
||||
"""PER segment — submitter contact. Returns empty when no contact info."""
|
||||
if not contact_name and not contact_phone:
|
||||
return ""
|
||||
parts = [
|
||||
"PER",
|
||||
"IC", # PER01 — contact function code (Information Contact)
|
||||
contact_name or "",
|
||||
"TE", # PER03 — phone qualifier
|
||||
contact_phone or "",
|
||||
]
|
||||
def _build_per(
|
||||
contact_name: str | None,
|
||||
contact_phone: str | None,
|
||||
contact_email: str | None = None,
|
||||
email_qual: str = "EM",
|
||||
) -> str:
|
||||
"""PER segment — submitter contact (Loop 1000A).
|
||||
|
||||
X12 005010X222A1 *requires* at least one PER segment in Loop 1000A
|
||||
(Submitter Name) and at least PER01 must be present, so this
|
||||
builder always emits a segment. PER01 = "IC" (Information Contact).
|
||||
|
||||
The remaining elements are filled from the available contact info:
|
||||
name, then email (preferred — Gainwell/HCPF expect this), then phone.
|
||||
"""
|
||||
parts = ["PER", "IC"]
|
||||
if contact_name:
|
||||
parts.append(contact_name)
|
||||
if contact_email:
|
||||
parts.append(email_qual) # PER03 — email qualifier (default "EM")
|
||||
parts.append(contact_email) # PER04 — the email itself
|
||||
elif contact_phone:
|
||||
parts.append("TE") # PER03 — phone qualifier
|
||||
parts.append(contact_phone) # PER04 — the phone itself
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
@@ -236,26 +252,40 @@ def _build_hl(hl_id: str, parent_id: str, level_code: str, child_code: str) -> s
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_sbr(relationship_code: str | None, member_id: str | None,
|
||||
payer_name: str | None) -> str:
|
||||
def _build_sbr(
|
||||
individual_relationship_code: str | None,
|
||||
claim_filing_indicator_code: str | None,
|
||||
) -> str:
|
||||
"""SBR segment — subscriber information.
|
||||
|
||||
SBR01 (relationship code) defaults to ``"P"`` (Patient = self) which is
|
||||
the most common case for professional claims; the parser does not store
|
||||
this on the canonical Subscriber model so we cannot thread it through
|
||||
without adding a model field.
|
||||
Slot layout (X12 005010X222A1):
|
||||
SBR01 — Payer Responsibility Sequence Number Code. Default ``"P"``
|
||||
(Patient = primary). The parser does not capture this
|
||||
field on ``ClaimOutput`` so we default it.
|
||||
SBR02 — Individual Relationship Code. ``"18"`` = self, ``"01"`` = spouse, etc.
|
||||
The parser does not capture this either; we default ``"18"``
|
||||
for the common self-pay case.
|
||||
SBR09 — Claim Filing Indicator Code. ``"MC"`` for Medicaid,
|
||||
``"16"`` for Medicare Part B, etc. The canonical
|
||||
PayerConfig837 carries ``sbr09_default``; we thread it in
|
||||
from the caller.
|
||||
|
||||
The member_id and payer name do NOT belong in SBR — the member_id
|
||||
lives in NM109 of the NM1*IL segment, and the payer name is in
|
||||
NM103 of NM1*PR. (Earlier revisions of this function put them in
|
||||
SBR06 / SBR09, which is wrong and rejected by HCPF.)
|
||||
"""
|
||||
parts = [
|
||||
"SBR",
|
||||
relationship_code or "P",
|
||||
"", # SBR02 — group number
|
||||
"", # SBR03 — group name
|
||||
"", # SBR04 — claim filing indicator code
|
||||
"", # SBR05 — sequence number code
|
||||
payer_name or "", # SBR06 — claim filing indicator code (CO uses MC)
|
||||
"", # SBR07
|
||||
"", # SBR08
|
||||
member_id or "", # SBR09 — claim submitter's id
|
||||
"P", # SBR01 — primary
|
||||
individual_relationship_code or "18", # SBR02 — self
|
||||
"", # SBR03 — group number
|
||||
"", # SBR04 — group name
|
||||
"", # SBR05 — insurance type code
|
||||
"", # SBR06 — coordination of benefits
|
||||
"", # SBR07 — yes/no condition
|
||||
"", # SBR08 — employment status code
|
||||
claim_filing_indicator_code or "", # SBR09 — claim filing indicator
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
@@ -300,7 +330,11 @@ def _build_clm(claim) -> str:
|
||||
clm05, # CLM05 — composite POS:qualifier:frequency_code
|
||||
claim.provider_signature or "Y", # CLM06
|
||||
claim.assignment or "Y", # CLM07
|
||||
"", # CLM08 — benefit assignment certification
|
||||
# CLM08 — Benefits Assignment Certification. X12 837P requires
|
||||
# this when CLM07 = "Y" (the common case for in-network
|
||||
# professional claims). Default to "Y" when the source did
|
||||
# not capture one — matches what 99% of HCPF files look like.
|
||||
claim.benefits_assignment_certification or "Y", # CLM08
|
||||
claim.release_of_info or "Y", # CLM09
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
@@ -325,21 +359,41 @@ def _build_lx(line_number: int) -> str:
|
||||
return _ELEM.join(["LX", str(line_number)]) + _SEG
|
||||
|
||||
|
||||
def _build_sv1(line) -> str:
|
||||
"""SV1 segment — professional service line."""
|
||||
def _build_sv1(line, *, dx_pointer: str | None = None) -> str:
|
||||
"""SV1 segment — professional service line.
|
||||
|
||||
X12 005010X222A1 layout (837P):
|
||||
SV1-01 composite procedure identifier
|
||||
SV1-02 monetary amount (charge)
|
||||
SV1-03 unit of basis measurement (UN, MJ, etc.) — ``line.unit_type``
|
||||
SV1-04 service unit count — ``line.units``
|
||||
SV1-05 place of service code — ``line.place_of_service``
|
||||
SV1-06 **NOT USED** by this guide (must be empty)
|
||||
SV1-07 diagnosis code pointer — ``dx_pointer`` (required when the
|
||||
parent claim has an HI segment)
|
||||
|
||||
The parser captures the original SV1-07 pointer when present; the
|
||||
serializer defaults it to ``"1"`` (pointing at the first HI
|
||||
diagnosis) when the claim has diagnoses and no explicit pointer
|
||||
was captured. When the claim has no HI segment we leave SV1-07
|
||||
empty to match the spec.
|
||||
"""
|
||||
proc = line.procedure
|
||||
code = proc.code if proc else ""
|
||||
mods = proc.modifiers if proc else []
|
||||
composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
|
||||
charge = f"{Decimal(line.charge or 0):.2f}"
|
||||
units = f"{Decimal(line.units):g}" if line.units is not None else "1"
|
||||
sv1_07 = dx_pointer or ""
|
||||
parts = [
|
||||
"SV1",
|
||||
composite,
|
||||
charge,
|
||||
line.unit_type or "UN",
|
||||
units,
|
||||
line.place_of_service or "",
|
||||
composite, # SV1-01
|
||||
charge, # SV1-02
|
||||
line.unit_type or "UN", # SV1-03 — unit basis code
|
||||
units, # SV1-04
|
||||
line.place_of_service or "", # SV1-05
|
||||
"", # SV1-06 — NOT USED in 837P
|
||||
sv1_07, # SV1-07 — diagnosis pointer
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
@@ -357,15 +411,20 @@ def _build_dtp_472(service_date: date | None) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_submitter_block(sender_id: str, submitter_name: str | None,
|
||||
contact_name: str | None,
|
||||
contact_phone: str | None) -> list[str]:
|
||||
def _build_submitter_block(
|
||||
sender_id: str,
|
||||
submitter_name: str | None,
|
||||
contact_name: str | None,
|
||||
contact_phone: str | None,
|
||||
contact_email: str | None = None,
|
||||
email_qual: str = "EM",
|
||||
) -> list[str]:
|
||||
out = [
|
||||
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
|
||||
]
|
||||
per = _build_per(contact_name, contact_phone)
|
||||
if per:
|
||||
out.append(per)
|
||||
# PER is required by X12 (at least PER01). _build_per always emits
|
||||
# the segment; the submitter block always has exactly one.
|
||||
out.append(_build_per(contact_name, contact_phone, contact_email, email_qual))
|
||||
return out
|
||||
|
||||
|
||||
@@ -395,11 +454,14 @@ def _build_billing_provider_block(provider) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _build_subscriber_block(subscriber, payer_name: str | None) -> list[str]:
|
||||
def _build_subscriber_block(
|
||||
subscriber,
|
||||
claim_filing_indicator_code: str | None,
|
||||
) -> list[str]:
|
||||
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
|
||||
out = [
|
||||
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children
|
||||
_build_sbr("18", subscriber.member_id, payer_name),
|
||||
_build_sbr("18", claim_filing_indicator_code),
|
||||
_build_nm1(
|
||||
"IL", "IL",
|
||||
f"{subscriber.last_name} {subscriber.first_name}".strip(),
|
||||
@@ -427,12 +489,24 @@ def _build_payer_block(payer) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def _build_service_lines_block(service_lines) -> list[str]:
|
||||
"""Per line: LX / SV1 / DTP*472 / REF*6R."""
|
||||
def _build_service_lines_block(service_lines, *, has_diagnoses: bool = False) -> list[str]:
|
||||
"""Per line: LX / SV1 / DTP*472 / REF*6R.
|
||||
|
||||
``has_diagnoses`` is True when the parent claim emits an HI segment;
|
||||
in that case SV1-07 is required by X12 and we default each line's
|
||||
pointer to ``"1"`` (the first HI diagnosis) unless the source
|
||||
captured a different pointer on the line itself.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for idx, line in enumerate(service_lines or [], start=1):
|
||||
out.append(_build_lx(idx))
|
||||
out.append(_build_sv1(line))
|
||||
# Prefer the line's captured pointer (parser pulled SV1-07
|
||||
# when present). Fall back to "1" only when the claim has
|
||||
# diagnoses and the source had no explicit pointer — the
|
||||
# common single-diagnosis case.
|
||||
line_pointer = getattr(line, "dx_pointer", None)
|
||||
effective_pointer = line_pointer or ("1" if has_diagnoses else "")
|
||||
out.append(_build_sv1(line, dx_pointer=effective_pointer))
|
||||
dtp = _build_dtp_472(line.service_date)
|
||||
if dtp:
|
||||
out.append(dtp)
|
||||
@@ -450,7 +524,10 @@ def serialize_837(
|
||||
submitter_name: str | None = None,
|
||||
submitter_contact_name: str | None = None,
|
||||
submitter_contact_phone: str | None = None,
|
||||
submitter_contact_email: str | None = None,
|
||||
submitter_contact_email_qual: str = "EM",
|
||||
receiver_name: str | None = None,
|
||||
claim_filing_indicator_code: str | None = None,
|
||||
interchange_control_number: str = "000000001",
|
||||
group_control_number: str = "1",
|
||||
) -> str:
|
||||
@@ -462,6 +539,16 @@ def serialize_837(
|
||||
(``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
|
||||
the configured values.
|
||||
|
||||
The submitter block (Loop 1000A) always emits a PER segment per the
|
||||
X12 spec — the canonical clearhouse config provides the contact
|
||||
name and email so callers should pass them through.
|
||||
|
||||
The claim filing indicator (SBR09) is read from the per-payer
|
||||
config (``PayerConfig837.sbr09_default``); callers should pass it
|
||||
in. If not passed, SBR09 is left empty (which causes the
|
||||
:func:`cyclone.parsers.validator._r202_sbr09_allowed` rule to skip
|
||||
its check — degraded but not a hard error).
|
||||
|
||||
Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are
|
||||
emitted from the canonical ``ClaimOutput`` fields, so post-parse
|
||||
edits propagate to the output.
|
||||
@@ -481,11 +568,13 @@ def serialize_837(
|
||||
),
|
||||
]
|
||||
segments.extend(_build_submitter_block(
|
||||
sender_id, submitter_name, submitter_contact_name, submitter_contact_phone,
|
||||
sender_id, submitter_name,
|
||||
submitter_contact_name, submitter_contact_phone, submitter_contact_email,
|
||||
submitter_contact_email_qual,
|
||||
))
|
||||
segments.extend(_build_receiver_block(receiver_id, receiver_name))
|
||||
segments.extend(_build_billing_provider_block(claim.billing_provider))
|
||||
segments.extend(_build_subscriber_block(claim.subscriber, claim.payer.name))
|
||||
segments.extend(_build_subscriber_block(claim.subscriber, claim_filing_indicator_code))
|
||||
segments.extend(_build_payer_block(claim.payer))
|
||||
|
||||
# Claim-level editable segments.
|
||||
@@ -497,7 +586,10 @@ def serialize_837(
|
||||
segments.append(_build_hi(claim.diagnoses))
|
||||
|
||||
# Service lines (LX / SV1 / DTP*472 / REF*6R).
|
||||
segments.extend(_build_service_lines_block(claim.service_lines))
|
||||
segments.extend(_build_service_lines_block(
|
||||
claim.service_lines,
|
||||
has_diagnoses=bool(claim.diagnoses),
|
||||
))
|
||||
|
||||
# SE segment count includes ST (line 3, 1-based) through SE itself
|
||||
# — i.e. the entire ST..SE block inclusive.
|
||||
@@ -514,15 +606,23 @@ def serialize_837_for_resubmit(
|
||||
claim: ClaimOutput,
|
||||
*,
|
||||
interchange_index: int,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Like :func:`serialize_837` but assigns deterministic-but-unique
|
||||
interchange + group control numbers for a bundle position.
|
||||
|
||||
Interchange number = ``f"{interchange_index:09d}"``.
|
||||
Group number = ``str(interchange_index)``.
|
||||
|
||||
All other keyword arguments (sender_id, receiver_id, submitter_*
|
||||
and receiver_* contact info, claim_filing_indicator_code) are
|
||||
forwarded to :func:`serialize_837` unchanged so callers — like the
|
||||
export and SFTP-submit endpoints — can pass through clearhouse +
|
||||
payer config without copying the signature.
|
||||
"""
|
||||
return serialize_837(
|
||||
claim,
|
||||
interchange_control_number=f"{interchange_index:09d}",
|
||||
group_control_number=str(interchange_index),
|
||||
**kwargs,
|
||||
)
|
||||
@@ -2277,7 +2277,7 @@ class CycloneStore:
|
||||
submitter_contact_email="tyler@dzinesco.com",
|
||||
filename_block={
|
||||
"tz": "America/Denver",
|
||||
"outbound_template": "{tpid}-{tx}-{ts_mt}-1of1.{ext}",
|
||||
"outbound_template": "tp{tpid}-{tx}-{ts_mt}-1of1.{ext}",
|
||||
"inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
|
||||
},
|
||||
sftp_block={
|
||||
|
||||
@@ -101,6 +101,14 @@ def test_parse_837_endpoint_streams_ndjson(client: TestClient):
|
||||
# Summary numbers match the JSON path.
|
||||
assert parsed[3]["data"]["total_claims"] == 2
|
||||
assert parsed[3]["data"]["passed"] == 2
|
||||
# The streaming summary carries the server-side batch id so the
|
||||
# frontend can call /api/batches/{id}/export-837 without a separate
|
||||
# GET /api/batches round-trip (regression: it used to be missing
|
||||
# on the stream path, only present on the JSON path, which made the
|
||||
# Upload page's Export button say "no batch to export").
|
||||
assert parsed[3]["type"] == "summary"
|
||||
assert isinstance(parsed[3]["data"]["batch_id"], str)
|
||||
assert len(parsed[3]["data"]["batch_id"]) == 32 # uuid4().hex
|
||||
|
||||
|
||||
def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
||||
|
||||
@@ -82,6 +82,13 @@ def test_parse_835_endpoint_streams_ndjson(client: TestClient):
|
||||
# Summary numbers match the JSON path.
|
||||
assert parsed[7]["data"]["total_claims"] == 2
|
||||
assert parsed[7]["data"]["passed"] == 2
|
||||
# The streaming summary carries the server-side batch id so the
|
||||
# frontend can call /api/batches/{id}/export-837 without a separate
|
||||
# GET /api/batches round-trip (matches the parallel fix on
|
||||
# /api/parse-837).
|
||||
assert parsed[7]["type"] == "summary"
|
||||
assert isinstance(parsed[7]["data"]["batch_id"], str)
|
||||
assert len(parsed[7]["data"]["batch_id"]) == 32 # uuid4().hex
|
||||
|
||||
|
||||
def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Tests for POST /api/batches/{batch_id}/export-837.
|
||||
|
||||
Reads Claim.raw_json for each requested claim_id and returns a ZIP of
|
||||
regenerated X12 837 files. No DB state mutation. Mirrors the
|
||||
X-Cyclone-Serialize-Errors convention from /api/inbox/rejected/resubmit?download=true.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone.api import app
|
||||
|
||||
|
||||
def _seed_batch(client: TestClient, filename: str = "claim.txt") -> dict:
|
||||
"""Parse a single 837P file and return a dict with ``batch_id`` and
|
||||
``claims``.
|
||||
|
||||
The parse-837 happy-path response does not currently surface
|
||||
``batch_id`` at the top level (it's a server-side UUID, surfaced in
|
||||
409 errors but not in 200s). We look it up from the DB — the Batch
|
||||
row is already persisted by the time the parse endpoint returns.
|
||||
"""
|
||||
from cyclone import db
|
||||
from cyclone.db import Batch
|
||||
|
||||
fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
|
||||
r = client.post(
|
||||
"/api/parse-837",
|
||||
files={"file": (filename, io.BytesIO(fixture.encode()), "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
with db.SessionLocal()() as s:
|
||||
most_recent = s.query(Batch).order_by(Batch.parsed_at.desc()).first()
|
||||
assert most_recent is not None, "expected at least one Batch row after parse"
|
||||
batch_id = most_recent.id
|
||||
return {"batch_id": batch_id, "claims": body["claims"]}
|
||||
|
||||
|
||||
def _claim_ids_from_seed(seeded: dict) -> list[str]:
|
||||
return [c["claim_id"] for c in seeded["claims"]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_happy_path_returns_zip_with_one_x12_per_claim():
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
claim_ids = _claim_ids_from_seed(seeded)
|
||||
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": claim_ids},
|
||||
)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-type"].startswith("application/zip")
|
||||
assert "attachment" in r.headers["content-disposition"]
|
||||
assert (
|
||||
f"batch-{batch_id}-{len(claim_ids)}-claims.zip"
|
||||
in r.headers["content-disposition"]
|
||||
)
|
||||
# Per-claim failures header absent on full success.
|
||||
assert "x-cyclone-serialize-errors" not in {k.lower() for k in r.headers.keys()}
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||
names = zf.namelist()
|
||||
assert len(names) == len(claim_ids)
|
||||
# Each entry follows the HCPF outbound naming template
|
||||
# "{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12" (the seeded
|
||||
# clearhouse TPID is "11525703"). Every name must be unique.
|
||||
from cyclone.edi.filenames import is_outbound_filename
|
||||
seen = set()
|
||||
for name in names:
|
||||
assert is_outbound_filename(name), (
|
||||
f"expected HCPF outbound filename, got {name!r}"
|
||||
)
|
||||
assert name not in seen, f"duplicate filename: {name}"
|
||||
seen.add(name)
|
||||
with zf.open(name) as f:
|
||||
first_line = f.readline().decode("ascii", errors="replace")
|
||||
assert first_line.startswith("ISA*"), f"{name} didn't start with ISA"
|
||||
|
||||
|
||||
def test_each_x12_in_zip_uses_clearhouse_submit_and_payer_receiver():
|
||||
"""Regression: regenerated 837s used to emit 'CYCLONE' / 'RECEIVER'
|
||||
placeholders. The export endpoint must thread the clearhouse
|
||||
submitter (dzinesco's TPID 11525703) and payer receiver
|
||||
(COMEDASSISTPROG) through to the serializer."""
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
claim_ids = _claim_ids_from_seed(seeded)
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": claim_ids},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||
for name in zf.namelist():
|
||||
with zf.open(name) as f:
|
||||
text = f.read().decode("ascii")
|
||||
# Submitter block must use the clearhouse TPID + name, not
|
||||
# the 'CYCLONE' placeholder.
|
||||
assert "CYCLONE" not in text, f"{name} still emits CYCLONE placeholder"
|
||||
assert "11525703" in text, f"{name} missing clearhouse TPID"
|
||||
assert "Dzinesco" in text, f"{name} missing clearhouse name"
|
||||
# Receiver block must use the CO_TXIX payer config
|
||||
# (COMEDASSISTPROG), not the 'RECEIVER' placeholder.
|
||||
assert "RECEIVER" not in text, f"{name} still emits RECEIVER placeholder"
|
||||
assert "COMEDASSISTPROG" in text, f"{name} missing receiver id"
|
||||
# Loop 1000A requires PER — must be present, not omitted.
|
||||
assert "PER*IC*" in text, f"{name} missing required PER segment"
|
||||
# SBR09 should be 'MC' (Medicaid claim filing indicator),
|
||||
# not the member id.
|
||||
sbr_line = next(seg for seg in text.split("~") if seg.startswith("SBR*"))
|
||||
sbr09 = sbr_line.rstrip("~").split("*")[9]
|
||||
assert sbr09 == "MC", f"{name} SBR09 expected 'MC', got {sbr09!r}"
|
||||
|
||||
|
||||
def test_each_x12_in_zip_round_trips_through_parser():
|
||||
"""Fidelity check: each .x12 must parse back to a ClaimOutput deep-equal
|
||||
to the source row's raw_json (modulo recomputed validation)."""
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
from cyclone.parsers.payer import PayerConfig
|
||||
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
claim_ids = _claim_ids_from_seed(seeded)
|
||||
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": claim_ids},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
by_id = {c["claim_id"]: c for c in seeded["claims"]}
|
||||
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||
for name in zf.namelist():
|
||||
with zf.open(name) as f:
|
||||
text = f.read().decode("ascii")
|
||||
result = parse(text, PayerConfig(name="CO_MEDICAID"))
|
||||
assert result.claims, f"{name} didn't parse back to any claims"
|
||||
# Claim ids must round-trip (proves the serializer didn't drop
|
||||
# or rewrite the id).
|
||||
assert result.claims[0].claim.claim_id in by_id, (
|
||||
f"{name} parsed to an unknown claim_id"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Partial-failure surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_partial_failure_one_claim_with_no_raw_json_returns_zip_and_errors_header():
|
||||
"""If one claim has raw_json=None, the ZIP still returns for the others
|
||||
and the failure is surfaced via X-Cyclone-Serialize-Errors."""
|
||||
from cyclone import db
|
||||
from cyclone.edi.filenames import is_outbound_filename
|
||||
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
claim_ids = _claim_ids_from_seed(seeded)
|
||||
assert len(claim_ids) >= 2, "fixture must produce at least 2 claims"
|
||||
|
||||
# Wipe raw_json on one claim to simulate a corrupted row.
|
||||
with db.SessionLocal()() as s:
|
||||
from cyclone.db import Claim
|
||||
target = s.get(Claim, claim_ids[0])
|
||||
target.raw_json = None
|
||||
s.commit()
|
||||
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": claim_ids},
|
||||
)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
# Filename uses SUCCESS count, not requested count.
|
||||
expected_success = len(claim_ids) - 1
|
||||
assert (
|
||||
f"batch-{batch_id}-{expected_success}-claims.zip"
|
||||
in r.headers["content-disposition"]
|
||||
)
|
||||
err_header = r.headers.get("x-cyclone-serialize-errors")
|
||||
assert err_header, "expected X-Cyclone-Serialize-Errors header"
|
||||
errs = json.loads(err_header)
|
||||
assert len(errs) == 1
|
||||
assert errs[0]["claim_id"] == claim_ids[0]
|
||||
assert "raw_json" in errs[0]["reason"].lower()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||
names = zf.namelist()
|
||||
assert len(names) == expected_success
|
||||
# Every entry follows HCPF outbound template; none is the
|
||||
# 'claim-CLM-X.x12' placeholder from the old endpoint.
|
||||
for name in names:
|
||||
assert is_outbound_filename(name), f"non-HCPF name: {name!r}"
|
||||
|
||||
|
||||
def test_all_claims_fail_to_serialize_returns_422():
|
||||
from cyclone import db
|
||||
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
claim_ids = _claim_ids_from_seed(seeded)
|
||||
assert len(claim_ids) >= 1
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
from cyclone.db import Claim
|
||||
for cid in claim_ids:
|
||||
c = s.get(Claim, cid)
|
||||
c.raw_json = None
|
||||
s.commit()
|
||||
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": claim_ids},
|
||||
)
|
||||
|
||||
assert r.status_code == 422, r.text
|
||||
body = r.json()
|
||||
# The 422 body surfaces the failure list so the UI can show details
|
||||
# without parsing a header.
|
||||
errs = body.get("detail", {}).get("serialize_errors") or body.get("serialize_errors")
|
||||
assert errs is not None
|
||||
assert len(errs) == len(claim_ids)
|
||||
for entry in errs:
|
||||
assert entry["claim_id"] in claim_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_empty_claim_ids_returns_400():
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": []},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_missing_claim_ids_key_returns_400():
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_unknown_batch_id_returns_404():
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/api/batches/BATCH-DOES-NOT-EXIST/export-837",
|
||||
json={"claim_ids": ["CLM-1"]},
|
||||
)
|
||||
assert r.status_code == 404, r.text
|
||||
body = r.json()
|
||||
assert "BATCH-DOES-NOT-EXIST" in (body.get("detail") or "")
|
||||
|
||||
|
||||
def test_unknown_claim_id_silently_omitted():
|
||||
"""A claim_id that doesn't exist is omitted from the ZIP and surfaced
|
||||
in the errors header — same convention as the resubmit endpoint."""
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
real_ids = _claim_ids_from_seed(seeded)
|
||||
assert real_ids, "fixture must produce at least 1 claim"
|
||||
|
||||
requested = real_ids + ["CLM-GHOST"]
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": requested},
|
||||
)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
# Filename uses success count = len(real_ids), not len(requested).
|
||||
assert (
|
||||
f"batch-{batch_id}-{len(real_ids)}-claims.zip"
|
||||
in r.headers["content-disposition"]
|
||||
)
|
||||
err_header = r.headers.get("x-cyclone-serialize-errors")
|
||||
assert err_header
|
||||
errs = json.loads(err_header)
|
||||
assert any(e["claim_id"] == "CLM-GHOST" for e in errs)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||
names = zf.namelist()
|
||||
# HCPF outbound filenames, one per real claim. We don't pin the
|
||||
# exact ts (it depends on the wall clock at test time), but the
|
||||
# count and the HCPF template must match.
|
||||
from cyclone.edi.filenames import is_outbound_filename
|
||||
assert len(names) == len(real_ids)
|
||||
for name in names:
|
||||
assert is_outbound_filename(name), f"non-HCPF name: {name!r}"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests that /api/parse-837 includes the persisted batch_id in its
|
||||
JSON response, so the frontend can correlate its in-memory batch with
|
||||
the server's row (and later call /api/batches/{id}/export-837).
|
||||
|
||||
The 409 (duplicate) path has always included batch_id; the 200 path
|
||||
did not. This brings the 200 path in line so the frontend doesn't have
|
||||
to query listBatches after every parse just to learn the server's id.
|
||||
"""
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone.api import app
|
||||
|
||||
|
||||
def test_parse_837_happy_path_includes_batch_id_in_response():
|
||||
with TestClient(app) as client:
|
||||
fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
|
||||
r = client.post(
|
||||
"/api/parse-837",
|
||||
files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert "batch_id" in body, "parse-837 response must include batch_id so the frontend can call /api/batches/{id}/export-837"
|
||||
# Sanity: the batch_id should be a non-empty string (UUID-shaped).
|
||||
assert isinstance(body["batch_id"], str) and body["batch_id"]
|
||||
@@ -27,7 +27,8 @@ MT = ZoneInfo("America/Denver")
|
||||
def test_build_outbound_with_explicit_mt():
|
||||
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
||||
name = build_outbound_filename("11525703", "837P", now_mt=now)
|
||||
assert name == "11525703-837P-20260620132243505-1of1.x12"
|
||||
# HCPF outbound format: tp prefix on the tpid
|
||||
assert name == "tp11525703-837P-20260620132243505-1of1.x12"
|
||||
|
||||
|
||||
def test_build_outbound_default_extension():
|
||||
@@ -39,15 +40,17 @@ def test_build_outbound_default_extension():
|
||||
def test_build_outbound_custom_extension():
|
||||
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
||||
name = build_outbound_filename("11525703", "837P", ext="txt", now_mt=now)
|
||||
assert name == "11525703-837P-20260620132243505-1of1.txt"
|
||||
assert name == "tp11525703-837P-20260620132243505-1of1.txt"
|
||||
|
||||
|
||||
def test_build_outbound_uses_mt_when_no_arg():
|
||||
# Snapshot test — the timestamp will be very recent; check format only
|
||||
name = build_outbound_filename("11525703", "837P")
|
||||
assert OUTBOUND_RE.match(name), name
|
||||
# tp11525703-837P-YYYYMMDDhhmmssSSS-1of1.x12 — 4 dash-separated parts
|
||||
parts = name.split("-")
|
||||
assert len(parts) == 4
|
||||
assert parts[0] == "tp11525703"
|
||||
assert len(parts[2]) == 17 # yyyymmddhhmmssSSS
|
||||
|
||||
|
||||
@@ -128,8 +131,9 @@ def test_parse_inbound_rejects_non_x12_ext():
|
||||
|
||||
|
||||
def test_roundtrip_outbound_to_inbound():
|
||||
# Outbound tpid is bare (no TP); inbound tpid is bare inside TP{...}
|
||||
# The two regexes use different shapes — round-trip via tpid only.
|
||||
# Outbound uses tp{...}, inbound uses TP{...} (case differs but both
|
||||
# prefixes are required). The two regexes use different shapes —
|
||||
# round-trip via tpid only.
|
||||
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
||||
out = build_outbound_filename("11525703", "837P", now_mt=now)
|
||||
assert OUTBOUND_RE.match(out)
|
||||
@@ -142,13 +146,19 @@ def test_roundtrip_outbound_to_inbound():
|
||||
|
||||
|
||||
def test_is_outbound_filename():
|
||||
assert is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
|
||||
# HCPF outbound always has the lowercase "tp" prefix
|
||||
assert is_outbound_filename("tp11525703-837P-20260620132243505-1of1.x12")
|
||||
# Bare tpid (no tp prefix) is no longer a valid outbound filename
|
||||
assert not is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
|
||||
# Uppercase TP prefix is the inbound shape, not outbound
|
||||
assert not is_outbound_filename("TP11525703-837P-20260620132243505-1of1.x12")
|
||||
assert not is_outbound_filename("not-a-filename")
|
||||
|
||||
|
||||
def test_is_inbound_filename():
|
||||
assert is_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.x12")
|
||||
# Lowercase tp prefix is the outbound shape, not inbound
|
||||
assert not is_inbound_filename("tp11525703-837P_M019048402-20260520231513488-1of1_999.x12")
|
||||
assert not is_inbound_filename("11525703-837P-20260620132243505-1of1.x12")
|
||||
|
||||
|
||||
|
||||
@@ -65,6 +65,21 @@ def test_build_gs_emits_gs_segment_with_hc_functional_id():
|
||||
assert parts[6] == "1"
|
||||
|
||||
|
||||
def test_build_gs_uses_gs04_yyyymmdd_8_digits():
|
||||
"""GS-04 must be CCYYMMDD (8 digits) per X12; ISA uses YYMMDD (6).
|
||||
|
||||
A 6-digit value like '260622' is rejected by EDI validators with
|
||||
'Element GS-04 must use CCYYMMDD date format'.
|
||||
"""
|
||||
gs = _build_gs("SENDER", "RECEIVER", "1")
|
||||
parts = gs.rstrip("~").split("*")
|
||||
# parts[4] = GS-04 date
|
||||
assert len(parts[4]) == 8, f"expected 8-digit CCYYMMDD, got {parts[4]!r}"
|
||||
# Must parse as a CCYYMMDD date
|
||||
from datetime import datetime
|
||||
datetime.strptime(parts[4], "%Y%m%d")
|
||||
|
||||
|
||||
def test_build_st_emits_837_segment():
|
||||
st = _build_st("0001")
|
||||
assert st.startswith("ST*837*0001*005010X222A1~")
|
||||
@@ -100,12 +115,15 @@ def test_build_nm1_person_entity_splits_first_last():
|
||||
assert parts[4] == "Jane"
|
||||
|
||||
|
||||
def test_build_per_returns_empty_when_no_contact():
|
||||
assert _build_per(None, None) == ""
|
||||
assert _build_per("", "") == ""
|
||||
def test_build_per_emits_per01_even_with_no_contact():
|
||||
"""PER is required by X12 Loop 1000A — at least PER01 must be present."""
|
||||
per = _build_per(None, None)
|
||||
assert per == "PER*IC~"
|
||||
per = _build_per("", "")
|
||||
assert per == "PER*IC~"
|
||||
|
||||
|
||||
def test_build_per_emits_segment_with_contact():
|
||||
def test_build_per_emits_segment_with_phone_contact():
|
||||
per = _build_per("Jane Doe", "5551234567")
|
||||
parts = per.rstrip("~").split("*")
|
||||
assert parts[0] == "PER"
|
||||
@@ -115,6 +133,25 @@ def test_build_per_emits_segment_with_contact():
|
||||
assert parts[4] == "5551234567"
|
||||
|
||||
|
||||
def test_build_per_emits_segment_with_email_contact():
|
||||
"""When email is given, it wins over phone (HCPF expects email)."""
|
||||
per = _build_per("Tyler Martinez", None, contact_email="tyler@dzinesco.com")
|
||||
parts = per.rstrip("~").split("*")
|
||||
assert parts[0] == "PER"
|
||||
assert parts[1] == "IC"
|
||||
assert parts[2] == "Tyler Martinez"
|
||||
assert parts[3] == "EM"
|
||||
assert parts[4] == "tyler@dzinesco.com"
|
||||
|
||||
|
||||
def test_build_per_email_takes_precedence_over_phone():
|
||||
"""If both phone and email are given, email is emitted (PER04)."""
|
||||
per = _build_per("Tyler", "555-1234", contact_email="t@example.com")
|
||||
parts = per.rstrip("~").split("*")
|
||||
assert parts[3] == "EM"
|
||||
assert parts[4] == "t@example.com"
|
||||
|
||||
|
||||
def test_build_n3_returns_empty_when_no_address():
|
||||
assert _build_n3(None, None) == ""
|
||||
assert _build_n3("", "") == ""
|
||||
@@ -133,12 +170,33 @@ def test_build_hl_emits_segment():
|
||||
assert hl == "HL*1**20*1~"
|
||||
|
||||
|
||||
def test_build_sbr_emits_segment():
|
||||
sbr = _build_sbr("18", "M123", "PAYER")
|
||||
def test_build_sbr_emits_segment_with_correct_slots():
|
||||
"""SBR01=Payer Responsibility Seq Code (default 'P'),
|
||||
SBR02=Individual Relationship Code (e.g. '18' for self),
|
||||
SBR09=Claim Filing Indicator Code (e.g. 'MC' for Medicaid)."""
|
||||
sbr = _build_sbr("18", "MC")
|
||||
parts = sbr.rstrip("~").split("*")
|
||||
assert parts[0] == "SBR"
|
||||
assert parts[1] == "18"
|
||||
assert parts[9] == "M123"
|
||||
# SBR01 — primary
|
||||
assert parts[1] == "P"
|
||||
# SBR02 — individual relationship (self = 18)
|
||||
assert parts[2] == "18"
|
||||
# SBR09 — claim filing indicator
|
||||
assert parts[9] == "MC"
|
||||
# Member ID and payer name do NOT belong in SBR — they live in
|
||||
# NM109 and NM1*PR.NM103 respectively.
|
||||
assert "M123" not in sbr
|
||||
assert "PAYER" not in sbr
|
||||
|
||||
|
||||
def test_build_sbr_defaults_relationship_to_self_and_filing_to_empty():
|
||||
"""When called with all-None, SBR01/02 fall back to safe defaults
|
||||
and SBR09 is left empty (the validator's R202 rule will then skip)."""
|
||||
sbr = _build_sbr(None, None)
|
||||
parts = sbr.rstrip("~").split("*")
|
||||
assert parts[1] == "P"
|
||||
assert parts[2] == "18"
|
||||
assert parts[9] == ""
|
||||
|
||||
|
||||
def test_build_ref_returns_empty_when_no_value():
|
||||
@@ -200,6 +258,34 @@ def test_build_clm_emits_clm01_to_clm05():
|
||||
assert parts[5] == "11:1"
|
||||
|
||||
|
||||
def test_build_clm_emits_clm08_defaulting_to_y():
|
||||
"""CLM-08 (Benefits Assignment Certification) is required by X12
|
||||
837P when CLM-07 = 'Y'. Default to 'Y' when the source didn't
|
||||
capture one (matches what 99% of HCPF files look like).
|
||||
"""
|
||||
claim = _stub_claim_header()
|
||||
clm = _build_clm(claim)
|
||||
parts = clm.rstrip("~").split("*")
|
||||
# parts[8] = CLM-08
|
||||
assert parts[8] == "Y", f"CLM-08 should default to 'Y', got {parts[8]!r}"
|
||||
|
||||
|
||||
def test_build_clm_propagates_captured_clm08():
|
||||
from cyclone.parsers.models import ClaimHeader
|
||||
claim = ClaimHeader(
|
||||
claim_id="CLM-1",
|
||||
total_charge=Decimal("100.00"),
|
||||
place_of_service="11",
|
||||
frequency_code="1",
|
||||
assignment="Y",
|
||||
benefits_assignment_certification="N",
|
||||
release_of_info="Y",
|
||||
)
|
||||
clm = _build_clm(claim)
|
||||
parts = clm.rstrip("~").split("*")
|
||||
assert parts[8] == "N"
|
||||
|
||||
|
||||
def test_build_ref_g1_returns_empty_when_no_prior_auth():
|
||||
assert _build_ref_g1(None) == ""
|
||||
assert _build_ref_g1("") == ""
|
||||
@@ -242,6 +328,57 @@ def test_build_sv1_emits_procedure_modifiers_charge_units():
|
||||
assert parts[4] == "1"
|
||||
|
||||
|
||||
def test_build_sv1_emits_sv1_06_and_sv1_07_when_dx_pointer_given():
|
||||
"""SV1-07 (Diagnosis Code Pointer) is required by X12/HCPF when the
|
||||
parent claim has an HI segment (i.e. has at least one diagnosis).
|
||||
|
||||
Per 005010X222A1, SV1-06 is "Not Used" by the guide and MUST be
|
||||
empty. Unit basis (UN/MJ/...) goes only in SV1-03.
|
||||
"""
|
||||
line = _stub_service_line()
|
||||
sv1 = _build_sv1(line, dx_pointer="1")
|
||||
parts = sv1.rstrip("~").split("*")
|
||||
# parts layout: SV1, comp(SV1-01), charge(02), unit_basis(03),
|
||||
# units(04), pos(05), ""(06 NOT USED), sv1_07(07)
|
||||
assert len(parts) == 8, f"expected 8 elements, got {parts}"
|
||||
# SV1-03 = unit basis
|
||||
assert parts[3] == "UN"
|
||||
# SV1-06 = "" (Not Used by 837P guide)
|
||||
assert parts[6] == "", f"SV1-06 must be empty (Not Used by 837P), got {parts[6]!r}"
|
||||
# SV1-07 = pointer
|
||||
assert parts[7] == "1"
|
||||
|
||||
|
||||
def test_build_sv1_omits_sv1_07_when_no_dx_pointer():
|
||||
"""When the claim has no HI segment, SV1-07 should be empty."""
|
||||
line = _stub_service_line()
|
||||
sv1 = _build_sv1(line) # no dx_pointer kwarg
|
||||
parts = sv1.rstrip("~").split("*")
|
||||
assert len(parts) == 8
|
||||
assert parts[6] == "" # SV1-06 still empty
|
||||
assert parts[7] == "" # SV1-07 empty
|
||||
|
||||
|
||||
def test_build_sv1_matches_goodclaim_layout():
|
||||
"""Layout must match the known-good reference at docs/goodclaim.x12:
|
||||
|
||||
SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~
|
||||
|
||||
i.e. 8 fields total: comp, charge, UN, units, '', '', '1'.
|
||||
"""
|
||||
from cyclone.parsers.models import Procedure, ServiceLine
|
||||
line = ServiceLine(
|
||||
line_number=1,
|
||||
procedure=Procedure(qualifier="HC", code="T1019", modifiers=["U1", "KX"]),
|
||||
charge=Decimal("125.40"),
|
||||
units=Decimal("19.00"),
|
||||
unit_type="UN",
|
||||
place_of_service=None,
|
||||
)
|
||||
sv1 = _build_sv1(line, dx_pointer="1")
|
||||
assert sv1 == "SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~"
|
||||
|
||||
|
||||
def test_build_dtp_472_emits_service_date():
|
||||
assert _build_dtp_472(date(2026, 6, 15)) == "DTP*472*D8*20260615~"
|
||||
|
||||
@@ -369,6 +506,76 @@ def test_serialize_837_uses_custom_sender_receiver_ids():
|
||||
parse(text, _CFG)
|
||||
|
||||
|
||||
def test_serialize_837_emits_per_segment_in_submitter_block():
|
||||
"""X12 Loop 1000A (Submitter Name) requires a PER segment after
|
||||
NM1*41. The serializer must emit one even with no contact info
|
||||
(PER01='IC' is the only required element)."""
|
||||
claim = _load_claim()
|
||||
text = serialize_837(claim)
|
||||
# The first NM1*41 should be followed immediately by a PER segment.
|
||||
seg_ids = [seg.split("*")[0] for seg in text.split("~") if seg]
|
||||
nm1_41_idx = seg_ids.index("NM1") # first NM1 is the submitter
|
||||
assert nm1_41_idx >= 0
|
||||
# The very next segment must be PER (PER01='IC' is required by spec).
|
||||
assert seg_ids[nm1_41_idx + 1] == "PER"
|
||||
per_line = next(seg for seg in text.split("~") if seg.startswith("PER*IC"))
|
||||
assert per_line.startswith("PER*IC")
|
||||
|
||||
|
||||
def test_serialize_837_per_segment_includes_email_from_kwargs():
|
||||
"""Passing submitter_contact_email should emit PER*IC*<name>*EM*<email>."""
|
||||
claim = _load_claim()
|
||||
text = serialize_837(
|
||||
claim,
|
||||
sender_id="DZINESCO",
|
||||
submitter_name="Dzinesco",
|
||||
submitter_contact_name="Tyler Martinez",
|
||||
submitter_contact_email="tyler@dzinesco.com",
|
||||
)
|
||||
assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text
|
||||
# And the ISA sender id should be the clearhouse TPID, not "CYCLONE".
|
||||
assert "ZZ*DZINESCO" in text
|
||||
assert "ZZ*CYCLONE" not in text
|
||||
|
||||
|
||||
def test_serialize_837_sbr09_uses_claim_filing_indicator_code_kwarg():
|
||||
"""SBR09 must be the claim filing indicator (e.g. 'MC' for Medicaid),
|
||||
not the member id. The serializer takes it from the kwarg."""
|
||||
claim = _load_claim()
|
||||
text = serialize_837(claim, claim_filing_indicator_code="MC")
|
||||
sbr_line = next(seg for seg in text.split("~") if seg.startswith("SBR*"))
|
||||
parts = sbr_line.rstrip("~").split("*")
|
||||
# SBR01 = P (primary), SBR02 = 18 (self), SBR09 = MC
|
||||
assert parts[1] == "P"
|
||||
assert parts[2] == "18"
|
||||
assert parts[9] == "MC"
|
||||
# And the member id should NOT be in SBR.
|
||||
assert claim.subscriber.member_id not in sbr_line
|
||||
|
||||
|
||||
def test_serialize_837_for_resubmit_forwards_kwargs_to_serialize_837():
|
||||
"""serialize_837_for_resubmit is a thin wrapper — it must forward
|
||||
clearhouse + payer kwargs so the export endpoint can use it."""
|
||||
claim = _load_claim()
|
||||
text = serialize_837_for_resubmit(
|
||||
claim,
|
||||
interchange_index=7,
|
||||
sender_id="DZINESCO",
|
||||
submitter_name="Dzinesco",
|
||||
submitter_contact_name="Tyler Martinez",
|
||||
submitter_contact_email="tyler@dzinesco.com",
|
||||
receiver_id="COMEDASSISTPROG",
|
||||
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
|
||||
claim_filing_indicator_code="MC",
|
||||
)
|
||||
assert "ZZ*DZINESCO" in text
|
||||
assert "ZZ*COMEDASSISTPROG" in text
|
||||
assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text
|
||||
# Control numbers reflect the resubmit index.
|
||||
isa = next(seg for seg in text.split("~") if seg.startswith("ISA*"))
|
||||
assert "000000007" in isa
|
||||
|
||||
|
||||
def test_serialize_error_is_an_exception():
|
||||
assert issubclass(SerializeError, Exception)
|
||||
|
||||
|
||||
@@ -31,13 +31,13 @@ def sftp_block(tmp_path):
|
||||
|
||||
def test_stub_writes_preserving_remote_path(sftp_block, tmp_path):
|
||||
client = SftpClient(sftp_block)
|
||||
remote = "/CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
|
||||
remote = "/CO XIX/PROD/coxix_prod_11525703/FromHPE/tp11525703-837P-20260620132243505-1of1.x12"
|
||||
target = client.write_file(remote, b"ISA*00*...~IEA*1*1~")
|
||||
assert target.exists()
|
||||
assert target.read_bytes() == b"ISA*00*...~IEA*1*1~"
|
||||
# Confirm the full nested MFT path is preserved under staging
|
||||
rel = target.relative_to(sftp_block.staging_dir)
|
||||
assert str(rel) == "CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
|
||||
assert str(rel) == "CO XIX/PROD/coxix_prod_11525703/FromHPE/tp11525703-837P-20260620132243505-1of1.x12"
|
||||
|
||||
|
||||
def test_stub_creates_parent_dirs(sftp_block):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ISA*00* *00* *ZZ*11525703 *ZZ*COMEDASSISTPROG*240911*2240*^*00501*240901088*1*P*:~GS*HC*11525703*COMEDASSISTPROG*20240911*224042*240007724*X*005010X222A1~ST*837*24007724*005010X222A1~BHT*0019*00*s9911i2440g11525703d*20240911*224042*CH~NM1*41*2*Dzinesco*****46*11525703~PER*IC*Tyler Martinez*EM*tmartinez@gmail.com~NM1*40*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****46*COMEDASSISTPROG~HL*1**20*1~PRV*BI*PXC*251E00000X~NM1*85*2*TOC, Inc.*****XX*1881068062~N3*1100 East Main St*Suite A~N4*Montrose*CO*814014063~REF*EI*721587149~HL*2*1*22*0~SBR*P*18*******MC~NM1*IL*1*Phillips*Esther ****MI*J851806~N3*579 NORWOOD RD~N4*Montrose*CO*814034628~DMG*D8*19390307*F~NM1*PR*2*CO_TXIX*****PI*CO_TXIX~CLM*t991102440o1c79d*595.32***12:B:1*Y*A*Y*Y~REF*G1*2~HI*ABK:R69~LX*1~SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~DTP*472*D8*20240902~REF*6R*t991102440v587348d~LX*2~SV1*HC:T1019:U1:KX*123.20*UN*18.67***1~DTP*472*D8*20240903~REF*6R*t991102440v587638d~LX*3~SV1*HC:T1019:U1:KX*123.64*UN*18.73***1~DTP*472*D8*20240904~REF*6R*t991102440v587903d~LX*4~SV1*HC:T1019:U1:KX*123.64*UN*18.73***1~DTP*472*D8*20240905~REF*6R*t991102440v588158d~LX*5~SV1*HC:T1019:U1:KX*99.44*UN*15.07***1~DTP*472*D8*20240906~REF*6R*t991102440v588441d~SE*42*24007724~GE*1*240007724~IEA*1*240901088~
|
||||
@@ -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
+61
-18
@@ -67,9 +67,15 @@ No CLI / settings changes.
|
||||
-- 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 foreign_keys=OFF;
|
||||
BEGIN;
|
||||
PRAGMA defer_foreign_keys = ON;
|
||||
|
||||
CREATE TABLE claims_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -111,9 +117,6 @@ 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;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
```
|
||||
|
||||
**Note on the `db.py` ORM model:** `backend/src/cyclone/db.py` declares
|
||||
@@ -165,29 +168,65 @@ Both pure reads; no transaction management needed.
|
||||
|
||||
### 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:
|
||||
existing_batch_id = store.find_existing_batch_for_claim(...)
|
||||
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": "...",
|
||||
"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:
|
||||
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 `...` argument is the first `claim.claim_id` from `result.claims`
|
||||
where the collision occurred. We pick the first one because the DB raises
|
||||
on the second insert; iterating through the parser's claim list to find
|
||||
which CLM01 already existed is cheap.
|
||||
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 existing remit's
|
||||
`payer_claim_control_number`.
|
||||
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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -309,12 +348,16 @@ No new dependencies. No config changes.
|
||||
|
||||
## 9. Risk
|
||||
|
||||
* **Migration irreversibility**: SQLite has no `DROP CONSTRAINT`; the
|
||||
* **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 transaction rolls back. The
|
||||
`PRAGMA foreign_keys=OFF` block is required because SQLite otherwise
|
||||
can't drop and rename a table that other tables reference.
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,816 @@
|
||||
# Cyclone Ubuntu Docker Deployment — Design
|
||||
|
||||
**Date:** 2026-06-22
|
||||
**Status:** Approved (pending user review of this doc)
|
||||
**Depends on:** [2026-06-19-cyclone-production-readiness-design.md](2026-06-19-cyclone-production-readiness-design.md) (in-memory store + react-query wiring; local-only)
|
||||
**Replaces:** The "no auth, no Docker, local-only" posture of the parent spec with a production-grade posture for real PHI on a single Ubuntu server.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Cyclone's first sub-project shipped a usable local-only system: parse 837/835 files, browse the data, edit and resubmit rejected claims, export a corrected 837 ZIP. Everything ran on `127.0.0.1` with no auth, in-memory storage, and a `python -m cyclone serve` invocation.
|
||||
|
||||
This sub-project graduates that to a production-grade deployment on a single Ubuntu Linux server:
|
||||
|
||||
- **Two-container Docker Compose** (backend + frontend) replacing the dev-server split
|
||||
- **App-layer authentication** with username + password, argon2id hashing, session cookies, and three fixed roles (admin / operator / viewer)
|
||||
- **SQLCipher encryption at rest** for the SQLite DB, with the key as a Docker secret
|
||||
- **Daily encrypted backups** with the existing AES-256-GCM `BackupService`, scheduler autostart, 14-day retention
|
||||
- **LAN-only bind** (single operator, single Ubuntu server); VPN handles outside access
|
||||
- **Manual `docker compose pull` updates** with semver tags; one-env-var rollback
|
||||
- **Operator runbook** + bootstrap scripts so the deploy is reproducible
|
||||
|
||||
Real PHI flows through this deployment. The SFTP wire stays stubbed — operators download the corrected 837 ZIP and hand it to the Gainwell MFT UI manually. That's intentional and documented as v1 scope; SFTP wire is v2.
|
||||
|
||||
After this ships, the operator can:
|
||||
1. `git clone <repo> /opt/cyclone && cd /opt/cyclone`
|
||||
2. `docker compose build`
|
||||
3. `bash scripts/cyclone-init.sh` → generates `/etc/cyclone/secrets/{db,secret,admin_pw}.key`, prints the admin password once
|
||||
4. `docker compose up -d`
|
||||
5. Open `http://<lan-ip>:8080/login` → log in as `admin`, change password, create operators
|
||||
6. Upload parsed 837s, edit claims, export ZIPs, hand to MFT UI
|
||||
7. Trust daily encrypted backups + the operator's off-box cron copy for ≤24h loss
|
||||
|
||||
## 2. Goals
|
||||
|
||||
1. **Dockerize the existing stack** so it runs the same way in production as in development. One repo, one `docker compose up -d`.
|
||||
2. **Add app-layer authentication** with username/password + 3-role RBAC, replacing the current "no auth at all" posture.
|
||||
3. **Encrypt the SQLite DB at rest** via SQLCipher; key as a Docker secret (not an env var, not a keychain on macOS).
|
||||
4. **Wire the existing encrypted backup system** to autostart on container boot with sane defaults (24h interval, 14-day retention).
|
||||
5. **Add an operator runbook + bootstrap scripts** so the system is reproducible from `git clone` and supportable without tribal knowledge.
|
||||
6. **Preserve all existing functionality** — the parser, editor, exporter, scheduler, audit log, backup/restore flow all keep working unchanged from the user's perspective.
|
||||
|
||||
## 3. Non-goals (this sub-project)
|
||||
|
||||
- **Real SFTP wire (paramiko)** — keep stub. Operators download ZIPs and use the MFT UI. Listed as v2.
|
||||
- **Public TLS / public domain / Caddy reverse proxy** — LAN-only bind for v1; VPN handles outside access. Listed as v2.
|
||||
- **Multi-host HA / DB replication / load balancing** — single Ubuntu server. Listed as v2.
|
||||
- **Prometheus / Grafana / real alerting** — v1 uses the simple `curl healthz | mail` cron pattern. Listed as v2.
|
||||
- **Off-box automated backup shipping** — operator's responsibility via host cron / rsync. Documented in runbook, not automated.
|
||||
- **2FA / SSO / OAuth** — v2.
|
||||
- **Self-service password reset** — v2; v1 admin resets via `cyclone admin reset-password` CLI.
|
||||
- **Per-file encryption of prodfiles / sftp_staging volumes** — v2; v1 relies on volume-level filesystem permissions + the SQLCipher-encrypted DB + the host being physically secure.
|
||||
- **LUKS full-disk encryption** — out of scope; assumed to be an operator-level concern (Ubuntu installer offers it).
|
||||
- **Automatic update mechanism** (Watchtower etc.) — v1 is manual `docker compose pull`. Listed as v2.
|
||||
- **New X12 transaction types or validation rules** — not this sub-project.
|
||||
|
||||
## 4. Stack
|
||||
|
||||
**Backend additions:**
|
||||
- `argon2-cffi` (new dep) for password hashing
|
||||
- `cyclone.auth` module: `User` / `Session` SQLAlchemy models, password hashing helpers, lockout logic
|
||||
- `cyclone.api.auth` routes: `/api/auth/{login,logout,change-password,me}`
|
||||
- `cyclone.api.users` routes: `/api/admin/users` (admin-only CRUD)
|
||||
- `cyclone.cli` additions: `cyclone admin {create-user,reset-password,list-users}`
|
||||
- Migration `0012_users_sessions.sql` for `users` + `sessions` tables
|
||||
- Existing `BackupService` (SP17) and `audit_log` machinery reused unchanged
|
||||
|
||||
**Backend Dockerfile** (`backend/Dockerfile`):
|
||||
- Multi-stage: builder stage installs `.[sqlcipher]` (sqlcipher is optional but recommended; the engine falls back to plain SQLite if the package isn't actually present, same graceful default as today), runtime stage copies installed site-packages + source
|
||||
- Base: `python:3.11-slim-bookworm` (matches `requires-python = ">=3.11"`)
|
||||
- Non-root user `cyclone` (uid 1000)
|
||||
- `HEALTHCHECK CMD curl -fs http://localhost:8000/api/healthz || exit 1`
|
||||
- Entrypoint: `python -m cyclone serve`
|
||||
|
||||
**Frontend additions:**
|
||||
- `nginx.conf` for SPA routing + reverse proxy to backend
|
||||
- `Dockerfile.frontend`: multi-stage builder (node:20-alpine runs `npm ci && npm run build`) + runtime (nginx:1.27-alpine serving `dist/`)
|
||||
- `useCurrentUser()` hook (react-query)
|
||||
- `Login.tsx` page
|
||||
- `<AuthGate>` wrapper in `App.tsx`
|
||||
- `<NavBar>` shows username + role badge + logout button
|
||||
- `/admin/users` page (admin-only)
|
||||
|
||||
**Infrastructure:**
|
||||
- `docker-compose.yml` at repo root
|
||||
- `scripts/cyclone-init.sh` — generates `/etc/cyclone/secrets/*` with `openssl rand -hex 32`
|
||||
- `scripts/post-deploy.sh` — sets up logrotate + healthcheck cron on host
|
||||
- `scripts/smoke.sh` — bring-up + login + parse + export end-to-end test
|
||||
- `RUNBOOK.md` — daily/weekly/quarterly/as-needed ops procedures
|
||||
- `.dockerignore` files for both build contexts
|
||||
|
||||
## 5. Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Ubuntu Linux server (your machine) │
|
||||
│ │
|
||||
│ cyclone_network (bridge) │
|
||||
│ ┌─────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ cyclone-backend │ │ cyclone-frontend │ │
|
||||
│ │ FastAPI │◄───┤ nginx (SPA + proxy)│ │
|
||||
│ │ :8000 (internal) │ │ :8080 (host) │ │
|
||||
│ └──────┬──────────────┘ └─────────┬──────────┘ │
|
||||
│ │ │ │
|
||||
└─────────┼─────────────────────────────┼────────────┘
|
||||
│ │
|
||||
┌──────▼──────────────────┐ ┌──────▼──────────┐
|
||||
│ Named volumes │ │ Bind mounts │
|
||||
│ cyclone_db │ │ ./dist (built) │
|
||||
│ cyclone_backups │ │ ./nginx.conf │
|
||||
│ cyclone_prodfiles │ └─────────────────┘
|
||||
│ cyclone_sftp_staging │
|
||||
└─────────────────────────┘
|
||||
|
||||
/etc/cyclone/secrets/ (host, chmod 600, root:root)
|
||||
├─ db.key → /run/secrets/cyclone_db_key
|
||||
├─ secret.key → /run/secrets/cyclone_secret_key
|
||||
└─ admin_pw → /run/secrets/cyclone_admin_password
|
||||
```
|
||||
|
||||
**Why two containers:** nginx serves the React build faster than FastAPI would, decouples frontend rebuild cadence from backend release cadence, and is the standard ops pattern. nginx also reverse-proxies `/api/*` to the backend so the browser only talks to one origin (no CORS needed, no preflight requests for the `Cookie` header).
|
||||
|
||||
**Why LAN-only bind:** nginx listens on `0.0.0.0:8080` inside the host network namespace, but the operator is expected to bind to the LAN IP via firewall rules / not exposing on the WAN. VPN handles outside access. No public TLS needed for v1.
|
||||
|
||||
**Container-to-container networking:** the frontend container reaches the backend at `http://cyclone-backend:8000` over the compose-managed bridge network. The browser only ever sees `http://<lan-ip>:8080`.
|
||||
|
||||
**Healthcheck:** container-level `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries. Compose `restart: unless-stopped` on healthcheck failure.
|
||||
|
||||
**Reverse proxy in nginx:**
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# API + auth: proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://cyclone-backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s; # long enough for big parse streams
|
||||
client_max_body_size 50m; # claims files can be large
|
||||
}
|
||||
|
||||
# SPA: serve index.html for all non-/api routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Backend changes
|
||||
|
||||
### 6.1 New module `backend/src/cyclone/auth/__init__.py`
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class Role:
|
||||
VIEWER = "viewer"
|
||||
OPERATOR = "operator"
|
||||
ADMIN = "admin"
|
||||
|
||||
ROLE_RANK = {Role.VIEWER: 0, Role.OPERATOR: 1, Role.ADMIN: 2}
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id: Mapped[str] # uuid4 hex
|
||||
username: Mapped[str] # UNIQUE
|
||||
password_hash: Mapped[str] # argon2id
|
||||
role: Mapped[str] # viewer | operator | admin
|
||||
created_at: Mapped[datetime]
|
||||
last_login_at: Mapped[datetime | None]
|
||||
failed_count: Mapped[int] = 0
|
||||
locked_until: Mapped[datetime | None] = None
|
||||
|
||||
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
id: Mapped[str] # 32-byte hex token
|
||||
user_id: Mapped[str]
|
||||
created_at: Mapped[datetime]
|
||||
expires_at: Mapped[datetime]
|
||||
ip: Mapped[str | None]
|
||||
user_agent: Mapped[str | None]
|
||||
```
|
||||
|
||||
Password hashing uses `argon2-cffi` with the OWASP-recommended parameters (`time_cost=2`, `memory_cost=19456`, `parallelism=1`, `hash_len=32`).
|
||||
|
||||
### 6.2 Auth routes in `backend/src/cyclone/api.py` (new router `auth_router`)
|
||||
|
||||
| Method | Path | Behavior | Auth |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/auth/login` | `{username, password}` → verify argon2id, increment `failed_count` on miss (lock at 5/15min), create session, set `HttpOnly; Secure; SameSite=Lax` cookie | none |
|
||||
| POST | `/api/auth/logout` | delete session row, clear cookie | any logged-in |
|
||||
| GET | `/api/auth/me` | `{user: {username, role}}` or 401 | any logged-in |
|
||||
| POST | `/api/auth/change-password` | `{old_password, new_password}` → verify, rehash | any logged-in |
|
||||
| POST | `/api/admin/users` | `{username, password, role}` → create user | admin |
|
||||
| GET | `/api/admin/users` | list users | admin |
|
||||
| PATCH | `/api/admin/users/{id}` | `{role?, password?}` | admin |
|
||||
| DELETE | `/api/admin/users/{id}` | soft-disable (set `disabled_at`) | admin |
|
||||
|
||||
### 6.3 The `require_role` dependency
|
||||
|
||||
Every existing protected route gets a `dependencies=[Depends(require_role(Role.OPERATOR))]` annotation:
|
||||
|
||||
```python
|
||||
def require_role(min_role: str):
|
||||
"""FastAPI dependency factory: read cookie, load user, enforce role."""
|
||||
def dep(request: Request) -> User:
|
||||
sid = request.cookies.get("cyclone_session")
|
||||
if not sid:
|
||||
raise HTTPException(401, "not authenticated")
|
||||
with db.SessionLocal()() as s:
|
||||
sess = s.get(Session, sid)
|
||||
if sess is None or sess.expires_at < utcnow():
|
||||
raise HTTPException(401, "session expired")
|
||||
user = s.get(User, sess.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(401, "user gone")
|
||||
if user.locked_until and user.locked_until > utcnow():
|
||||
raise HTTPException(423, "account locked")
|
||||
if ROLE_RANK[user.role] < ROLE_RANK[min_role]:
|
||||
raise HTTPException(403, f"requires {min_role}")
|
||||
request.state.user = user
|
||||
request.state.actor = user.username # existing audit-log contract
|
||||
return user
|
||||
return dep
|
||||
```
|
||||
|
||||
**Role → action matrix:**
|
||||
|
||||
| Action | viewer | operator | admin |
|
||||
|---|:-:|:-:|:-:|
|
||||
| View claims, batches, acks | ✓ | ✓ | ✓ |
|
||||
| `POST /api/parse-837`, `/api/parse-835` | — | ✓ | ✓ |
|
||||
| Edit claim fields, resubmit rejected | — | ✓ | ✓ |
|
||||
| `POST /api/batches/{id}/export-837` | — | ✓ | ✓ |
|
||||
| `POST /api/clearhouse/submit` | — | ✓ | ✓ |
|
||||
| `/api/admin/users` CRUD | — | — | ✓ |
|
||||
| `/api/admin/backup/*` | — | — | ✓ |
|
||||
| `/api/config/*` (clearhouse, payers) | — | — | ✓ |
|
||||
| `/api/admin/audit-log` | — | — | ✓ |
|
||||
| Backup scheduler on/off | — | — | ✓ |
|
||||
|
||||
**Login rate-limit:** the existing `cyclone.api.security.request_rejected` machinery already rate-limits by IP; we add per-username lockout on top:
|
||||
- `failed_count >= 5` within 15min → `locked_until = now + 15min`
|
||||
- Locked accounts return 423; the lock clears after the cooldown
|
||||
|
||||
### 6.4 Audit log wiring
|
||||
|
||||
The existing `audit_log.append(actor=..., ...)` calls already accept an actor string. The new auth dependency sets `request.state.actor = user.username`, and a small middleware reads it for every request. No audit-log schema change.
|
||||
|
||||
### 6.5 CLI additions
|
||||
|
||||
```bash
|
||||
cyclone admin create-user --username X --role admin --password <from_stdin or --password-file>
|
||||
cyclone admin reset-password --username X --new-password <from_stdin or --password-file>
|
||||
cyclone admin list-users
|
||||
```
|
||||
|
||||
Used by `scripts/cyclone-init.sh` for the bootstrap admin, and by the operator for password recovery.
|
||||
|
||||
### 6.6 Migration `0012_users_sessions.sql`
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
last_login_at TIMESTAMP,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMP,
|
||||
disabled_at TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
ip TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
||||
```
|
||||
|
||||
### 6.7 Session timeout config
|
||||
|
||||
| Setting | Default | Env var |
|
||||
|---|---|---|
|
||||
| Session absolute lifetime | 30 days | `CYCLONE_SESSION_ABSOLUTE_DAYS` |
|
||||
| Session sliding expiry | 8 hours | `CYCLONE_SESSION_SLIDING_HOURS` |
|
||||
| Cookie name | `cyclone_session` | `CYCLONE_COOKIE_NAME` |
|
||||
| Cookie `Secure` flag | true | always true in prod |
|
||||
|
||||
### 6.8 Backend Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# Builder stage
|
||||
FROM python:3.11-slim-bookworm AS builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential libsqlcipher-dev libffi-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /build
|
||||
COPY pyproject.toml .
|
||||
COPY src/ ./src/
|
||||
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]'
|
||||
|
||||
# Runtime stage
|
||||
FROM python:3.11-slim-bookworm
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libsqlcipher-dev curl tini \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --create-home --uid 1000 cyclone
|
||||
WORKDIR /app
|
||||
COPY --from=builder /wheels /wheels
|
||||
RUN pip install --no-cache-dir --no-index --find-links /wheels cyclone
|
||||
COPY src/ /app/src/
|
||||
USER cyclone
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -fs http://localhost:8000/api/healthz || exit 1
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["python", "-m", "cyclone", "serve"]
|
||||
```
|
||||
|
||||
`sqlcipher` is included but the engine falls back to plain SQLite if the package isn't actually installed — same graceful default as today, just biased toward enabling encryption in prod.
|
||||
|
||||
## 7. Frontend changes
|
||||
|
||||
### 7.1 New login page `src/pages/Login.tsx`
|
||||
|
||||
A simple form: username + password inputs, submit button, error message slot. On success, navigate to `/`. Lives outside the auth-gated shell, served from a route that doesn't require authentication.
|
||||
|
||||
```tsx
|
||||
export default function Login() {
|
||||
const login = useMutation({
|
||||
mutationFn: ({ username, password }) => api.login(username, password),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me'] }),
|
||||
});
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 `useCurrentUser` hook
|
||||
|
||||
```ts
|
||||
export function useCurrentUser() {
|
||||
return useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: () => api.me(),
|
||||
retry: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 AuthGate in `App.tsx`
|
||||
|
||||
```tsx
|
||||
function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const { data: user, isLoading } = useCurrentUser();
|
||||
const location = useLocation();
|
||||
if (isLoading) return <FullPageSkeleton />;
|
||||
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
Wrap every route except `/login` in `<AuthGate>`. `/login` itself does *not* require auth.
|
||||
|
||||
### 7.4 Role-based UI gating
|
||||
|
||||
```ts
|
||||
const ROLE_RANK = { viewer: 0, operator: 1, admin: 2 };
|
||||
export function useCan(minRole: 'viewer' | 'operator' | 'admin'): boolean {
|
||||
const { data: user } = useCurrentUser();
|
||||
if (!user) return false;
|
||||
return ROLE_RANK[user.role] >= ROLE_RANK[minRole];
|
||||
}
|
||||
```
|
||||
|
||||
Used in NavBar to hide "Admin" links from operators/viewers, and on the `/admin/users` page as a guard (`if (!useCan('admin')) return <Forbidden />`).
|
||||
|
||||
### 7.5 NavBar changes
|
||||
|
||||
- Username + role badge (admin/operator/viewer) shown right-aligned
|
||||
- Logout button (calls `api.logout()`, clears react-query cache, navigates to `/login`)
|
||||
- "Admin" dropdown appears only when `useCan('admin')`
|
||||
|
||||
### 7.6 `/admin/users` page (admin only)
|
||||
|
||||
Table of users (username, role, last login, status). Actions:
|
||||
- **Create user** (modal with username + password + role)
|
||||
- **Edit role** (inline select)
|
||||
- **Reset password** (modal)
|
||||
- **Disable / enable** (toggle; sets `disabled_at`)
|
||||
|
||||
### 7.7 api.ts additions
|
||||
|
||||
```ts
|
||||
api.login(username: string, password: string): Promise<{ user: User }>
|
||||
api.logout(): Promise<void>
|
||||
api.me(): Promise<{ user: User }> // throws 401 if not logged in
|
||||
api.changePassword(oldPw: string, newPw: string): Promise<void>
|
||||
api.listUsers(): Promise<User[]>
|
||||
api.createUser(...): Promise<User>
|
||||
api.updateUser(id: string, ...): Promise<User>
|
||||
api.deleteUser(id: string): Promise<void>
|
||||
```
|
||||
|
||||
All `api.*` calls send `credentials: 'include'` so the session cookie flows.
|
||||
|
||||
### 7.8 Frontend Dockerfile + nginx.conf
|
||||
|
||||
`frontend/Dockerfile`:
|
||||
```dockerfile
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /build
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Runtime stage
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /build/dist /usr/share/nginx/html
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost:8080/ >/dev/null || exit 1
|
||||
```
|
||||
|
||||
The nginx config shown in §5 lives at `frontend/nginx.conf`.
|
||||
|
||||
## 8. Data, secrets, backups
|
||||
|
||||
### 8.1 Named volumes
|
||||
|
||||
| Volume | Mount path in container | Contents |
|
||||
|---|---|---|
|
||||
| `cyclone_db` | `/var/lib/cyclone/db/cyclone.db` | SQLCipher-encrypted SQLite |
|
||||
| `cyclone_backups` | `/var/lib/cyclone/backups/` | AES-256-GCM `.bin` + `.meta.json` |
|
||||
| `cyclone_prodfiles` | `/var/lib/cyclone/prodfiles/` | Uploaded 837 `.txt`, downloaded 835 |
|
||||
| `cyclone_sftp_staging` | `/var/lib/cyclone/sftp_staging/` | SFTP stub's outbound/inbound |
|
||||
|
||||
These are managed by Docker (`docker volume create` / compose `volumes:`); they live under `/var/lib/docker/volumes/` on the host.
|
||||
|
||||
### 8.2 Docker secrets (host-managed)
|
||||
|
||||
| File | Mounted as | Purpose |
|
||||
|---|---|---|
|
||||
| `/etc/cyclone/secrets/db.key` | `/run/secrets/cyclone_db_key` | SQLCipher `PRAGMA key` — 64 hex chars |
|
||||
| `/etc/cyclone/secrets/secret.key` | `/run/secrets/cyclone_secret_key` | Cookie signing key — 64 hex chars |
|
||||
| `/etc/cyclone/secrets/admin_pw` | `/run/secrets/cyclone_admin_password` | One-time bootstrap admin password |
|
||||
|
||||
`scripts/cyclone-init.sh` generates all three with `openssl rand -hex 32` (db.key + secret.key) and a memorable-but-random admin password, sets permissions to `chmod 600 root:root`, and prints the admin password once.
|
||||
|
||||
### 8.3 Backup strategy (≤24h loss is acceptable)
|
||||
|
||||
- The existing `BackupService` (SP17) is already implemented and tested: takes online snapshot via SQLite `.backup()` API, encrypts with AES-256-GCM, writes `.bin` + `.meta.json` in `cyclone_backups` volume
|
||||
- `CYCLONE_BACKUP_AUTOSTART=1` in compose starts the in-container backup scheduler on container start
|
||||
- `CYCLONE_BACKUP_INTERVAL_HOURS=24` (default)
|
||||
- 14-day retention (default; configurable via `CYCLONE_BACKUP_RETENTION_DAYS`)
|
||||
- Restore via existing `/api/admin/backup/{id}/restore/{initiate,confirm}` (admin-only after auth is wired)
|
||||
- Existing audit-log call `actor="backup-scheduler"` becomes `actor="backup-scheduler"` (unchanged — system actor, not a user)
|
||||
|
||||
**Off-box backup copy** (operator's responsibility, documented in runbook):
|
||||
|
||||
The operator sets up a host cron / systemd timer that rsyncs `/var/lib/docker/volumes/cyclone_backups/_data/` to an external drive or NAS nightly. The `.bin` files are already encrypted so the destination doesn't need its own encryption. Documented in `RUNBOOK.md` as a required post-deploy step.
|
||||
|
||||
### 8.4 Data loss scenarios
|
||||
|
||||
| Scenario | Outcome |
|
||||
|---|---|
|
||||
| Container crashes | Docker restarts; SQLite WAL recovers; no loss |
|
||||
| Disk corruption | Restore from latest local backup (≤24h old) |
|
||||
| Disk total loss | Restore from off-box backup copy (depends on operator's cron cadence) |
|
||||
| DB key compromise | Rotate via `POST /api/admin/db/rotate-key`; old backups re-encrypted on next cycle |
|
||||
| Operator forgets admin password | Reset via `cyclone admin reset-password --username admin --new-password X` |
|
||||
|
||||
### 8.5 Encryption-at-rest coverage
|
||||
|
||||
| Asset | Encrypted at rest (v1) | Notes |
|
||||
|---|---|---|
|
||||
| Live SQLite DB | ✓ SQLCipher | AES-256, key in Docker secret |
|
||||
| Backup `.bin` files | ✓ AES-256-GCM | Key from backup passphrase |
|
||||
| Prodfiles (uploaded 837s) | — plain on disk | v2: per-file encryption or move to DB |
|
||||
| sftp_staging | — plain on disk | v2: per-file encryption |
|
||||
| Log files | — plain on disk | rotated, contained |
|
||||
|
||||
v1 assumes the host is physically secure (single-operator server, locked room) and that LUKS is operator-level.
|
||||
|
||||
## 9. Logging, monitoring, updates, ops
|
||||
|
||||
### 9.1 Logging
|
||||
|
||||
- JSON to stdout (already configured via `logging_config.py`) — Docker collects via `docker logs`
|
||||
- Bind-mount `/var/log/cyclone/` from the host so logrotate can manage retention (configured in `scripts/post-deploy.sh`)
|
||||
- Log levels: INFO in prod, DEBUG opt-in via `CYCLONE_LOG_LEVEL=DEBUG`
|
||||
- Every login event (success + failure) logged with username, ip, user-agent, role
|
||||
- Every state-changing API call carries `actor=<user_id>` (existing audit-log machinery; we wire the auth user into `request.state.actor`)
|
||||
- New audit events: `auth.login`, `auth.logout`, `auth.login_failed`, `auth.password_changed`, `auth.user_created`, `auth.user_updated`, `auth.user_disabled`
|
||||
|
||||
### 9.2 Healthcheck
|
||||
|
||||
- Container-level: `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries (configured in Dockerfile)
|
||||
- Compose `restart: unless-stopped` on healthcheck failure (max 3 restarts then backoff)
|
||||
- `GET /api/healthz` returns `{db_ok: bool, scheduler_running: bool, last_backup_at: timestamp}` (existing endpoint, unchanged)
|
||||
|
||||
### 9.3 Monitoring (v1 minimal)
|
||||
|
||||
- No Prometheus/Grafana in v1
|
||||
- Host-level cron: `curl -fs http://localhost:8080/api/healthz >/dev/null || echo "Cyclone down at $(date)" | mail -s "cyclone DOWN" you@example.com`
|
||||
- Set up by `scripts/post-deploy.sh` during initial bootstrap
|
||||
|
||||
### 9.4 Updates
|
||||
|
||||
- Images tagged semver from `package.json` / `pyproject.toml`: `cyclone-backend:0.1.0`, `cyclone-frontend:0.1.0`
|
||||
- Two tag aliases per image:
|
||||
- `latest` — set automatically by `docker compose build` (most recent build)
|
||||
- `stable` — manually promoted by the operator once a release has run in prod for ≥1 week without issues, via `docker tag cyclone-backend:0.1.0 cyclone-backend:stable && docker tag cyclone-frontend:0.1.0 cyclone-frontend:stable`. Compose defaults to `:stable` so a new build doesn't get auto-rolled-out.
|
||||
- Update procedure (to pick up a new `:stable`):
|
||||
```bash
|
||||
cd /opt/cyclone
|
||||
docker compose pull # pulls newer :stable tags
|
||||
docker compose up -d # recreates containers, preserves volumes + secrets
|
||||
```
|
||||
- DB migrations run automatically on backend start; migrations are forward-only with documented downgrade procedures (existing pattern)
|
||||
- Rollback: `TAG=0.0.9 docker compose up -d` (one env var override; compose reads `${TAG:-stable}` for the image tag)
|
||||
- Previous image stays in Docker's local cache for one cycle so rollback is offline
|
||||
|
||||
### 9.5 Initial bootstrap
|
||||
|
||||
```bash
|
||||
git clone <repo> /opt/cyclone && cd /opt/cyclone
|
||||
docker compose build # or: docker compose pull
|
||||
bash scripts/cyclone-init.sh # generates /etc/cyclone/secrets/*, prints admin password
|
||||
docker compose up -d # starts both containers
|
||||
# wait ~10s for migrations
|
||||
docker compose exec backend cyclone admin create-user \
|
||||
--username admin --role admin --password-file /run/secrets/cyclone_admin_password
|
||||
# open browser to http://<lan-ip>:8080/login
|
||||
# log in as admin, change password, create operators
|
||||
bash scripts/post-deploy.sh # logrotate + healthcheck cron
|
||||
```
|
||||
|
||||
### 9.6 Daily ops (operator runbook)
|
||||
|
||||
- **Daily:** check `GET /api/healthz` (or trust the healthcheck email)
|
||||
- **Weekly:** review `/admin/audit-log` page for unusual activity
|
||||
- **Quarterly:** rotate DB key via `POST /api/admin/db/rotate-key`
|
||||
- **As needed:** create operators via `/admin/users`; restore from backup via the restore UI
|
||||
- **Annual:** rotate cookie signing key (re-login all users)
|
||||
|
||||
Full procedures live in `RUNBOOK.md` shipped in the repo.
|
||||
|
||||
## 10. docker-compose.yml
|
||||
|
||||
```yaml
|
||||
name: cyclone
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: cyclone-backend:${TAG:-stable}
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CYCLONE_DB_URL: "sqlite:////var/lib/cyclone/db/cyclone.db"
|
||||
CYCLONE_BACKUP_AUTOSTART: "1"
|
||||
CYCLONE_BACKUP_INTERVAL_HOURS: "24"
|
||||
CYCLONE_BACKUP_RETENTION_DAYS: "14"
|
||||
CYCLONE_LOG_LEVEL: "INFO"
|
||||
CYCLONE_LOG_FILE: "/var/log/cyclone/cyclone.log"
|
||||
CYCLONE_LOG_JSON: "1"
|
||||
CYCLONE_SESSION_ABSOLUTE_DAYS: "30"
|
||||
CYCLONE_SESSION_SLIDING_HOURS: "8"
|
||||
CYCLONE_COOKIE_SECURE: "1"
|
||||
secrets:
|
||||
- cyclone_db_key
|
||||
- cyclone_secret_key
|
||||
volumes:
|
||||
- cyclone_db:/var/lib/cyclone/db
|
||||
- cyclone_backups:/var/lib/cyclone/backups
|
||||
- cyclone_prodfiles:/var/lib/cyclone/prodfiles
|
||||
- cyclone_sftp_staging:/var/lib/cyclone/sftp_staging
|
||||
- cyclone_logs:/var/log/cyclone
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fs", "http://localhost:8000/api/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks: [cyclone_network]
|
||||
|
||||
frontend:
|
||||
image: cyclone-frontend:${TAG:-stable}
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks: [cyclone_network]
|
||||
|
||||
secrets:
|
||||
cyclone_db_key:
|
||||
file: /etc/cyclone/secrets/db.key
|
||||
cyclone_secret_key:
|
||||
file: /etc/cyclone/secrets/secret.key
|
||||
cyclone_admin_password:
|
||||
file: /etc/cyclone/secrets/admin_pw
|
||||
|
||||
volumes:
|
||||
cyclone_db:
|
||||
cyclone_backups:
|
||||
cyclone_prodfiles:
|
||||
cyclone_sftp_staging:
|
||||
cyclone_logs:
|
||||
|
||||
networks:
|
||||
cyclone_network:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
The `8080:8080` port is the only one published to the host. The operator is expected to bind to the LAN IP at the firewall level (UFW rules or similar).
|
||||
|
||||
## 11. Error handling
|
||||
|
||||
- **Login failures:** increment `failed_count`; lock at 5 fails / 15min; log `auth.login_failed`; 423 on locked accounts.
|
||||
- **Session expiry:** 401; frontend redirects to `/login` with `state.from` preserved.
|
||||
- **Missing role:** 403 with explicit message; UI shows a "Forbidden" page instead of trying to render.
|
||||
- **Backend crash:** Docker healthcheck restarts the container; SQLite WAL recovers in-flight writes.
|
||||
- **DB key missing:** container starts but `db.is_encryption_enabled()` returns False; `BackupService` refuses to run (existing behavior); compose logs a warning.
|
||||
- **Migration failure on startup:** container exits with non-zero; compose `restart: unless-stopped` backs off after 3 attempts; operator checks logs via `docker compose logs backend`.
|
||||
- **Backup failure:** logged at ERROR; audit event `backup.failed`; existing retry behavior in `BackupService`.
|
||||
|
||||
## 12. Testing
|
||||
|
||||
### 12.1 New unit tests
|
||||
|
||||
- `tests/test_auth.py` (~12 tests)
|
||||
- `test_create_user_with_argon2_hash`
|
||||
- `test_login_with_correct_password_returns_session`
|
||||
- `test_login_with_wrong_password_increments_failed_count`
|
||||
- `test_login_locks_account_after_5_failures`
|
||||
- `test_login_lock_clears_after_15min`
|
||||
- `test_session_cookie_is_httponly_secure_samesite_lax`
|
||||
- `test_session_expires_after_sliding_window`
|
||||
- `test_session_absolute_lifetime_enforced`
|
||||
- `test_logout_deletes_session_and_clears_cookie`
|
||||
- `test_change_password_invalidates_other_sessions`
|
||||
- `test_require_role_rejects_below_minimum`
|
||||
- `test_require_role_returns_user_object_to_dependency`
|
||||
- `tests/test_users.py` (~6 tests)
|
||||
- `test_admin_can_create_user`
|
||||
- `test_operator_cannot_create_user`
|
||||
- `test_admin_can_change_user_role`
|
||||
- `test_admin_can_disable_user`
|
||||
- `test_disabled_user_cannot_login`
|
||||
- `test_audit_event_for_user_lifecycle`
|
||||
|
||||
Total new backend tests: **~18**.
|
||||
|
||||
### 12.2 Docker smoke tests
|
||||
|
||||
- `tests/test_docker.py` (~4 tests)
|
||||
- `test_compose_config_validates`
|
||||
- `test_dockerfile_backend_builds`
|
||||
- `test_dockerfile_frontend_builds`
|
||||
- `test_compose_up_brings_up_healthy_stack` (uses `testcontainers-python` or skips if not available; gated on a `DOCKER_TESTS=1` env var)
|
||||
|
||||
### 12.3 Smoke script
|
||||
|
||||
`scripts/smoke.sh`:
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 1. Bring up stack
|
||||
docker compose up -d --build
|
||||
|
||||
# 2. Wait for healthcheck
|
||||
for i in {1..30}; do
|
||||
if curl -fs http://localhost:8080/api/healthz > /dev/null; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 3. Login
|
||||
COOKIE_JAR=$(mktemp)
|
||||
curl -fs -c "$COOKIE_JAR" -X POST http://localhost:8080/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"admin\",\"password\":\"$(cat /etc/cyclone/secrets/admin_pw)\"}"
|
||||
|
||||
# 4. Parse a sample 837
|
||||
curl -fs -b "$COOKIE_JAR" -X POST http://localhost:8080/api/parse-837 \
|
||||
-F "file=@docs/goodclaim.x12"
|
||||
|
||||
# 5. List batches
|
||||
curl -fs -b "$COOKIE_JAR" http://localhost:8080/api/batches
|
||||
|
||||
# 6. Export
|
||||
curl -fs -b "$COOKIE_JAR" -X POST http://localhost:8080/api/batches/<id>/export-837 \
|
||||
-H 'Content-Type: application/json' -d '{"claim_ids":["..."]}' \
|
||||
-o /tmp/export.zip
|
||||
|
||||
unzip -l /tmp/export.zip | grep -E '\.x12$'
|
||||
|
||||
echo "smoke: OK"
|
||||
```
|
||||
|
||||
### 12.4 Existing tests
|
||||
|
||||
- All existing backend tests must continue to pass
|
||||
- All existing frontend tests must continue to pass
|
||||
- Pre-existing prodfile smoke failures (rate-limit / orphan-set refs on 999/TA1) remain pre-existing and out of scope
|
||||
|
||||
## 13. Migration / rollout
|
||||
|
||||
### 13.1 First-time deploy
|
||||
|
||||
For a brand-new install on a fresh Ubuntu 22.04 server, follow §9.5 above. No data to migrate — this is the first deployment.
|
||||
|
||||
### 13.2 Upgrading an existing dev install
|
||||
|
||||
If an operator has been running `python -m cyclone serve` locally with the in-memory store or a plaintext SQLite file, the upgrade path is:
|
||||
|
||||
1. **Stop the existing server.**
|
||||
2. **Export any production data:** SQLite is at `~/.local/share/cyclone/cyclone.db`. The operator takes a final plaintext backup *before* the SQLCipher transition; we don't try to encrypt-in-place (would need a different migration).
|
||||
3. **Run `scripts/cyclone-init.sh`** to generate secrets.
|
||||
4. **Run `docker compose up -d`** — the backend starts with an empty SQLCipher DB. Migrations run.
|
||||
5. **Bootstrap admin user** via the CLI in the container.
|
||||
6. **Re-import data** from the plaintext export (manual step; documented in RUNBOOK).
|
||||
|
||||
The in-memory store from the parent spec is *lost* on upgrade (by design — local-only). The plaintext SQLite DB *can* be carried over manually if needed.
|
||||
|
||||
### 13.3 Updating the running stack
|
||||
|
||||
```bash
|
||||
cd /opt/cyclone
|
||||
docker compose pull # or: docker compose build
|
||||
docker compose up -d # recreates containers
|
||||
docker compose logs -f backend | head -200 # verify migrations + healthcheck
|
||||
```
|
||||
|
||||
DB migrations run automatically on backend start; they're forward-only. To roll back the code (not the schema): `TAG=0.0.9 docker compose up -d`.
|
||||
|
||||
## 14. Out of scope (explicit)
|
||||
|
||||
- Real SFTP wire (paramiko)
|
||||
- Public TLS / domain / Caddy reverse proxy in compose
|
||||
- Multi-host HA / DB replication / load balancing
|
||||
- Prometheus / Grafana / alerting beyond the simple email cron
|
||||
- Off-box automated backup shipping (operator's cron)
|
||||
- 2FA / SSO / OAuth
|
||||
- Self-service password reset
|
||||
- Per-file encryption of prodfiles / sftp_staging
|
||||
- LUKS full-disk encryption
|
||||
- Watchtower / automatic updates
|
||||
- Linting/pre-commit/dev tooling polish
|
||||
- Component / E2E browser tests (Playwright)
|
||||
|
||||
## 15. Acceptance checklist
|
||||
|
||||
### 15.1 Backend
|
||||
|
||||
- [ ] `pytest tests/test_auth.py tests/test_users.py tests/test_docker.py -v` passes
|
||||
- [ ] All previously-passing tests still pass; new total ≥ previous + 18
|
||||
- [ ] `docker compose config` validates with no errors
|
||||
- [ ] `docker compose build` succeeds for both services
|
||||
- [ ] `docker compose up -d` brings both containers to `healthy` within 60s
|
||||
- [ ] `curl http://localhost:8080/api/healthz` returns `{db_ok: true, scheduler_running: true}`
|
||||
|
||||
### 15.2 Auth + RBAC
|
||||
|
||||
- [ ] `POST /api/auth/login` with correct creds sets `cyclone_session` cookie (HttpOnly, Secure, SameSite=Lax) and returns `{user: {...}}`
|
||||
- [ ] 5 wrong logins within 15min locks the account; 423 returned
|
||||
- [ ] `GET /api/batches` without session cookie returns 401
|
||||
- [ ] `GET /api/admin/users` as operator returns 403; as admin returns list
|
||||
- [ ] Logout clears the cookie and invalidates the session in the DB
|
||||
- [ ] Password change invalidates other sessions for that user
|
||||
|
||||
### 15.3 Encryption + backups
|
||||
|
||||
- [ ] `cyclone.db_crypto.is_encryption_enabled()` returns True in container
|
||||
- [ ] Inspecting the DB file on disk shows binary ciphertext, not plaintext
|
||||
- [ ] `POST /api/admin/backup/create` creates an encrypted `.bin` in `/var/lib/cyclone/backups/`
|
||||
- [ ] Backup scheduler runs every 24h (`docker compose logs backend | grep backup-scheduler`)
|
||||
- [ ] Restore flow works end-to-end: create backup → wipe DB → restore → verify data present
|
||||
|
||||
### 15.4 Frontend
|
||||
|
||||
- [ ] Unauthenticated browser request to `/` redirects to `/login`
|
||||
- [ ] Login as admin → land on `/`; NavBar shows username + role
|
||||
- [ ] Operator does not see "Admin" link in NavBar
|
||||
- [ ] `/admin/users` as operator shows "Forbidden" page
|
||||
- [ ] Logout button clears session and returns to `/login`
|
||||
- [ ] `npm run build` produces a `dist/` that serves correctly from nginx
|
||||
|
||||
### 15.5 Smoke
|
||||
|
||||
- [ ] `scripts/smoke.sh` passes end-to-end against a fresh stack
|
||||
- [ ] `RUNBOOK.md` exists and contains the §9.6 procedures
|
||||
- [ ] `scripts/post-deploy.sh` sets up logrotate + healthcheck cron without errors
|
||||
Generated
+62
@@ -12,6 +12,7 @@
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"ansi-styles": "^6.2.3",
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"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-select": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"ansi-styles": "^6.2.3",
|
||||
"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";
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Plus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { DrillableCell } from "@/components/drill/DrillableCell";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
@@ -263,13 +265,28 @@ function RowIndicator({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 (
|
||||
<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`}
|
||||
>
|
||||
{id}
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-[12px] tracking-tight"
|
||||
data-testid="diff-claim-id"
|
||||
>
|
||||
{id}
|
||||
</span>
|
||||
</DrillableCell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Shared visual primitives used by ClaimCard837 and ClaimCard835.
|
||||
//
|
||||
// Both cards render validation status (Passed / Warnings / Failed) and
|
||||
// a grid of stat pills (Member, Place of service, …). The exact same
|
||||
// mark-up was being copy-pasted inside Upload.tsx for both cards; this
|
||||
// file is the shared home.
|
||||
|
||||
import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Tri-state validation indicator.
|
||||
*
|
||||
* - `passed` → green "Passed" with check icon
|
||||
* - `hasWarnings` → amber "Warnings" with triangle
|
||||
* - else → red "Failed" with X
|
||||
*/
|
||||
export function ValidationDot({
|
||||
passed,
|
||||
hasWarnings,
|
||||
}: {
|
||||
passed: boolean;
|
||||
hasWarnings?: boolean;
|
||||
}) {
|
||||
if (passed) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--success))] font-medium"
|
||||
title="Validation passed"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
Passed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (hasWarnings) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--warning))] font-medium"
|
||||
title="Validation passed with warnings"
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
Warnings
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-destructive font-medium"
|
||||
title="Validation failed"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
Failed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Small label + value cell for the expanded-card detail grid.
|
||||
*/
|
||||
export function StatPill({
|
||||
label,
|
||||
value,
|
||||
mono = true,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-[13px]",
|
||||
mono && "display mono"
|
||||
)}
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment happy-dom
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
|
||||
import React, { act } from "react";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { useAppStore } from "@/store";
|
||||
import { ClaimCard837 } from "./ClaimCard837";
|
||||
import type { ClaimOutput } from "@/types";
|
||||
|
||||
// Tiny fixture — just enough to exercise the card's UI surface.
|
||||
const CLAIM: ClaimOutput = {
|
||||
claim_id: "CLM-TEST",
|
||||
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: [] },
|
||||
};
|
||||
|
||||
function renderCard(
|
||||
props: Partial<React.ComponentProps<typeof ClaimCard837>> = {},
|
||||
): {
|
||||
container: HTMLDivElement;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root: Root = createRoot(container);
|
||||
const onToggleSelect = props.onToggleSelect ?? (() => {});
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
MemoryRouter,
|
||||
null,
|
||||
React.createElement(ClaimCard837, {
|
||||
claim: CLAIM,
|
||||
selected: false,
|
||||
onToggleSelect,
|
||||
...props,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
return {
|
||||
container,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ClaimCard837 — checkbox + select interaction", () => {
|
||||
beforeEach(() => {
|
||||
useAppStore.setState({ parsedBatches: [] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a checkbox with checked={selected}", () => {
|
||||
const { container, unmount } = renderCard({ selected: true });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
);
|
||||
expect(cb).not.toBeNull();
|
||||
expect(cb!.checked).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders an unchecked checkbox when selected={false}", () => {
|
||||
const { container, unmount } = renderCard({ selected: false });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
);
|
||||
expect(cb!.checked).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clicking the checkbox fires onToggleSelect with the claim_id", () => {
|
||||
const onToggleSelect = vi.fn();
|
||||
const { container, unmount } = renderCard({ onToggleSelect });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
)!;
|
||||
act(() => {
|
||||
cb.click();
|
||||
});
|
||||
expect(onToggleSelect).toHaveBeenCalledWith("CLM-TEST");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clicking the checkbox does NOT toggle the card's expand state", () => {
|
||||
// Regression guard: the card body is a <button> that toggles expand
|
||||
// on click. The new checkbox must not bubble its click into that
|
||||
// button (which would also fire onToggleSelect, double-counting).
|
||||
// We assert this by checking the expanded content (the service lines
|
||||
// table) is NOT in the DOM after a checkbox click.
|
||||
const { container, unmount } = renderCard();
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
)!;
|
||||
act(() => {
|
||||
cb.click();
|
||||
});
|
||||
// The service lines table only renders when the card is expanded.
|
||||
const table = container.querySelector("table");
|
||||
expect(table).toBeNull();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clicking the card body still toggles expand (existing behavior preserved)", () => {
|
||||
const { container, unmount } = renderCard();
|
||||
// The card body is a <button aria-expanded="false">. Clicking it
|
||||
// should reveal the expanded details (service lines table).
|
||||
const expandButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-expanded]',
|
||||
);
|
||||
expect(expandButton).not.toBeNull();
|
||||
act(() => {
|
||||
expandButton!.click();
|
||||
});
|
||||
// After expand, the service lines table is in the DOM.
|
||||
const table = container.querySelector("table");
|
||||
expect(table).not.toBeNull();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("checkbox has an accessible label that names the claim", () => {
|
||||
const { container, unmount } = renderCard();
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
);
|
||||
// aria-label or wrapping <label> — at minimum the input must be
|
||||
// findable by an accessible name. The simplest assertion is that an
|
||||
// aria-label exists on the input itself.
|
||||
expect(
|
||||
cb!.getAttribute("aria-label") ||
|
||||
cb!.closest("label")?.textContent,
|
||||
).toContain("CLM-TEST");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
// ClaimCard837 — the warm-paper card that represents one 837P claim in
|
||||
// the Upload page's streaming section. Originally inlined in
|
||||
// `src/pages/Upload.tsx`; extracted into its own file in SP9 (June 2026)
|
||||
// so the per-claim select-for-export interaction can live here without
|
||||
// bloating the page.
|
||||
//
|
||||
// Two click targets, by design (see approach A in the batch-export
|
||||
// design spec): a leading checkbox column for selection, and the rest
|
||||
// of the card (a <button>) for expand/collapse. Putting the checkbox
|
||||
// inside the button would be an a11y anti-pattern (nested interactive
|
||||
// elements).
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useAppStore } from "@/store";
|
||||
import { fmt, toNum } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ClaimOutput, ServiceLine } from "@/types";
|
||||
import { StatPill, ValidationDot } from "./ClaimCard/shared";
|
||||
|
||||
export interface ClaimCard837Props {
|
||||
claim: ClaimOutput;
|
||||
/**
|
||||
* Whether this card is currently selected for the batch export.
|
||||
* Controlled by the parent (Upload.tsx) via the per-claim checkbox.
|
||||
* The checkbox column only renders when the parent passes
|
||||
* `onToggleSelect` — keeps the component backward-compatible with
|
||||
* callers that don't need the export flow (e.g. the persisted
|
||||
* batch's "Recent batches" view, when we add it).
|
||||
*/
|
||||
selected?: boolean;
|
||||
/** Called with the claim's id when the user clicks the checkbox. */
|
||||
onToggleSelect?: (claimId: string) => void;
|
||||
}
|
||||
|
||||
export function ClaimCard837({ claim, selected = false, onToggleSelect }: ClaimCard837Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const passed = claim.validation.passed;
|
||||
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();
|
||||
|
||||
const showCheckbox = typeof onToggleSelect === "function";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg overflow-hidden border flex"
|
||||
style={{
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
boxShadow: "inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
|
||||
}}
|
||||
>
|
||||
{/* Selection column — only when the parent wires up selection. */}
|
||||
{showCheckbox ? (
|
||||
<label
|
||||
className="flex items-center pl-3 pr-2 cursor-pointer shrink-0"
|
||||
// Belt-and-suspenders: the label is a sibling of the button
|
||||
// below, so click events don't bubble into it. stopPropagation
|
||||
// here guards against future refactors that might nest the
|
||||
// label inside the button.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid={`claim-card-select-${claim.claim_id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => onToggleSelect?.(claim.claim_id)}
|
||||
aria-label={`Select claim ${claim.claim_id} for export`}
|
||||
className="h-3.5 w-3.5 rounded border-border accent-accent cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 text-left px-4 py-3 hover:bg-[hsl(36_22%_92%)] transition-colors",
|
||||
!showCheckbox && "rounded-l-lg",
|
||||
)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform shrink-0",
|
||||
open && "rotate-90"
|
||||
)}
|
||||
strokeWidth={1.75}
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<span
|
||||
className="display mono text-[12.5px]"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{claim.claim_id}
|
||||
</span>
|
||||
<span
|
||||
className="text-[12px] truncate"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{claim.subscriber.first_name} {claim.subscriber.last_name}
|
||||
</span>
|
||||
<span
|
||||
className="text-[12px]"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
· {claim.payer.name}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mono text-[10.5px] flex items-center gap-2 mt-1"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
<span>NPI {claim.billing_provider.npi}</span>
|
||||
<span className="opacity-40">·</span>
|
||||
<span>
|
||||
{claim.service_lines.length} line
|
||||
{claim.service_lines.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span className="opacity-40">·</span>
|
||||
<span>{claim.diagnoses.length} dx</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div
|
||||
className="display mono text-[14px]"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{fmt.usdDecimal(claim.claim.total_charge)}
|
||||
</div>
|
||||
<div className="mt-0.5">
|
||||
<ValidationDot passed={passed} hasWarnings={hasWarnings} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className="basis-full border-t px-4 py-3 grid gap-4 animate-fade-in"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.08)",
|
||||
backgroundColor: "hsl(36 22% 92%)",
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatPill label="Member" value={claim.subscriber.member_id} />
|
||||
<StatPill
|
||||
label="Place of service"
|
||||
value={claim.claim.place_of_service ?? "—"}
|
||||
/>
|
||||
<StatPill label="Frequency" value={claim.claim.frequency_code ?? "—"} />
|
||||
<StatPill
|
||||
label="Prior auth"
|
||||
value={claim.claim.prior_auth ?? "—"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{claim.diagnoses.length > 0 ? (
|
||||
<div>
|
||||
<div
|
||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
Diagnoses
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{claim.diagnoses.map((d, i) => (
|
||||
<Badge key={`${d.code}-${i}`} variant="muted">
|
||||
{d.qualifier ? `${d.qualifier}·` : ""}
|
||||
{d.code}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{claim.service_lines.length > 0 ? (
|
||||
<div>
|
||||
<div
|
||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
Service lines
|
||||
</div>
|
||||
<div
|
||||
className="rounded-md border overflow-x-auto"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
backgroundColor: "hsl(36 22% 98%)",
|
||||
}}
|
||||
>
|
||||
<table className="w-full text-[12px]">
|
||||
<thead style={{ backgroundColor: "hsl(36 22% 90%)" }}>
|
||||
<tr className="text-left" style={{ color: "hsl(var(--surface-ink-2))" }}>
|
||||
<th className="px-2.5 py-1.5 font-medium">#</th>
|
||||
<th className="px-2.5 py-1.5 font-medium">Code</th>
|
||||
<th className="px-2.5 py-1.5 font-medium">Mods</th>
|
||||
<th className="px-2.5 py-1.5 font-medium">Date</th>
|
||||
<th className="px-2.5 py-1.5 font-medium">Units</th>
|
||||
<th className="px-2.5 py-1.5 font-medium text-right">Charge</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{claim.service_lines.map((line) => (
|
||||
<ServiceLine837Row key={line.line_number} line={line} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(claim.validation.errors.length > 0 ||
|
||||
claim.validation.warnings.length > 0) ? (
|
||||
<div>
|
||||
<div
|
||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
Validation
|
||||
</div>
|
||||
<ul className="space-y-1 text-[12px]">
|
||||
{claim.validation.errors.map((issue, i) => (
|
||||
<li
|
||||
key={`e-${i}`}
|
||||
className="flex items-start gap-2 text-destructive"
|
||||
>
|
||||
<XCircle
|
||||
className="h-3.5 w-3.5 mt-0.5 shrink-0"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
<span>
|
||||
<span className="mono">{issue.rule}</span> — {issue.message}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{claim.validation.warnings.map((issue, i) => (
|
||||
<li
|
||||
key={`w-${i}`}
|
||||
className="flex items-start gap-2 text-[hsl(var(--warning))]"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="h-3.5 w-3.5 mt-0.5 shrink-0"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
<span>
|
||||
<span className="mono">{issue.rule}</span> — {issue.message}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : 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>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceLine837Row({ line }: { line: ServiceLine }) {
|
||||
return (
|
||||
<tr
|
||||
className="border-t"
|
||||
style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}
|
||||
>
|
||||
<td
|
||||
className="px-2.5 py-1.5 mono"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{line.line_number}
|
||||
</td>
|
||||
<td
|
||||
className="px-2.5 py-1.5 mono"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{line.procedure.qualifier}·{line.procedure.code}
|
||||
</td>
|
||||
<td
|
||||
className="px-2.5 py-1.5 mono"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{line.procedure.modifiers.length > 0
|
||||
? line.procedure.modifiers.join(", ")
|
||||
: "—"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2.5 py-1.5 mono"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{line.service_date ?? "—"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2.5 py-1.5 mono"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{line.units ? `${toNum(line.units)} ${line.unit_type ?? ""}`.trim() : "—"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2.5 py-1.5 mono text-right display"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{fmt.usdDecimal(line.charge)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ClaimDrawer } from "./ClaimDrawer";
|
||||
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import type { ClaimDetail } from "@/types";
|
||||
|
||||
@@ -162,14 +163,22 @@ function renderDrawer(
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: qc },
|
||||
React.createElement(ClaimDrawer, {
|
||||
claimId: props.claimId,
|
||||
claims,
|
||||
onClose,
|
||||
onNavigate,
|
||||
onToggleHelp,
|
||||
})
|
||||
)
|
||||
// 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, {
|
||||
claimId: props.claimId,
|
||||
claims,
|
||||
onClose,
|
||||
onNavigate,
|
||||
onToggleHelp,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -141,12 +141,14 @@ export function ClaimDrawer({
|
||||
// Right-anchored side panel. We override the Dialog primitive's
|
||||
// default centered positioning (`left-1/2 top-1/2 -translate-x-1/2
|
||||
// -translate-y-1/2`) by re-asserting `right-0 top-0 translate-x-0
|
||||
// translate-y-0`. `h-full` + `rounded-none` + the left border
|
||||
// translate-y-0`. `h-full` + `rounded-none` + the left hairline
|
||||
// give it the visual identity of a drawer rather than a modal.
|
||||
// `bg-card` matches the rest of the dark instrument chrome; the
|
||||
// outer shadow is a soft directional falloff to the left.
|
||||
// `aria-describedby={undefined}` suppresses the Radix warning
|
||||
// about a missing description — the drawer content is its own
|
||||
// description and there's nothing useful to point at.
|
||||
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)]/60 bg-[color:var(--m-surface)] p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
|
||||
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border/60 bg-card p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
|
||||
data-testid="claim-drawer"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
@@ -167,7 +169,7 @@ export function ClaimDrawer({
|
||||
>
|
||||
<ClaimDrawerHeader claim={data} onClose={onClose} />
|
||||
<div
|
||||
className="flex gap-0 px-6 border-b border-[color:var(--surface-line)]/40"
|
||||
className="flex gap-0 px-6 border-b border-border/40"
|
||||
data-testid="claim-drawer-tabs"
|
||||
>
|
||||
<button
|
||||
@@ -176,15 +178,15 @@ export function ClaimDrawer({
|
||||
className={cn(
|
||||
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
|
||||
activeTab === "details"
|
||||
? "text-[color:var(--surface-ink)]"
|
||||
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
data-testid="tab-button-details"
|
||||
data-active={activeTab === "details" ? "true" : "false"}
|
||||
>
|
||||
Details
|
||||
{activeTab === "details" ? (
|
||||
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
|
||||
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
@@ -193,29 +195,29 @@ export function ClaimDrawer({
|
||||
className={cn(
|
||||
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
|
||||
activeTab === "line-reconciliation"
|
||||
? "text-[color:var(--surface-ink)]"
|
||||
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
data-testid="tab-button-line-reconciliation"
|
||||
data-active={activeTab === "line-reconciliation" ? "true" : "false"}
|
||||
>
|
||||
Line Reconciliation
|
||||
{activeTab === "line-reconciliation" ? (
|
||||
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
|
||||
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === "line-reconciliation" ? (
|
||||
lr.loading ? (
|
||||
<p
|
||||
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
|
||||
className="px-6 py-4 text-sm text-muted-foreground"
|
||||
data-testid="line-reconciliation-loading"
|
||||
>
|
||||
Loading line reconciliation…
|
||||
</p>
|
||||
) : lr.error ? (
|
||||
<p
|
||||
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
|
||||
className="px-6 py-4 text-sm text-muted-foreground"
|
||||
data-testid="line-reconciliation-error"
|
||||
>
|
||||
Failed to load line reconciliation.
|
||||
@@ -224,7 +226,7 @@ export function ClaimDrawer({
|
||||
<LineReconciliationTab data={lr.data} />
|
||||
) : null
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-[color:var(--surface-line)]/12">
|
||||
<div className="flex flex-col divide-y divide-border/40">
|
||||
<ValidationPanel validation={data.validation} />
|
||||
<ServiceLinesTable
|
||||
serviceLines={data.serviceLines}
|
||||
|
||||
@@ -32,21 +32,21 @@ export function ClaimDrawerError({ kind, onRetry, onClose }: ClaimDrawerErrorPro
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-start gap-4 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
|
||||
className="flex flex-col items-start gap-4 p-6 bg-card text-foreground"
|
||||
role="alert"
|
||||
data-testid={`claim-drawer-error-${kind}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className="h-5 w-5 text-[color:var(--m-error)]"
|
||||
className="h-5 w-5 text-destructive"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="eyebrow text-[color:var(--m-error)]">
|
||||
<span className="eyebrow text-destructive">
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[color:var(--m-ink-secondary)] max-w-sm">{message}</p>
|
||||
<p className="text-sm text-muted-foreground 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">
|
||||
|
||||
@@ -131,14 +131,17 @@ describe("ClaimDrawerHeader", () => {
|
||||
it("test_renders_claim_id_label_and_value", () => {
|
||||
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();
|
||||
expect(text).toContain("claim");
|
||||
|
||||
// The claim id sits in a node tagged with data-testid="header-id".
|
||||
const idEl = container.querySelector('[data-testid="header-id"]');
|
||||
expect(idEl).not.toBeNull();
|
||||
expect(idEl?.textContent).toBe("CLM-42");
|
||||
// SP21 Phase 5 Task 5.10: the claim id is now the title passed
|
||||
// to DrillDrawerHeader, which renders it inside an <h2>. Find
|
||||
// the h2 and assert its text matches the claim id.
|
||||
const titleEl = container.querySelector("h2");
|
||||
expect(titleEl).not.toBeNull();
|
||||
expect(titleEl?.textContent).toBe("CLM-42");
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -212,8 +215,11 @@ describe("ClaimDrawerHeader", () => {
|
||||
const onClose = vi.fn();
|
||||
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(
|
||||
'[data-testid="header-close"]'
|
||||
'button[aria-label="Close drawer"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(closeBtn).not.toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
@@ -226,18 +232,25 @@ describe("ClaimDrawerHeader", () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_uses_modern_palette_surface", () => {
|
||||
// The drawer header anchors itself on the light surface palette
|
||||
// token (matches ClaimDrawerSkeleton / ClaimDrawerError). The root
|
||||
// <header> element is tagged with data-testid="claim-drawer-header"
|
||||
// so we can sniff its className without coupling to the badge
|
||||
// or close-button wrappers.
|
||||
const { container, unmount } = renderHeader({});
|
||||
it("test_uses_shared_drilldrawerheader_shell", () => {
|
||||
// SP21 Phase 5 Task 5.10: the header is now a thin wrapper
|
||||
// around DrillDrawerHeader — verify the wrapper is present and
|
||||
// that the underlying shell is the shared one (an h2 with the
|
||||
// expected Tailwind treatment). The "Claim" eyebrow + claim id
|
||||
// title prove the shell rendered.
|
||||
const { container, unmount } = renderHeader({ id: "CLM-42" });
|
||||
|
||||
const root = container.querySelector('[data-testid="claim-drawer-header"]');
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { Download, X } from "lucide-react";
|
||||
import { Download } from "lucide-react";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
|
||||
import { api } from "@/lib/api";
|
||||
import { downloadTextFile } from "@/lib/download";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ClaimDetail } from "@/types";
|
||||
|
||||
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.
|
||||
* Top-right: state badge + total billed amount + close button. The
|
||||
* badge color encodes state so the user can read the drawer's status
|
||||
* at a glance without scrolling.
|
||||
* The shell is now the shared `DrillDrawerHeader` (same as
|
||||
* `ProviderDrawer` / `AckDrawer`) — eyebrow + title on the left,
|
||||
* close button on the right. The right-side `action` slot carries
|
||||
* 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({
|
||||
claim,
|
||||
@@ -80,66 +87,48 @@ export function ClaimDrawerHeader({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-start justify-between gap-4 px-6 py-5",
|
||||
"border-b border-[color:var(--m-border-heavy)]/40",
|
||||
"bg-[color:var(--m-surface)]"
|
||||
)}
|
||||
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>
|
||||
// The action slot is rendered by DrillDrawerHeader to the left of
|
||||
// the close button. Group the three action pieces (badge, amount,
|
||||
// download) in a single flex row so they read as a unit.
|
||||
const action = (
|
||||
<div className="flex items-center gap-2" data-testid="claim-header-actions">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={badgeVariantFor(claim.state)}
|
||||
data-testid="header-state"
|
||||
className="uppercase tracking-[0.14em]"
|
||||
>
|
||||
{claim.stateLabel}
|
||||
</Badge>
|
||||
<span
|
||||
data-testid="header-id"
|
||||
className="mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
||||
data-testid="header-amount"
|
||||
className="mono text-sm tabular-nums text-muted-foreground"
|
||||
>
|
||||
{claim.id}
|
||||
{fmt.usdPrecise(claim.billedAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
aria-label={downloading ? "Downloading 837 file" : "Download 837 file"}
|
||||
title="Download 837 file"
|
||||
data-testid="header-download-837"
|
||||
>
|
||||
<Download className="h-4 w-4" strokeWidth={1.75} />
|
||||
</Button>
|
||||
</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
|
||||
variant={badgeVariantFor(claim.state)}
|
||||
data-testid="header-state"
|
||||
className="uppercase tracking-[0.14em]"
|
||||
>
|
||||
{claim.stateLabel}
|
||||
</Badge>
|
||||
<span
|
||||
data-testid="header-amount"
|
||||
className="mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
>
|
||||
{fmt.usdPrecise(claim.billedAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
aria-label={downloading ? "Downloading 837 file" : "Download 837 file"}
|
||||
title="Download 837 file"
|
||||
data-testid="header-download-837"
|
||||
>
|
||||
<Download className="h-4 w-4" strokeWidth={1.75} />
|
||||
</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>
|
||||
return (
|
||||
<header data-testid="claim-drawer-header">
|
||||
<DrillDrawerHeader
|
||||
eyebrow="Claim"
|
||||
title={claim.id}
|
||||
onClose={onClose}
|
||||
action={action}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
export function ClaimDrawerSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-6 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
|
||||
className="flex flex-col gap-6 p-6 bg-card text-foreground"
|
||||
data-testid="claim-drawer-skeleton"
|
||||
aria-busy="true"
|
||||
>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
|
||||
<section className="flex flex-col gap-3 px-6 py-4">
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-muted-foreground"
|
||||
>
|
||||
Diagnoses ({diagnoses.length})
|
||||
</h3>
|
||||
@@ -43,7 +43,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
|
||||
{diagnoses.length === 0 ? (
|
||||
<p
|
||||
data-testid="diagnoses-empty"
|
||||
className="text-sm text-[color:var(--m-ink-tertiary)]"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
No diagnoses
|
||||
</p>
|
||||
@@ -58,17 +58,17 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
|
||||
className="flex items-baseline gap-3 text-sm"
|
||||
>
|
||||
<span
|
||||
className="mono text-[color:var(--m-ink-primary)] font-medium"
|
||||
className="mono text-foreground font-medium"
|
||||
>
|
||||
{d.qualifier ? `${d.qualifier} ${d.code}` : d.code}
|
||||
</span>
|
||||
{desc ? (
|
||||
<>
|
||||
<span
|
||||
className="h-px w-3 bg-[color:var(--m-border-heavy)]/30"
|
||||
className="h-px w-3 bg-border/60"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[color:var(--m-ink-secondary)] italic">
|
||||
<span className="text-muted-foreground italic">
|
||||
{desc}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -31,26 +31,26 @@ export function LineReconciliationTab({
|
||||
>
|
||||
<div className="flex gap-5 font-mono tabular-nums text-sm">
|
||||
<span data-testid="billed-total" className="flex flex-col gap-0.5">
|
||||
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
|
||||
<span className="text-muted-foreground eyebrow">
|
||||
Billed
|
||||
</span>
|
||||
<span className="text-[color:var(--m-ink-primary)] font-semibold">
|
||||
<span className="text-foreground font-semibold">
|
||||
{fmt.usdPrecise(Number(summary.billedTotal))}
|
||||
</span>
|
||||
</span>
|
||||
<span data-testid="paid-total" className="flex flex-col gap-0.5">
|
||||
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
|
||||
<span className="text-muted-foreground eyebrow">
|
||||
Paid
|
||||
</span>
|
||||
<span className="text-[color:var(--m-ink-primary)] font-semibold">
|
||||
<span className="text-foreground font-semibold">
|
||||
{fmt.usdPrecise(Number(summary.paidTotal))}
|
||||
</span>
|
||||
</span>
|
||||
<span data-testid="adjustments-total" className="flex flex-col gap-0.5">
|
||||
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
|
||||
<span className="text-muted-foreground eyebrow">
|
||||
Adjustments
|
||||
</span>
|
||||
<span className="text-[color:var(--m-ink-primary)] font-semibold">
|
||||
<span className="text-foreground font-semibold">
|
||||
{fmt.usdPrecise(Number(summary.adjustmentTotal))}
|
||||
</span>
|
||||
</span>
|
||||
@@ -70,7 +70,7 @@ export function LineReconciliationTab({
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div data-testid="left-column">
|
||||
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
|
||||
<h3 className="eyebrow text-muted-foreground mb-2">
|
||||
Billed (837 SV1)
|
||||
</h3>
|
||||
{leftRows.map((row) => (
|
||||
@@ -78,7 +78,7 @@ export function LineReconciliationTab({
|
||||
))}
|
||||
</div>
|
||||
<div data-testid="right-column">
|
||||
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
|
||||
<h3 className="eyebrow text-muted-foreground mb-2">
|
||||
Adjudicated (835 SVC)
|
||||
</h3>
|
||||
{rightRows.map((row) => (
|
||||
@@ -114,7 +114,7 @@ function LineCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-[color:var(--m-ink-tertiary)]/5"
|
||||
className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-muted/40"
|
||||
style={{
|
||||
borderLeft: `3px solid ${accentColor}`,
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
@@ -123,9 +123,9 @@ function LineCard({
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="font-mono text-sm">
|
||||
<span className="font-semibold text-[color:var(--m-ink-primary)]">{procCode}</span>
|
||||
<span className="font-semibold text-foreground">{procCode}</span>
|
||||
{lineNumber !== null ? (
|
||||
<span className="text-[color:var(--m-ink-tertiary)] ml-2">
|
||||
<span className="text-muted-foreground ml-2">
|
||||
#{lineNumber}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -146,16 +146,16 @@ function LineCard({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="font-mono tabular-nums text-xs text-[color:var(--m-ink-primary)] font-semibold">
|
||||
<div className="font-mono tabular-nums text-xs text-foreground font-semibold">
|
||||
{side === "left" && cl ? <span>{fmt.usdPrecise(Number(cl.charge))}</span> : null}
|
||||
{side === "right" && svc ? <span>{fmt.usdPrecise(Number(svc.payment))}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{row.adjustments.length > 0 ? (
|
||||
<ul className="text-xs font-mono text-[color:var(--m-ink-tertiary)] mt-1 space-y-0.5">
|
||||
<ul className="text-xs font-mono text-muted-foreground mt-1 space-y-0.5">
|
||||
{row.adjustments.map((a, i) => (
|
||||
<li key={i}>
|
||||
<span className="text-[color:var(--m-ink-secondary)]">{a.groupCode}-{a.reasonCode}</span>
|
||||
<span className="text-foreground/80">{a.groupCode}-{a.reasonCode}</span>
|
||||
<span className="ml-2 tabular-nums">{fmt.usdPrecise(Number(a.amount))}</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -56,20 +56,20 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-muted-foreground"
|
||||
>
|
||||
Matched Remittance
|
||||
</h3>
|
||||
|
||||
<div
|
||||
data-testid="matched-remit-card-inner"
|
||||
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 p-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
className="flex flex-col gap-4 rounded-lg border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
{/* Left: remit id + status badge + received date */}
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<span
|
||||
data-testid="matched-remit-id"
|
||||
className="mono text-sm text-[color:var(--m-ink-primary)]"
|
||||
className="mono text-sm text-foreground"
|
||||
>
|
||||
{id}
|
||||
</span>
|
||||
@@ -83,7 +83,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
||||
</Badge>
|
||||
<span
|
||||
data-testid="matched-remit-received"
|
||||
className="text-xs text-[color:var(--m-ink-tertiary)]"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Received {fmt.date(receivedAt)}
|
||||
</span>
|
||||
@@ -94,7 +94,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
||||
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||
<span
|
||||
data-testid="matched-remit-total-paid"
|
||||
className="display text-3xl text-[color:var(--m-ink-primary)] tabular-nums"
|
||||
className="display text-3xl text-foreground tabular-nums"
|
||||
>
|
||||
{fmt.usdPrecise(totalPaid)}
|
||||
</span>
|
||||
@@ -103,7 +103,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
||||
size="sm"
|
||||
onClick={handleViewRemittance}
|
||||
data-testid="view-remittance-link"
|
||||
className="gap-1 text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)]"
|
||||
className="gap-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View remittance
|
||||
<ArrowRight
|
||||
|
||||
@@ -6,13 +6,23 @@
|
||||
|
||||
import React, { act } from "react";
|
||||
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 { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||
import type {
|
||||
ClaimDetailAddress,
|
||||
ClaimDetailParties,
|
||||
} 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): {
|
||||
container: HTMLDivElement;
|
||||
unmount: () => void;
|
||||
@@ -20,8 +30,11 @@ function renderIntoContainer(element: React.ReactElement): {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(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(() => {
|
||||
root.render(element);
|
||||
root.render(<DrillStackProvider>{element}</DrillStackProvider>);
|
||||
});
|
||||
return {
|
||||
container,
|
||||
@@ -279,4 +292,112 @@ describe("PartiesGrid", () => {
|
||||
|
||||
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 { useDrillStack } from "@/components/drill/DrillStackProvider";
|
||||
import { PeekModal } from "@/components/drill/PeekModal";
|
||||
import { PayerPeekContent } from "@/components/drill/PayerPeekContent";
|
||||
|
||||
type PartiesGridProps = {
|
||||
parties: ClaimDetail["parties"];
|
||||
@@ -25,7 +28,7 @@ function AddressBlock({ address }: { address: AddressLike }) {
|
||||
const a = address as ClaimDetailAddress;
|
||||
return (
|
||||
<div
|
||||
className="mt-1 border-t border-dashed border-[color:var(--m-border-heavy)]/30 pt-2 text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
|
||||
className="mt-1 border-t border-dashed border-border/60 pt-2 text-xs leading-relaxed text-muted-foreground"
|
||||
data-testid="party-address"
|
||||
>
|
||||
<div>{a.line1}</div>
|
||||
@@ -48,26 +51,40 @@ function PartyCard({
|
||||
name,
|
||||
identity,
|
||||
address,
|
||||
onNameClick,
|
||||
}: {
|
||||
testId: string;
|
||||
label: string;
|
||||
name: string;
|
||||
identity: React.ReactNode;
|
||||
address?: AddressLike;
|
||||
onNameClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 px-4 py-3 transition-colors hover:border-[color:var(--m-border-heavy)]/50"
|
||||
className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/30 px-4 py-3 transition-colors hover:border-border hover:bg-muted/40"
|
||||
>
|
||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="eyebrow text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<div className="display text-lg text-[color:var(--m-ink-primary)]">
|
||||
{name}
|
||||
</div>
|
||||
{onNameClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNameClick}
|
||||
data-testid={`${testId}-name-drill`}
|
||||
aria-label={`Drill into ${label.toLowerCase()} ${name}`}
|
||||
className="display text-lg text-foreground text-left cursor-pointer drillable rounded-sm px-0 -mx-0"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
) : (
|
||||
<div className="display text-lg text-foreground">
|
||||
{name}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
className="mono text-[12.5px] text-muted-foreground tabular-nums"
|
||||
>
|
||||
{identity}
|
||||
</div>
|
||||
@@ -88,6 +105,13 @@ function PartyCard({
|
||||
*/
|
||||
export function PartiesGrid({ parties }: PartiesGridProps) {
|
||||
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 (
|
||||
<section
|
||||
@@ -96,7 +120,7 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-muted-foreground"
|
||||
>
|
||||
Parties
|
||||
</h3>
|
||||
@@ -142,8 +166,31 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,17 +33,17 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-muted-foreground"
|
||||
>
|
||||
Raw Segments ({rawSegments.length})
|
||||
</h3>
|
||||
|
||||
<details className="group rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/40 overflow-hidden transition-colors hover:border-[color:var(--m-border-heavy)]/50">
|
||||
<details className="group rounded-lg border border-border/60 bg-muted/30 overflow-hidden transition-colors hover:border-border">
|
||||
<summary
|
||||
className="cursor-pointer select-none px-4 py-2.5 text-sm text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)] transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2"
|
||||
className="cursor-pointer select-none px-4 py-2.5 text-sm text-muted-foreground hover:text-foreground transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2"
|
||||
data-testid="raw-segments-summary"
|
||||
>
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[color:var(--m-ink-tertiary)] group-open:bg-[color:var(--m-accent)] transition-colors" />
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground group-open:bg-accent transition-colors" />
|
||||
{rawSegments.length === 0
|
||||
? `Show raw segments`
|
||||
: `Show ${rawSegments.length} raw segment${rawSegments.length === 1 ? "" : "s"}`}
|
||||
@@ -52,14 +52,14 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
|
||||
{rawSegments.length === 0 ? (
|
||||
<p
|
||||
data-testid="raw-segments-empty"
|
||||
className="px-4 pb-3 text-sm text-[color:var(--m-ink-tertiary)]"
|
||||
className="px-4 pb-3 text-sm text-muted-foreground"
|
||||
>
|
||||
No segments
|
||||
</p>
|
||||
) : (
|
||||
<pre
|
||||
data-testid="raw-segments-pre"
|
||||
className="overflow-x-auto whitespace-pre-wrap border-t border-[color:var(--m-border-heavy)]/20 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-[color:var(--m-ink-primary)] font-mono"
|
||||
className="overflow-x-auto whitespace-pre-wrap border-t border-border/40 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-foreground font-mono"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
>
|
||||
{rawSegments.map((segment, i) => (
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ServiceLinesTable({
|
||||
<section className="flex flex-col gap-3 px-6 py-4">
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-muted-foreground"
|
||||
>
|
||||
Service Lines ({serviceLines.length})
|
||||
</h3>
|
||||
@@ -57,7 +57,7 @@ export function ServiceLinesTable({
|
||||
{serviceLines.length === 0 ? (
|
||||
<p
|
||||
data-testid="service-lines-empty"
|
||||
className="text-sm text-[color:var(--m-ink-tertiary)]"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
No service lines
|
||||
</p>
|
||||
@@ -84,34 +84,34 @@ export function ServiceLinesTable({
|
||||
data-line-number={line.lineNumber}
|
||||
className="row-hover"
|
||||
>
|
||||
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
|
||||
<TableCell className="font-mono text-muted-foreground">
|
||||
{line.lineNumber}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[color:var(--m-ink-primary)]">
|
||||
<TableCell className="font-mono text-foreground">
|
||||
{formatProcedure(line)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)] font-semibold"
|
||||
className="text-right font-mono text-base tabular-nums text-foreground font-semibold"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
>
|
||||
{fmt.usdPrecise(line.charge)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
|
||||
<TableCell className="font-mono text-muted-foreground">
|
||||
{formatUnits(line)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
|
||||
<TableCell className="font-mono text-muted-foreground">
|
||||
{line.serviceDate ? fmt.date(line.serviceDate) : ""}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
data-testid="paid-cell"
|
||||
className="text-right font-mono tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
className="text-right font-mono tabular-nums text-foreground"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
>
|
||||
{formatMoneyOrDash(lr?.paid ?? null)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
data-testid="adjustments-cell"
|
||||
className="text-right font-mono tabular-nums text-[color:var(--m-ink-secondary)]"
|
||||
className="text-right font-mono tabular-nums text-muted-foreground"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
>
|
||||
{formatMoneyOrDash(lr?.adjustmentsSum ?? null)}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-muted-foreground"
|
||||
>
|
||||
State History ({history.length})
|
||||
</h3>
|
||||
@@ -72,14 +72,14 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
||||
{history.length === 0 ? (
|
||||
<p
|
||||
data-testid="state-history-empty"
|
||||
className="text-sm text-[color:var(--m-ink-tertiary)]"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
No history events
|
||||
</p>
|
||||
) : (
|
||||
<ol
|
||||
data-testid="state-history-timeline"
|
||||
className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-[color:var(--m-border-heavy)]/40 pl-6"
|
||||
className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-border/40 pl-6"
|
||||
>
|
||||
{history.map((event, i) => (
|
||||
<li
|
||||
@@ -91,18 +91,18 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
||||
<span
|
||||
data-testid="state-history-dot"
|
||||
aria-hidden
|
||||
className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-[color:var(--m-surface)] ${dotColorFor(event.kind)}`}
|
||||
className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-card ${dotColorFor(event.kind)}`}
|
||||
/>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span
|
||||
data-testid="state-history-kind"
|
||||
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-primary)]"
|
||||
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-foreground"
|
||||
>
|
||||
{kindLabel(event.kind)}
|
||||
</span>
|
||||
<span
|
||||
data-testid="state-history-ts"
|
||||
className="mono text-xs text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
className="mono text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
{fmt.date(event.ts)} · {fmt.time(event.ts)}
|
||||
</span>
|
||||
@@ -110,7 +110,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
||||
{event.remittanceId ? (
|
||||
<span
|
||||
data-testid="history-remit-id"
|
||||
className="mono text-xs text-[color:var(--m-ink-tertiary)]"
|
||||
className="mono text-xs text-muted-foreground"
|
||||
>
|
||||
↳ Remit {event.remittanceId}
|
||||
</span>
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
import React, { act } from "react";
|
||||
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 { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||
import type { ClaimDetailValidation, ClaimDetailValidationIssue } from "@/types";
|
||||
|
||||
function renderIntoContainer(element: React.ReactElement): {
|
||||
@@ -17,8 +18,11 @@ function renderIntoContainer(element: React.ReactElement): {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(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(() => {
|
||||
root.render(element);
|
||||
root.render(<DrillStackProvider>{element}</DrillStackProvider>);
|
||||
});
|
||||
return {
|
||||
container,
|
||||
@@ -244,4 +248,129 @@ describe("ValidationPanel", () => {
|
||||
|
||||
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 { useDrillStack } from "@/components/drill/DrillStackProvider";
|
||||
import { PeekModal } from "@/components/drill/PeekModal";
|
||||
import { ValidationRulePeekContent } from "@/components/drill/ValidationRulePeekContent";
|
||||
import type { ClaimDetail } from "@/types";
|
||||
|
||||
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
|
||||
* 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({
|
||||
rule,
|
||||
@@ -43,17 +53,22 @@ function IssueGroup({
|
||||
testId === "validation-errors"
|
||||
? "text-[color:var(--m-error)]"
|
||||
: "text-[color:var(--m-warning)]";
|
||||
const { openPeek } = useDrillStack();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
||||
<button
|
||||
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-foreground cursor-pointer drillable rounded-sm px-0 -mx-0"
|
||||
>
|
||||
{rule}
|
||||
</span>
|
||||
</button>
|
||||
<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-muted-foreground/15 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground tabular-nums"
|
||||
data-testid={`${testId}-count`}
|
||||
>
|
||||
{issues.length}
|
||||
@@ -63,7 +78,7 @@ function IssueGroup({
|
||||
{issues.map((issue, idx) => (
|
||||
<li
|
||||
key={`${rule}-${idx}`}
|
||||
className="flex items-start gap-2 text-[13px] leading-snug text-[color:var(--m-ink-secondary)]"
|
||||
className="flex items-start gap-2 text-[13px] leading-snug text-muted-foreground"
|
||||
data-testid={`${testId}-message`}
|
||||
>
|
||||
<Icon
|
||||
@@ -93,6 +108,11 @@ function IssueGroup({
|
||||
*/
|
||||
export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
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) {
|
||||
return (
|
||||
@@ -101,14 +121,14 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
className="flex items-center gap-2 px-6 py-3"
|
||||
>
|
||||
<CheckCircle2
|
||||
className="h-4 w-4 text-[color:var(--m-success)]"
|
||||
className="h-4 w-4 text-[hsl(var(--success))]"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[13px] font-medium text-[color:var(--m-ink-primary)]">
|
||||
<span className="text-[13px] font-medium text-foreground">
|
||||
All checks passed
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center rounded-full bg-[color:var(--m-success)]/12 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-success)]">
|
||||
<span className="ml-auto inline-flex items-center rounded-full bg-[hsl(var(--success)/0.12)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[hsl(var(--success))]">
|
||||
Valid
|
||||
</span>
|
||||
</section>
|
||||
@@ -120,10 +140,10 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-4 px-6 py-4 bg-[color:var(--m-surface)]"
|
||||
className="flex flex-col gap-4 px-6 py-4"
|
||||
data-testid="validation-panel"
|
||||
>
|
||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="eyebrow text-muted-foreground">
|
||||
Validation
|
||||
</span>
|
||||
|
||||
@@ -132,15 +152,15 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
data-testid="validation-errors"
|
||||
data-rule-group="errors"
|
||||
role="alert"
|
||||
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.06)] px-3.5 py-3"
|
||||
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.08)] px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle
|
||||
className="h-3.5 w-3.5 text-[color:var(--m-error)]"
|
||||
className="h-3.5 w-3.5 text-destructive"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-error)]">
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-destructive">
|
||||
Errors ({validation.errors.length})
|
||||
</span>
|
||||
</div>
|
||||
@@ -165,11 +185,11 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle
|
||||
className="h-3.5 w-3.5 text-[color:var(--m-warning)]"
|
||||
className="h-3.5 w-3.5 text-[hsl(var(--warning))]"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-warning)]">
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[hsl(var(--warning))]">
|
||||
Warnings ({validation.warnings.length})
|
||||
</span>
|
||||
</div>
|
||||
@@ -185,6 +205,22 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// DominantKpiCard — the headline KPI tile.
|
||||
//
|
||||
// One KPI gets the dominant slot on the dashboard. It carries:
|
||||
// - a thicker accent rule on the left edge
|
||||
// - an oversized Instrument Serif number
|
||||
// - a wide sparkline (not a 28px afterthought)
|
||||
// - delta + hint row in mono
|
||||
// Same paper variant as the smaller KpiCard. Designed to live INSIDE
|
||||
// the statement, next to a column of 4 smaller readouts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { ArrowDownRight, ArrowUpRight, type LucideIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Sparkline } from "./Sparkline";
|
||||
import { AnimatedNumber } from "./AnimatedNumber";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DominantKpiCardProps {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
/** Pre-rendered value node. Required unless `rawValue`+`format`
|
||||
* are both supplied, in which case the AnimatedNumber is used. */
|
||||
value?: React.ReactNode;
|
||||
/** Used when value is a plain number — drives the AnimatedNumber. */
|
||||
rawValue?: number;
|
||||
/** Formatter for the AnimatedNumber. */
|
||||
format?: (n: number) => string;
|
||||
delta?: { value: string; direction: "up" | "down"; positive: boolean };
|
||||
hint?: string;
|
||||
sparkline?: number[];
|
||||
/** Sparkline stroke colour. Defaults to accent. */
|
||||
sparklineColor?: string;
|
||||
/** Accent rail colour (left edge). Defaults to accent. */
|
||||
accent?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function DominantKpiCard({
|
||||
label,
|
||||
icon: Icon,
|
||||
value,
|
||||
rawValue,
|
||||
format,
|
||||
delta,
|
||||
hint,
|
||||
sparkline,
|
||||
sparklineColor = "hsl(var(--accent))",
|
||||
accent = "hsl(var(--accent))",
|
||||
className,
|
||||
style,
|
||||
}: DominantKpiCardProps) {
|
||||
const computedStyle: React.CSSProperties = {
|
||||
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)",
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={computedStyle}
|
||||
className={cn(
|
||||
"relative rounded-xl p-6 flex flex-col gap-4 overflow-hidden",
|
||||
"border border-[hsl(30_14%_14%/_0.10)] hover:bg-[hsl(36_22%_92%)] transition-colors",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Accent rail on the left — thicker than the small cards */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute left-0 top-5 bottom-5 w-[3px] rounded-r-sm"
|
||||
style={{ backgroundColor: accent, opacity: 0.85 }}
|
||||
/>
|
||||
|
||||
{/* Header row: label + icon */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className="eyebrow flex items-center gap-2"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{hint ? (
|
||||
<span
|
||||
className="mono normal-case tracking-normal"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-3))",
|
||||
fontSize: 10,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
· {hint}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{Icon ? (
|
||||
<div
|
||||
className="h-7 w-7 rounded-md flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: "hsl(36 22% 90%)",
|
||||
boxShadow: "inset 0 0 0 1px hsl(30 14% 22% / 0.08)",
|
||||
color: "hsl(var(--surface-ink-2))",
|
||||
}}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Big number */}
|
||||
<div
|
||||
className="display tabular-nums tracking-[-0.04em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(48px, 6vw, 72px)",
|
||||
lineHeight: 0.92,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{rawValue !== undefined && format ? (
|
||||
<AnimatedNumber value={rawValue} format={format} />
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delta + sparkline */}
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-[11px] min-h-[16px]">
|
||||
{delta ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 font-medium mono",
|
||||
delta.positive
|
||||
? "text-[hsl(var(--success))]"
|
||||
: "text-destructive"
|
||||
)}
|
||||
>
|
||||
{delta.direction === "up" ? (
|
||||
<ArrowUpRight className="h-3 w-3" strokeWidth={2} />
|
||||
) : (
|
||||
<ArrowDownRight className="h-3 w-3" strokeWidth={2} />
|
||||
)}
|
||||
{delta.value}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="mono uppercase tracking-[0.14em]"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-3))",
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
vs last 6 mo
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sparkline ? (
|
||||
<div className="flex-1 max-w-[240px]">
|
||||
<Sparkline values={sparkline} stroke={sparklineColor} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// EditorialNote — italic Instrument Serif margin annotations.
|
||||
//
|
||||
// A small italicized serif note that "speaks" to the data next to it.
|
||||
// Used in the dashboard to narrate the day's narrative: "Three NPIs
|
||||
// are in flight" sits next to the KPI grid; an editorial note sits
|
||||
// beside the chart explaining what it shows. The note itself is a
|
||||
// single italic line of Instrument Serif with a tiny serif ampersand
|
||||
// or hairline rule introducing it.
|
||||
//
|
||||
// This is the dashboard's "voice" — the moment the data stops being
|
||||
// silent and reads as a story.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface EditorialNoteProps {
|
||||
children: React.ReactNode;
|
||||
/** Optional lead-in (e.g. "↘", "§", "—" or a short label). */
|
||||
lead?: string;
|
||||
/** Optional secondary line shown below the note in mono caps. */
|
||||
caption?: string;
|
||||
/** Tone — dark canvas (default) or paper. */
|
||||
tone?: "dark" | "paper";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EditorialNote({
|
||||
children,
|
||||
lead,
|
||||
caption,
|
||||
tone = "dark",
|
||||
className,
|
||||
}: EditorialNoteProps) {
|
||||
const noteColor =
|
||||
tone === "paper"
|
||||
? "hsl(var(--surface-ink))"
|
||||
: "hsl(var(--foreground))";
|
||||
const leadColor =
|
||||
tone === "paper"
|
||||
? "hsl(var(--surface-ink-3))"
|
||||
: "hsl(var(--muted-foreground))";
|
||||
const captionColor =
|
||||
tone === "paper"
|
||||
? "hsl(var(--surface-ink-3))"
|
||||
: "hsl(var(--muted-foreground))";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-start gap-3", className)}>
|
||||
{lead ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="display italic shrink-0 leading-none mt-1"
|
||||
style={{ color: leadColor, fontSize: 18 }}
|
||||
>
|
||||
{lead}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="display italic leading-snug"
|
||||
style={{
|
||||
color: noteColor,
|
||||
fontSize: 15,
|
||||
letterSpacing: "-0.005em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
{caption ? (
|
||||
<p
|
||||
className="mono uppercase tracking-[0.18em] mt-1.5"
|
||||
style={{ color: captionColor, fontSize: 9.5 }}
|
||||
>
|
||||
{caption}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// @vitest-environment happy-dom
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
|
||||
import React, { act, useState } from "react";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { ExportBar } from "./ExportBar";
|
||||
|
||||
function renderBar(
|
||||
props: Partial<React.ComponentProps<typeof ExportBar>> = {},
|
||||
): {
|
||||
container: HTMLDivElement;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root: Root = createRoot(container);
|
||||
const onToggleAll = props.onToggleAll ?? (() => {});
|
||||
const onExport = props.onExport ?? (() => {});
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(ExportBar, {
|
||||
total: 10,
|
||||
selectedCount: 10,
|
||||
exporting: false,
|
||||
onToggleAll,
|
||||
onExport,
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
});
|
||||
return {
|
||||
container,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ExportBar", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the total and selected count", () => {
|
||||
const { container, unmount } = renderBar({ total: 24, selectedCount: 18 });
|
||||
expect(container.textContent).toContain("18");
|
||||
expect(container.textContent).toContain("24");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("the select-all checkbox is checked when all items are selected", () => {
|
||||
const { container, unmount } = renderBar({ total: 5, selectedCount: 5 });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
)!;
|
||||
expect(cb.checked).toBe(true);
|
||||
expect(cb.indeterminate).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("the select-all checkbox is unchecked when nothing is selected", () => {
|
||||
const { container, unmount } = renderBar({ total: 5, selectedCount: 0 });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
)!;
|
||||
expect(cb.checked).toBe(false);
|
||||
expect(cb.indeterminate).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("the select-all checkbox is indeterminate when some items are selected", () => {
|
||||
const { container, unmount } = renderBar({ total: 5, selectedCount: 2 });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
)!;
|
||||
expect(cb.checked).toBe(false);
|
||||
expect(cb.indeterminate).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clicking the select-all checkbox calls onToggleAll", () => {
|
||||
const onToggleAll = vi.fn();
|
||||
const { container, unmount } = renderBar({ onToggleAll });
|
||||
const cb = container.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"]',
|
||||
)!;
|
||||
act(() => {
|
||||
cb.click();
|
||||
});
|
||||
expect(onToggleAll).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("the export button is disabled when selectedCount is 0", () => {
|
||||
const { container, unmount } = renderBar({ selectedCount: 0 });
|
||||
const btn = container.querySelector<HTMLButtonElement>("button")!;
|
||||
expect(btn.disabled).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("the export button is enabled when selectedCount > 0", () => {
|
||||
const { container, unmount } = renderBar({ selectedCount: 3 });
|
||||
const btn = container.querySelector<HTMLButtonElement>("button")!;
|
||||
expect(btn.disabled).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("the export button is disabled while exporting", () => {
|
||||
const { container, unmount } = renderBar({ selectedCount: 3, exporting: true });
|
||||
const btn = container.querySelector<HTMLButtonElement>("button")!;
|
||||
expect(btn.disabled).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clicking the export button calls onExport", () => {
|
||||
const onExport = vi.fn();
|
||||
const { container, unmount } = renderBar({ onExport, selectedCount: 3 });
|
||||
const btn = container.querySelector<HTMLButtonElement>("button")!;
|
||||
act(() => {
|
||||
btn.click();
|
||||
});
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders the selected count in the export button label", () => {
|
||||
const { container, unmount } = renderBar({ selectedCount: 7 });
|
||||
const btn = container.querySelector<HTMLButtonElement>("button")!;
|
||||
expect(btn.textContent).toContain("7");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// ExportBar — sticky bar that sits at the top of the Upload page's
|
||||
// streaming section and lets the user select-all / deselect-all the
|
||||
// streamed claims and trigger the batch X12 export.
|
||||
//
|
||||
// Tri-state "Select all" checkbox:
|
||||
// - all selected → checked
|
||||
// - none selected → unchecked
|
||||
// - mixed → indeterminate (the dash / line state)
|
||||
//
|
||||
// The Export button is disabled when the selection is empty (no point
|
||||
// sending an empty bundle) or when an export is already in flight
|
||||
// (don't double-fire the network call).
|
||||
|
||||
import { CheckSquare, Download, Loader2, Square } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ExportBarProps {
|
||||
/** Total claims in the stream (denominator for "X of Y selected"). */
|
||||
total: number;
|
||||
/** Current selection size (numerator). */
|
||||
selectedCount: number;
|
||||
/** When true, the export button shows a spinner and is disabled. */
|
||||
exporting: boolean;
|
||||
/** Called when the user toggles the select-all checkbox. */
|
||||
onToggleAll: () => void;
|
||||
/** Called when the user clicks the Export button. */
|
||||
onExport: () => void;
|
||||
}
|
||||
|
||||
export function ExportBar({
|
||||
total,
|
||||
selectedCount,
|
||||
exporting,
|
||||
onToggleAll,
|
||||
onExport,
|
||||
}: ExportBarProps) {
|
||||
const allSelected = total > 0 && selectedCount === total;
|
||||
const noneSelected = selectedCount === 0;
|
||||
// Indeterminate = some selected, but not all. Bound to the input via
|
||||
// a ref because the `indeterminate` attribute is not a real HTML
|
||||
// attribute — it's a JS property only.
|
||||
const indeterminate = !allSelected && !noneSelected;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-3 py-2 rounded-md border bg-background/40",
|
||||
"flex-wrap",
|
||||
)}
|
||||
style={{ borderColor: "hsl(var(--border) / 0.6)" }}
|
||||
data-testid="export-bar"
|
||||
>
|
||||
<label
|
||||
className="inline-flex items-center gap-2 cursor-pointer select-none text-[12px] mono uppercase tracking-[0.14em] font-semibold text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="export-bar-toggle-all"
|
||||
>
|
||||
{allSelected ? (
|
||||
<CheckSquare
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
style={{ color: "hsl(var(--accent))" }}
|
||||
/>
|
||||
) : (
|
||||
<Square
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
style={{
|
||||
color: indeterminate
|
||||
? "hsl(var(--accent) / 0.7)"
|
||||
: "hsl(var(--muted-foreground) / 0.6)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = indeterminate;
|
||||
}}
|
||||
onChange={onToggleAll}
|
||||
className="sr-only"
|
||||
aria-label={
|
||||
allSelected
|
||||
? "Deselect all claims"
|
||||
: noneSelected
|
||||
? "Select all claims"
|
||||
: `Select all claims (${selectedCount} of ${total} currently selected)`
|
||||
}
|
||||
/>
|
||||
{allSelected ? "Deselect all" : "Select all"}
|
||||
</label>
|
||||
|
||||
<span
|
||||
className="text-[11.5px] mono text-muted-foreground/70"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="text-foreground">{selectedCount}</span> of{" "}
|
||||
<span className="text-foreground">{total}</span> selected
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={onExport}
|
||||
disabled={noneSelected || exporting}
|
||||
data-testid="export-bar-export"
|
||||
aria-label={`Export ${selectedCount} claims as X12 ZIP`}
|
||||
>
|
||||
{exporting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Exporting…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export ({selectedCount})
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,24 @@ export function Layout() {
|
||||
return (
|
||||
<div className="relative min-h-screen z-10">
|
||||
<SkipLink />
|
||||
{/* Ambient page halo — a soft, fixed radial that creates a
|
||||
subtle lighter zone behind the page content (centered
|
||||
just below the top bar). Pure decoration; pointer-events
|
||||
disabled so it never intercepts clicks. Two layered
|
||||
radials: a neutral softbox centered above the page
|
||||
header, and a cool accent in the upper-right that
|
||||
mirrors the body::before composition. The halo sits
|
||||
behind everything else (z-0 inside the z-10 root). */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-x-0 top-0 z-0 h-[70vh]"
|
||||
style={{
|
||||
background: `
|
||||
radial-gradient(ellipse 55% 30% at 50% 6%, hsla(220, 32%, 30%, 0.16), transparent 70%),
|
||||
radial-gradient(ellipse 35% 25% at 82% 10%, hsla(212, 70%, 60%, 0.10), transparent 65%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none transition-opacity duration-200"
|
||||
style={{ opacity: showScan ? 1 : 0 }}
|
||||
|
||||
@@ -44,6 +44,51 @@ const SAMPLE_PROVIDER: Provider = {
|
||||
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
|
||||
@@ -193,4 +238,66 @@ describe("ProviderDrawer", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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 {
|
||||
@@ -12,12 +15,17 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider drill-down drawer (SP21 Task 2.2).
|
||||
* Provider drill-down drawer (SP21 Task 2.2 + 3.1).
|
||||
*
|
||||
* Side-panel shell that consumes ``useProviderDetail(npi)`` and renders
|
||||
* the Overview tab content (Phase 2 ships only this tab; Phase 3 adds
|
||||
* Claims/Activity tabs once ``recent_claims`` and ``recent_activity``
|
||||
* rendering lands).
|
||||
* 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
|
||||
@@ -76,9 +84,22 @@ export function ProviderDrawer({ npi, onClose }: Props) {
|
||||
title={data.name}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<ProviderOverview provider={data} />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -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,152 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// TickerTape — live vital-signs strip for the dashboard hero.
|
||||
//
|
||||
// A horizontal tape of small numeric readouts separated by hairline
|
||||
// dividers, rendered on the dark canvas. Each cell shows a label
|
||||
// (uppercase mono) and a value (mono). One cell can be marked
|
||||
// `accent` to draw the eye, and the optional `live` cell shows a
|
||||
// pulsing dot — the dashboard's only acknowledgement of time.
|
||||
//
|
||||
// This is a static ticker (not scrolling); the cells are just laid
|
||||
// out left-to-right with dividers. We use it to give the dark hero
|
||||
// data density without committing to a real chart.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TickerCell {
|
||||
/** Stable id used as React key. */
|
||||
id: string;
|
||||
/** Uppercase mono label (e.g. "Billed", "Pending", "Denial"). */
|
||||
label: string;
|
||||
/** The value, rendered verbatim in mono. Format it before passing. */
|
||||
value: string;
|
||||
/** Optional secondary value shown muted (e.g. delta). */
|
||||
delta?: string;
|
||||
/** Highlight this cell with the accent colour. */
|
||||
accent?: boolean;
|
||||
}
|
||||
|
||||
export interface TickerTapeProps {
|
||||
cells: TickerCell[];
|
||||
/** Show the "LIVE" pulse on the leading edge. */
|
||||
live?: boolean;
|
||||
/** Live label (default "Live"). */
|
||||
liveLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TickerTape({
|
||||
cells,
|
||||
live = false,
|
||||
liveLabel = "Live",
|
||||
className,
|
||||
}: TickerTapeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-stretch w-full overflow-hidden",
|
||||
"rounded-md border border-border/50 bg-card/40 backdrop-blur",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Left rail — amber signal accent for the LIVE indicator */}
|
||||
{live ? (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 border-r border-border/60 shrink-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, hsl(36 92% 56% / 0.06), transparent)",
|
||||
}}
|
||||
>
|
||||
<span className="relative inline-flex h-1.5 w-1.5 shrink-0">
|
||||
<span
|
||||
className="absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping"
|
||||
style={{ backgroundColor: "hsl(var(--signal))" }}
|
||||
/>
|
||||
<span
|
||||
className="relative inline-flex h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: "hsl(var(--signal))" }}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="mono uppercase tracking-[0.18em] font-semibold"
|
||||
style={{
|
||||
color: "hsl(var(--signal))",
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{liveLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Cells */}
|
||||
<div className="flex items-stretch flex-1 min-w-0 overflow-x-auto">
|
||||
{cells.map((c, i) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"flex-1 min-w-[110px] px-4 py-2.5 flex flex-col gap-0.5",
|
||||
i > 0 ? "border-l border-border/40" : ""
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mono uppercase tracking-[0.18em] font-medium"
|
||||
style={{
|
||||
color: c.accent
|
||||
? "hsl(var(--accent))"
|
||||
: "hsl(var(--muted-foreground))",
|
||||
fontSize: 9.5,
|
||||
}}
|
||||
>
|
||||
{c.label}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className="display mono tabular-nums"
|
||||
style={{
|
||||
color: c.accent
|
||||
? "hsl(var(--foreground))"
|
||||
: "hsl(var(--foreground))",
|
||||
fontSize: 16,
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
{c.value}
|
||||
</span>
|
||||
{c.delta ? (
|
||||
<span
|
||||
className="mono tabular-nums"
|
||||
style={{
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
fontSize: 10.5,
|
||||
}}
|
||||
>
|
||||
{c.delta}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right rail — perforated tick pattern, decoratively marks the
|
||||
end of the tape without using a heavy border. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="shrink-0 w-3 self-stretch"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, hsl(36 14% 88% / 0.16) 1px, transparent 1.5px)",
|
||||
backgroundSize: "6px 6px",
|
||||
backgroundPosition: "0 center",
|
||||
maskImage:
|
||||
"linear-gradient(to right, transparent, black 30%, black 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent, black 30%, black 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgingBars — AR aging stacked horizontal bar.
|
||||
//
|
||||
// One bar split into up to 4 segments by aging bucket (0–30 / 31–60 /
|
||||
// 61–90 / 90+ days). Each segment is coloured along a cool-to-warm ramp
|
||||
// so the eye reads left-to-right as "freshest to most stale". Below
|
||||
// the bar, four legend rows show the bucket label, dollar amount, and
|
||||
// share of total. The component is data-driven — pass any four-bucket
|
||||
// distribution.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { fmt } from "@/lib/format";
|
||||
|
||||
export interface AgingBucket {
|
||||
/** Bucket id for keying. */
|
||||
id: string;
|
||||
/** Display label (e.g. "0–30", "31–60", "61–90", "90+"). */
|
||||
label: string;
|
||||
/** Dollar amount outstanding in this bucket. */
|
||||
amount: number;
|
||||
/** Tailwind/HSL colour for the segment. */
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface AgingBarsProps {
|
||||
buckets: AgingBucket[];
|
||||
/** Total AR amount for share calc. Defaults to sum of buckets. */
|
||||
total?: number;
|
||||
/** Optional caption shown right-aligned above the bar. */
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export function AgingBars({ buckets, total, caption }: AgingBarsProps) {
|
||||
const sum = total ?? buckets.reduce((s, b) => s + b.amount, 0);
|
||||
if (sum <= 0) {
|
||||
return (
|
||||
<div
|
||||
className="mono text-[11px] py-6 text-center"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
Nothing outstanding.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{caption ? (
|
||||
<div
|
||||
className="mono text-[10.5px] uppercase tracking-[0.16em] mb-2 flex items-center justify-between"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
<span>Aging buckets</span>
|
||||
<span>{caption}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* The stacked bar */}
|
||||
<div
|
||||
className="flex w-full h-3 overflow-hidden rounded-[3px]"
|
||||
style={{ boxShadow: "inset 0 0 0 1px hsl(30 14% 14% / 0.08)" }}
|
||||
>
|
||||
{buckets.map((b, i) => {
|
||||
const pct = (b.amount / sum) * 100;
|
||||
if (pct <= 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: b.color,
|
||||
animation: `bar-grow-h 800ms cubic-bezier(0.2,0.8,0.2,1) ${
|
||||
i * 70
|
||||
}ms both`,
|
||||
transformOrigin: "left center",
|
||||
}}
|
||||
title={`${b.label}: ${fmt.usd(b.amount)}`}
|
||||
aria-label={`${b.label} days: ${fmt.usd(b.amount)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend rows */}
|
||||
<ul className="mt-4 space-y-2">
|
||||
{buckets.map((b) => {
|
||||
const share = sum > 0 ? (b.amount / sum) * 100 : 0;
|
||||
return (
|
||||
<li
|
||||
key={b.id}
|
||||
className="flex items-center justify-between gap-3 text-[12px]"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-2 w-2 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: b.color }}
|
||||
/>
|
||||
<span
|
||||
className="mono tabular-nums"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{b.label}d
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div
|
||||
className="relative h-1 w-16 rounded-full overflow-hidden"
|
||||
style={{ backgroundColor: "hsl(30 14% 14% / 0.06)" }}
|
||||
aria-hidden
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
style={{
|
||||
width: `${share}%`,
|
||||
backgroundColor: b.color,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="mono tabular-nums"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
minWidth: 70,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{fmt.usd(b.amount)}
|
||||
</span>
|
||||
<span
|
||||
className="mono tabular-nums"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-3))",
|
||||
minWidth: 40,
|
||||
textAlign: "right",
|
||||
fontSize: 10.5,
|
||||
}}
|
||||
>
|
||||
{share.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// BarChart — grouped vertical bar chart on cream paper.
|
||||
//
|
||||
// Renders N series (e.g. billed, received, AR) over M categories (months).
|
||||
// Each category gets a cluster of bars, one per series. The chart grows
|
||||
// the bars from 0 on mount with a staggered delay so the statement
|
||||
// doesn't appear all at once. Hover shows the exact value as a small
|
||||
// ledger callout. Designed to live INSIDE the paper statement — no dark
|
||||
// chrome, hairline grid only.
|
||||
//
|
||||
// The paper variant: cream background, dark ink, hairline rule in
|
||||
// --surface-line-soft. Three series uses --accent (billed), --success
|
||||
// (received), --signal (AR outstanding) so each carries its own
|
||||
// semantic colour rather than three random hues.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface BarSeries {
|
||||
/** Stable id used for keying, hover hit-tests, and legend swatches. */
|
||||
id: string;
|
||||
/** Display name shown in the legend. */
|
||||
label: string;
|
||||
/** Hex/HSL colour used for bar fill + legend swatch. */
|
||||
color: string;
|
||||
/** Values aligned with `categories`. */
|
||||
values: number[];
|
||||
/** Optional formatter for tooltip + axis labels. Defaults to identity. */
|
||||
format?: (n: number) => string;
|
||||
}
|
||||
|
||||
export interface BarChartProps {
|
||||
/** Category labels along the X axis (e.g. month abbreviations). */
|
||||
categories: string[];
|
||||
/** Ordered series. First series renders leftmost in each cluster. */
|
||||
series: BarSeries[];
|
||||
/** Height in px. Default 260. */
|
||||
height?: number;
|
||||
/** Optional y-axis tick formatter. If omitted, the chart self-scales
|
||||
* to a "round" max and labels with a compact thousands suffix. */
|
||||
formatY?: (n: number) => string;
|
||||
/** When true, draws a hairline rule for each y tick. Default true. */
|
||||
showGrid?: boolean;
|
||||
/** Force a specific Y-axis maximum. When omitted, the chart auto-scales
|
||||
* to a "round" max with a small headroom. */
|
||||
yMax?: number;
|
||||
/** When true, prints the per-cluster total above each month. Default true. */
|
||||
showTotals?: boolean;
|
||||
/** Index of the "current" category to highlight (e.g. latest month).
|
||||
* Adds a subtle background band and an eyebrow above. */
|
||||
highlightIndex?: number;
|
||||
}
|
||||
|
||||
const PAD_LEFT = 44;
|
||||
const PAD_RIGHT = 16;
|
||||
const PAD_TOP = 16;
|
||||
const PAD_BOTTOM = 28;
|
||||
|
||||
const niceMax = (raw: number): number => {
|
||||
if (raw <= 0) return 1;
|
||||
const exp = Math.floor(Math.log10(raw));
|
||||
const base = Math.pow(10, exp);
|
||||
const norm = raw / base;
|
||||
let nice: number;
|
||||
if (norm <= 1) nice = 1;
|
||||
else if (norm <= 2) nice = 2;
|
||||
else if (norm <= 2.5) nice = 2.5;
|
||||
else if (norm <= 5) nice = 5;
|
||||
else nice = 10;
|
||||
return nice * base;
|
||||
};
|
||||
|
||||
const compact = (n: number): string => {
|
||||
const abs = Math.abs(n);
|
||||
if (abs >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (abs >= 1_000) return `$${(n / 1_000).toFixed(0)}k`;
|
||||
return `$${n.toFixed(0)}`;
|
||||
};
|
||||
|
||||
interface Hover {
|
||||
cat: number;
|
||||
series: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function BarChart({
|
||||
categories,
|
||||
series,
|
||||
height = 260,
|
||||
formatY,
|
||||
showGrid = true,
|
||||
yMax: yMaxProp,
|
||||
showTotals = true,
|
||||
highlightIndex,
|
||||
}: BarChartProps) {
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const [width, setWidth] = useState(800);
|
||||
const [hover, setHover] = useState<Hover | null>(null);
|
||||
|
||||
// Track container width so the SVG reflows with the layout grid.
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width ?? 800;
|
||||
setWidth(Math.max(320, Math.floor(w)));
|
||||
});
|
||||
ro.observe(el);
|
||||
setWidth(Math.max(320, Math.floor(el.clientWidth)));
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const innerW = width - PAD_LEFT - PAD_RIGHT;
|
||||
const innerH = height - PAD_TOP - PAD_BOTTOM;
|
||||
|
||||
const allValues = series.flatMap((s) => s.values);
|
||||
const rawMax = allValues.length ? Math.max(...allValues) : 1;
|
||||
// If caller forces a yMax, use it; otherwise compute a "round" max
|
||||
// with a small (5%) headroom so the tallest bar doesn't kiss the lid.
|
||||
const yMax = yMaxProp ?? niceMax(rawMax * 1.05);
|
||||
const yTicks = 4;
|
||||
|
||||
const clusterW = innerW / Math.max(1, categories.length);
|
||||
const barGap = 4;
|
||||
const barW = Math.max(
|
||||
4,
|
||||
(clusterW - barGap * (series.length - 1)) / Math.max(1, series.length)
|
||||
);
|
||||
|
||||
const yToPx = (v: number) => PAD_TOP + innerH - (v / yMax) * innerH;
|
||||
const catCenterX = (i: number) => PAD_LEFT + clusterW * i + clusterW / 2;
|
||||
|
||||
const fmtY = formatY ?? compact;
|
||||
|
||||
// Stagger the bars so they grow from baseline on first paint. We just
|
||||
// emit a CSS animation-delay per bar via inline style.
|
||||
const barIndex = (catI: number, serI: number) =>
|
||||
catI * series.length + serI;
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative w-full" style={{ height }}>
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="block"
|
||||
role="img"
|
||||
aria-label="Monthly billed, received, and AR trend"
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{/* Y-axis grid + labels */}
|
||||
{showGrid &&
|
||||
Array.from({ length: yTicks + 1 }, (_, i) => {
|
||||
const v = (yMax / yTicks) * i;
|
||||
const y = yToPx(v);
|
||||
return (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={PAD_LEFT}
|
||||
x2={width - PAD_RIGHT}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="hsl(30 14% 14% / 0.10)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={i === 0 ? "" : "2 3"}
|
||||
/>
|
||||
<text
|
||||
x={PAD_LEFT - 10}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
className="mono"
|
||||
fontSize={11}
|
||||
fontWeight={500}
|
||||
style={{ fill: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{fmtY(v)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Highlight band on the current month (subtle, like a sticky note) */}
|
||||
{highlightIndex !== undefined &&
|
||||
highlightIndex >= 0 &&
|
||||
highlightIndex < categories.length ? (
|
||||
<g>
|
||||
<rect
|
||||
x={catCenterX(highlightIndex) - clusterW / 2}
|
||||
y={PAD_TOP}
|
||||
width={clusterW}
|
||||
height={innerH}
|
||||
fill="hsl(36 92% 56% / 0.06)"
|
||||
/>
|
||||
<line
|
||||
x1={catCenterX(highlightIndex)}
|
||||
x2={catCenterX(highlightIndex)}
|
||||
y1={PAD_TOP}
|
||||
y2={PAD_TOP + innerH}
|
||||
stroke="hsl(36 92% 56% / 0.45)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2 3"
|
||||
/>
|
||||
<text
|
||||
x={catCenterX(highlightIndex)}
|
||||
y={PAD_TOP + 12}
|
||||
textAnchor="middle"
|
||||
className="mono"
|
||||
fontSize={9}
|
||||
fontWeight={600}
|
||||
style={{ fill: "hsl(36 92% 36%)", letterSpacing: "0.18em" }}
|
||||
>
|
||||
NOW
|
||||
</text>
|
||||
</g>
|
||||
) : null}
|
||||
|
||||
{/* Bars */}
|
||||
{categories.map((cat, ci) =>
|
||||
series.map((s, si) => {
|
||||
const v = s.values[ci] ?? 0;
|
||||
const x =
|
||||
catCenterX(ci) -
|
||||
(series.length * barW + (series.length - 1) * barGap) / 2 +
|
||||
si * (barW + barGap);
|
||||
const y = yToPx(v);
|
||||
const h = Math.max(0, PAD_TOP + innerH - y);
|
||||
const delay = barIndex(ci, si) * 35;
|
||||
const isHover =
|
||||
hover?.cat === ci && hover?.series === si ? true : false;
|
||||
return (
|
||||
<rect
|
||||
key={`${cat}-${s.id}`}
|
||||
x={x}
|
||||
y={y}
|
||||
width={barW}
|
||||
height={h}
|
||||
rx={1.5}
|
||||
fill={s.color}
|
||||
opacity={hover && !isHover ? 0.55 : 1}
|
||||
style={{
|
||||
transformOrigin: `${x + barW / 2}px ${PAD_TOP + innerH}px`,
|
||||
animation: `bar-grow 600ms cubic-bezier(0.2,0.8,0.2,1) ${delay}ms both`,
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHover({
|
||||
cat: ci,
|
||||
series: si,
|
||||
x: x + barW / 2,
|
||||
y,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* X-axis labels */}
|
||||
{categories.map((cat, i) => (
|
||||
<text
|
||||
key={cat}
|
||||
x={catCenterX(i)}
|
||||
y={height - 14}
|
||||
textAnchor="middle"
|
||||
className="mono"
|
||||
fontSize={11}
|
||||
fontWeight={500}
|
||||
style={{
|
||||
fill:
|
||||
highlightIndex === i
|
||||
? "hsl(var(--surface-ink))"
|
||||
: "hsl(var(--surface-ink-2))",
|
||||
}}
|
||||
>
|
||||
{cat}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Per-cluster totals above each month — quick read of the spine. */}
|
||||
{showTotals &&
|
||||
categories.map((_, ci) => {
|
||||
const total = series.reduce(
|
||||
(s, sr) => s + (sr.values[ci] ?? 0),
|
||||
0
|
||||
);
|
||||
const x = catCenterX(ci);
|
||||
const topY = yToPx(
|
||||
Math.max(...series.map((s) => s.values[ci] ?? 0))
|
||||
);
|
||||
const labelY = Math.max(14, topY - 8);
|
||||
return (
|
||||
<text
|
||||
key={`total-${ci}`}
|
||||
x={x}
|
||||
y={labelY}
|
||||
textAnchor="middle"
|
||||
className="display mono"
|
||||
fontSize={11}
|
||||
fontWeight={600}
|
||||
style={{
|
||||
fill: "hsl(var(--surface-ink-2))",
|
||||
opacity: 0.75,
|
||||
}}
|
||||
>
|
||||
{fmtY(total)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Hover callout */}
|
||||
{hover ? (
|
||||
(() => {
|
||||
const s = series[hover.series]!;
|
||||
const v = s.values[hover.cat] ?? 0;
|
||||
const catLabel = categories[hover.cat] ?? "";
|
||||
const text = `${s.label} · ${catLabel}`;
|
||||
const valueText = s.format ? s.format(v) : fmtY(v);
|
||||
// Position the chip to the right of the bar; flip left if it
|
||||
// would overflow the canvas edge.
|
||||
const flipLeft = hover.x > width - 160;
|
||||
const chipX = flipLeft ? hover.x - 8 : hover.x + 8;
|
||||
const anchor: "end" | "start" = flipLeft ? "end" : "start";
|
||||
return (
|
||||
<g style={{ pointerEvents: "none" }}>
|
||||
<line
|
||||
x1={hover.x}
|
||||
x2={hover.x}
|
||||
y1={PAD_TOP}
|
||||
y2={PAD_TOP + innerH}
|
||||
stroke={s.color}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2 3"
|
||||
opacity={0.55}
|
||||
/>
|
||||
<text
|
||||
x={chipX}
|
||||
y={Math.max(14, hover.y - 10)}
|
||||
textAnchor={anchor}
|
||||
className="mono"
|
||||
fontSize={10}
|
||||
style={{ fill: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
<text
|
||||
x={chipX}
|
||||
y={Math.max(26, hover.y + 2)}
|
||||
textAnchor={anchor}
|
||||
className="display mono"
|
||||
fontSize={16}
|
||||
style={{ fill: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{valueText}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})()
|
||||
) : null}
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 mt-3 flex-wrap">
|
||||
{series.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-2 w-2 rounded-sm"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
<span
|
||||
className="mono text-[10.5px] uppercase tracking-[0.16em]"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// HBarChart — horizontal bar chart for ranked lists (e.g. Top Providers).
|
||||
//
|
||||
// Each row gets a label slot on the left, a bar that grows from 0 to
|
||||
// its value on mount, and a trailing value. Bars share a common scale
|
||||
// derived from the max value. Designed for 4–8 rows; collapses to a
|
||||
// list if rows are empty. Paper-friendly: cream background, dark ink.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface HBarRow {
|
||||
/** Stable id used as React key. */
|
||||
id: string;
|
||||
/** Left label (e.g. provider name). */
|
||||
label: string;
|
||||
/** Sub-label under the row (e.g. NPI). Optional. */
|
||||
sublabel?: string;
|
||||
/** Bar value. */
|
||||
value: number;
|
||||
/** Optional pre-formatted trailing value. Defaults to fmt.num(value). */
|
||||
formatted?: string;
|
||||
/** Optional color override. Defaults to --accent. */
|
||||
color?: string;
|
||||
/** Optional leading slot (icon, initials avatar, etc). */
|
||||
leading?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface HBarChartProps {
|
||||
rows: HBarRow[];
|
||||
/** Bar fill colour when a row doesn't override. Defaults to accent. */
|
||||
defaultColor?: string;
|
||||
/** Pixel height of each row. Default 44. */
|
||||
rowHeight?: number;
|
||||
/** Compact mode: smaller text, tighter padding. */
|
||||
compact?: boolean;
|
||||
/** Optional click handler. */
|
||||
onRowClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function HBarChart({
|
||||
rows,
|
||||
defaultColor = "hsl(var(--accent))",
|
||||
rowHeight = 44,
|
||||
compact = false,
|
||||
onRowClick,
|
||||
}: HBarChartProps) {
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const [labelW, setLabelW] = useState(180);
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width ?? 800;
|
||||
// Reserve ~38% of width for label + leading slot, capped between
|
||||
// 140 and 260 so bars stay readable on wide and narrow layouts.
|
||||
setLabelW(Math.max(140, Math.min(260, Math.floor(w * 0.38))));
|
||||
});
|
||||
ro.observe(el);
|
||||
setLabelW(Math.max(140, Math.min(260, Math.floor(el.clientWidth * 0.38))));
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="mono text-[11px] py-6 text-center"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
No data yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const max = Math.max(...rows.map((r) => r.value), 1);
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="w-full">
|
||||
{rows.map((r, i) => {
|
||||
const pct = (r.value / max) * 100;
|
||||
const color = r.color ?? defaultColor;
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
role={onRowClick ? "button" : undefined}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
onClick={onRowClick ? () => onRowClick(r.id) : undefined}
|
||||
onKeyDown={
|
||||
onRowClick
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRowClick(r.id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"group flex items-center gap-3",
|
||||
compact ? "py-1.5" : "py-2.5",
|
||||
onRowClick ? "cursor-pointer" : ""
|
||||
)}
|
||||
style={{ minHeight: rowHeight }}
|
||||
>
|
||||
{/* Leading slot (e.g. initials avatar) */}
|
||||
{r.leading ? (
|
||||
<div className="shrink-0">{r.leading}</div>
|
||||
) : null}
|
||||
|
||||
{/* Label column */}
|
||||
<div
|
||||
className="shrink-0 min-w-0"
|
||||
style={{ width: labelW - (r.leading ? 36 : 0) }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"truncate font-medium",
|
||||
compact ? "text-[12.5px]" : "text-[13.5px]"
|
||||
)}
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
{r.label}
|
||||
</div>
|
||||
{r.sublabel ? (
|
||||
<div
|
||||
className="mono truncate"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-3))",
|
||||
fontSize: 10.5,
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
{r.sublabel}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div className="flex-1 min-w-0 relative">
|
||||
{/* Track (hairline) */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
|
||||
style={{ backgroundColor: "hsl(30 14% 14% / 0.08)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="relative h-[10px] rounded-[2px]"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.85,
|
||||
animation: `bar-grow-h 700ms cubic-bezier(0.2,0.8,0.2,1) ${
|
||||
i * 50
|
||||
}ms both`,
|
||||
transformOrigin: "left center",
|
||||
boxShadow: `inset 0 1px 0 hsl(0 0% 100% / 0.15)`,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
{/* Rank tick on the bar */}
|
||||
<span
|
||||
className="mono absolute top-1/2 -translate-y-1/2 text-[9px] font-semibold tabular-nums"
|
||||
style={{
|
||||
left: 6,
|
||||
color: "hsl(0 0% 100% / 0.92)",
|
||||
mixBlendMode: "screen",
|
||||
opacity: pct > 12 ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Trailing value */}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 text-right mono tabular-nums",
|
||||
compact ? "text-[12.5px]" : "text-[14px]"
|
||||
)}
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
minWidth: 56,
|
||||
}}
|
||||
>
|
||||
{r.formatted ?? r.value.toLocaleString("en-US")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// SegmentedBar — thin horizontal status distribution strip.
|
||||
//
|
||||
// A single bar split into N color segments showing the share of each
|
||||
// status. The legend below the bar shows the segment label + count +
|
||||
// percentage, color-coded to the segment. Designed for 4–6 statuses.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { fmt } from "@/lib/format";
|
||||
|
||||
export interface Segment {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface SegmentedBarProps {
|
||||
segments: Segment[];
|
||||
/** Optional caption shown above the bar (e.g. "Status distribution"). */
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export function SegmentedBar({ segments, caption }: SegmentedBarProps) {
|
||||
const total = segments.reduce((s, x) => s + x.count, 0);
|
||||
if (total <= 0) {
|
||||
return (
|
||||
<div
|
||||
className="mono text-[11px] py-6 text-center"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
No claims yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{caption ? (
|
||||
<div
|
||||
className="mono text-[10.5px] uppercase tracking-[0.16em] mb-2"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
{caption}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* The bar */}
|
||||
<div
|
||||
className="flex w-full h-[10px] overflow-hidden rounded-[3px]"
|
||||
style={{ boxShadow: "inset 0 0 0 1px hsl(30 14% 14% / 0.08)" }}
|
||||
>
|
||||
{segments.map((s, i) => {
|
||||
const pct = (s.count / total) * 100;
|
||||
if (pct <= 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: s.color,
|
||||
animation: `bar-grow-h 700ms cubic-bezier(0.2,0.8,0.2,1) ${
|
||||
i * 50
|
||||
}ms both`,
|
||||
transformOrigin: "left center",
|
||||
}}
|
||||
title={`${s.label}: ${s.count} (${((s.count / total) * 100).toFixed(0)}%)`}
|
||||
aria-label={`${s.label}: ${s.count} of ${total}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<ul className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
{segments.map((s) => {
|
||||
const pct = (s.count / total) * 100;
|
||||
if (s.count === 0) return null;
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-2 text-[11.5px] min-w-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-2 w-2 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
<span
|
||||
className="truncate"
|
||||
style={{ color: "hsl(var(--surface-ink-2))" }}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 mono tabular-nums">
|
||||
<span style={{ color: "hsl(var(--surface-ink))" }}>
|
||||
{fmt.num(s.count)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink-3))",
|
||||
fontSize: 10.5,
|
||||
minWidth: 30,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{pct.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,7 +25,7 @@ interface Props {
|
||||
* the app (see ``.eyebrow`` in ``src/index.css`` and the
|
||||
* ``DrillDrawerHeader`` usage in ``ClaimDrawerHeader``).
|
||||
*/
|
||||
export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
|
||||
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>
|
||||
@@ -24,14 +34,17 @@ export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
|
||||
</div>
|
||||
<h2 className="text-[18px] font-semibold tracking-tight mt-0.5">{title}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close drawer"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
<div 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -102,10 +102,15 @@ export function Lane({
|
||||
<section
|
||||
className="flex-1 min-w-[280px] flex flex-col rounded-md overflow-hidden"
|
||||
style={{
|
||||
background: "var(--tt-bg-elev)",
|
||||
// "Lit from above" gradient — same treatment as the main
|
||||
// .surface cards, in the inbox's cool/blue-grey hue family.
|
||||
// Each lane becomes its own small lighter area against the
|
||||
// surrounding ticker-tape background.
|
||||
background:
|
||||
"linear-gradient(180deg, hsl(220 18% 11.5%) 0%, hsl(220 18% 9.5%) 55%, hsl(220 18% 8.5%) 100%)",
|
||||
border: "1px solid hsl(220 8% 18%)",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.03), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
|
||||
}}
|
||||
data-lane={name.toLowerCase()}
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,288 @@
|
||||
// @vitest-environment happy-dom
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
|
||||
import React, { act } from "react";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
|
||||
// Mock the api + download + toast surfaces so the hook can be exercised
|
||||
// in isolation. Each test sets up its own expectations on these.
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
exportBatch837: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/download", () => ({
|
||||
downloadBlob: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useBatchExport, type BatchExportItem } from "./useBatchExport";
|
||||
import { api } from "@/lib/api";
|
||||
import { downloadBlob } from "@/lib/download";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const mockExportBatch837 = vi.mocked(api.exportBatch837);
|
||||
const mockDownloadBlob = vi.mocked(downloadBlob);
|
||||
const mockToastSuccess = vi.mocked(toast.success);
|
||||
const mockToastWarning = vi.mocked(toast.warning);
|
||||
const mockToastError = vi.mocked(toast.error);
|
||||
|
||||
function makeItem(claimId: string): BatchExportItem {
|
||||
return { kind: "837p", data: { claim_id: claimId } };
|
||||
}
|
||||
function make835Item(id: string): BatchExportItem {
|
||||
return { kind: "835", data: { payer_claim_control_number: id } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the hook with a fixed initial list and capture its return
|
||||
* value. The `rerenderWith` helper re-creates the harness with a new
|
||||
* initial list — that's how we simulate "new claims streamed in"
|
||||
* without poking at internal useState setters from outside the
|
||||
* component.
|
||||
*/
|
||||
function setup(initial: BatchExportItem[]) {
|
||||
let latest: ReturnType<typeof useBatchExport> | null = null;
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
|
||||
function Harness({ items }: { items: BatchExportItem[] }) {
|
||||
latest = useBatchExport(items);
|
||||
return null;
|
||||
}
|
||||
|
||||
const root: Root = createRoot(container);
|
||||
function render(items: BatchExportItem[]) {
|
||||
act(() => {
|
||||
root.render(React.createElement(Harness, { items }));
|
||||
});
|
||||
}
|
||||
render(initial);
|
||||
|
||||
return {
|
||||
get latest(): ReturnType<typeof useBatchExport> {
|
||||
if (!latest) throw new Error("hook result not yet captured");
|
||||
return latest;
|
||||
},
|
||||
/** Re-render with a new items list (e.g. to simulate streaming). */
|
||||
rerenderWith(items: BatchExportItem[]) {
|
||||
render(items);
|
||||
},
|
||||
unmount() {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useBatchExport", () => {
|
||||
beforeEach(() => {
|
||||
mockExportBatch837.mockReset();
|
||||
mockDownloadBlob.mockReset();
|
||||
mockToastSuccess.mockReset();
|
||||
mockToastWarning.mockReset();
|
||||
mockToastError.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("auto-selects all 837P claims passed in initially", () => {
|
||||
const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]);
|
||||
expect(h.latest.selectedClaimIds.size).toBe(3);
|
||||
expect(h.latest.total837InStream).toBe(3);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("ignores 835 items when counting / selecting", () => {
|
||||
const h = setup([makeItem("CLM-1"), make835Item("PCN-1")]);
|
||||
expect(h.latest.selectedClaimIds.size).toBe(1);
|
||||
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true);
|
||||
expect(h.latest.total837InStream).toBe(1);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("auto-selects a new 837P claim that streams in", () => {
|
||||
const h = setup([makeItem("CLM-1")]);
|
||||
expect(h.latest.selectedClaimIds.size).toBe(1);
|
||||
h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]);
|
||||
expect(h.latest.selectedClaimIds.size).toBe(2);
|
||||
expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("does not deselect existing claims when a new one streams in", () => {
|
||||
const h = setup([makeItem("CLM-1")]);
|
||||
act(() => {
|
||||
h.latest.onToggleSelect("CLM-1"); // deselect
|
||||
});
|
||||
expect(h.latest.selectedClaimIds.size).toBe(0);
|
||||
h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]);
|
||||
// CLM-1 is still deselected (user's choice is preserved), CLM-2
|
||||
// is auto-selected.
|
||||
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false);
|
||||
expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onToggleSelect adds and removes", () => {
|
||||
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
|
||||
act(() => {
|
||||
h.latest.onToggleSelect("CLM-1"); // toggle off
|
||||
});
|
||||
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false);
|
||||
act(() => {
|
||||
h.latest.onToggleSelect("CLM-1"); // toggle back on
|
||||
});
|
||||
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onToggleAll deselects when everything is selected", () => {
|
||||
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
|
||||
expect(h.latest.selectedClaimIds.size).toBe(2);
|
||||
act(() => {
|
||||
h.latest.onToggleAll();
|
||||
});
|
||||
expect(h.latest.selectedClaimIds.size).toBe(0);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onToggleAll selects everything when partial / none are selected", () => {
|
||||
const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]);
|
||||
act(() => {
|
||||
h.latest.onToggleSelect("CLM-1"); // partial: only CLM-2 and CLM-3
|
||||
});
|
||||
act(() => {
|
||||
h.latest.onToggleSelect("CLM-2");
|
||||
});
|
||||
act(() => {
|
||||
h.latest.onToggleAll();
|
||||
});
|
||||
expect(h.latest.selectedClaimIds.size).toBe(3);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onExport does nothing when no batch id is recorded", async () => {
|
||||
const h = setup([makeItem("CLM-1")]);
|
||||
await h.latest.onExport();
|
||||
expect(mockExportBatch837).not.toHaveBeenCalled();
|
||||
expect(mockToastError).toHaveBeenCalled();
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onExport does nothing when selection is empty (after recordBatchId)", async () => {
|
||||
const h = setup([makeItem("CLM-1")]);
|
||||
h.latest.recordBatchId("server-uuid-123");
|
||||
act(() => {
|
||||
h.latest.onToggleSelect("CLM-1"); // deselect
|
||||
});
|
||||
await h.latest.onExport();
|
||||
expect(mockExportBatch837).not.toHaveBeenCalled();
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onExport calls api.exportBatch837 with the recorded batch id and selected ids, then downloadBlob + success toast", async () => {
|
||||
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
|
||||
h.latest.recordBatchId("server-uuid-456");
|
||||
const exportedBlob = new Blob([new Uint8Array([1, 2, 3])], {
|
||||
type: "application/zip",
|
||||
});
|
||||
mockExportBatch837.mockResolvedValueOnce({
|
||||
blob: exportedBlob,
|
||||
filename: "batch-server-uuid-456-2-claims.zip",
|
||||
serializeErrors: [],
|
||||
});
|
||||
await h.latest.onExport();
|
||||
expect(mockExportBatch837).toHaveBeenCalledWith(
|
||||
"server-uuid-456",
|
||||
expect.arrayContaining(["CLM-1", "CLM-2"]),
|
||||
);
|
||||
expect(mockDownloadBlob).toHaveBeenCalledTimes(1);
|
||||
// Regression: downloadBlob's signature is (filename, blob). An earlier
|
||||
// build had the args swapped, which made URL.createObjectURL receive
|
||||
// the filename string and throw "Overload resolution failed" — pin
|
||||
// the order here so a swap can't sneak back in.
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith(
|
||||
"batch-server-uuid-456-2-claims.zip",
|
||||
exportedBlob,
|
||||
);
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
"Exported 2 claims as X12",
|
||||
expect.objectContaining({ description: "batch-server-uuid-456-2-claims.zip" }),
|
||||
);
|
||||
expect(mockToastWarning).not.toHaveBeenCalled();
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onExport shows a warning toast with serialize-error details on partial failure", async () => {
|
||||
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
|
||||
h.latest.recordBatchId("server-uuid-789");
|
||||
mockExportBatch837.mockResolvedValueOnce({
|
||||
blob: new Blob([new Uint8Array([1])]),
|
||||
filename: "batch-server-uuid-789-1-claims.zip",
|
||||
serializeErrors: [{ claim_id: "CLM-2", reason: "no raw_json" }],
|
||||
});
|
||||
await h.latest.onExport();
|
||||
expect(mockDownloadBlob).toHaveBeenCalledTimes(1);
|
||||
expect(mockToastSuccess).not.toHaveBeenCalled();
|
||||
expect(mockToastWarning).toHaveBeenCalledWith(
|
||||
"Exported 1 · 1 couldn't be regenerated",
|
||||
expect.objectContaining({
|
||||
description: expect.stringContaining("CLM-2: no raw_json"),
|
||||
}),
|
||||
);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onExport shows an error toast and resets exporting=false on network failure", async () => {
|
||||
const h = setup([makeItem("CLM-1")]);
|
||||
h.latest.recordBatchId("server-uuid-abc");
|
||||
mockExportBatch837.mockRejectedValueOnce(new Error("network down"));
|
||||
await h.latest.onExport();
|
||||
expect(mockToastError).toHaveBeenCalledWith("network down");
|
||||
expect(h.latest.exporting).toBe(false);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("exporting is true while the export is in flight, false after", async () => {
|
||||
const h = setup([makeItem("CLM-1")]);
|
||||
h.latest.recordBatchId("server-uuid-xyz");
|
||||
|
||||
let resolveExport: (v: unknown) => void = () => {};
|
||||
mockExportBatch837.mockReturnValueOnce(
|
||||
new Promise((res) => {
|
||||
resolveExport = res;
|
||||
}) as ReturnType<typeof api.exportBatch837>,
|
||||
);
|
||||
|
||||
const exportPromise = h.latest.onExport();
|
||||
// Yield once so the exporting=true state has flushed.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(h.latest.exporting).toBe(true);
|
||||
|
||||
resolveExport({
|
||||
blob: new Blob(),
|
||||
filename: "x.zip",
|
||||
serializeErrors: [],
|
||||
});
|
||||
// The state update in onExport's `finally` block happens on a
|
||||
// microtask after the promise resolves; wrapping the await in
|
||||
// act() flushes it.
|
||||
await act(async () => {
|
||||
await exportPromise;
|
||||
});
|
||||
expect(h.latest.exporting).toBe(false);
|
||||
h.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
// useBatchExport — owns the per-claim selection state and the
|
||||
// "click Export → POST → download ZIP" flow for the Upload page's
|
||||
// streaming view.
|
||||
//
|
||||
// Extracted from Upload.tsx in SP9 (June 2026) so the logic is unit-
|
||||
// testable in isolation from the page (and so the page itself stays
|
||||
// focused on layout). All state is local to the hook — selection is
|
||||
// not persisted across navigations, and the captured `batchId` is the
|
||||
// most-recently-completed parse's server-side id.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import { downloadBlob } from "@/lib/download";
|
||||
|
||||
/** Minimal shape of a streamed claim this hook needs to read the id. */
|
||||
export interface BatchExportItem {
|
||||
kind: "837p" | "835";
|
||||
data: { claim_id?: string; payer_claim_control_number?: string };
|
||||
}
|
||||
|
||||
export interface UseBatchExportResult {
|
||||
/** Currently-selected 837P claim ids. */
|
||||
selectedClaimIds: Set<string>;
|
||||
/** True while an export POST is in flight. */
|
||||
exporting: boolean;
|
||||
/** Total 837P claims currently in the stream. */
|
||||
total837InStream: number;
|
||||
/** Toggle a single claim's selection. */
|
||||
onToggleSelect: (claimId: string) => void;
|
||||
/** Toggle between "all selected" and "none selected" across the stream. */
|
||||
onToggleAll: () => void;
|
||||
/** Fire the export. Returns the promise so tests can await it. */
|
||||
onExport: () => Promise<void>;
|
||||
/**
|
||||
* Called by the parent after a successful parse to record the
|
||||
* server-side batch id. Captured into a ref so a re-parse that
|
||||
* completes mid-export can't swap in a different id under the
|
||||
* export promise.
|
||||
*/
|
||||
recordBatchId: (batchId: string | null) => void;
|
||||
}
|
||||
|
||||
export function useBatchExport(items: BatchExportItem[]): UseBatchExportResult {
|
||||
const [selectedClaimIds, setSelectedClaimIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const lastBatchIdRef = useRef<string | null>(null);
|
||||
// Tracks every claim id that has ever been in the stream. Used by the
|
||||
// auto-select effect to distinguish "new claim, default-select it"
|
||||
// from "user previously deselected this claim — leave it alone".
|
||||
// Without this, a re-render with the same items list would re-add
|
||||
// every claim that the user explicitly deselected.
|
||||
const seenClaimIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Auto-select newly streamed 837P claims.
|
||||
//
|
||||
// Pre-compute the new claim ids OUTSIDE the setState callback so
|
||||
// the ref is mutated exactly once per items.length change and the
|
||||
// setState callback only runs when there's actually something new
|
||||
// to add. The previous inline pattern (mutating the ref inside the
|
||||
// setState callback) failed under React StrictMode's effect
|
||||
// double-invoke: the second invoke saw an empty prev (the first
|
||||
// invoke's queued update was dropped) AND a ref that already
|
||||
// contained the ids, so the second invoke added nothing — and the
|
||||
// selection ended up empty even though the stream populated.
|
||||
useEffect(() => {
|
||||
const newIds: string[] = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === "837p" && item.data.claim_id) {
|
||||
const id = item.data.claim_id;
|
||||
// Only add if we've never seen this id before (so a
|
||||
// re-render with the same items doesn't re-add anything the
|
||||
// user just deselected) AND it's not already selected (no
|
||||
// need to add it again).
|
||||
if (!seenClaimIdsRef.current.has(id)) {
|
||||
seenClaimIdsRef.current.add(id);
|
||||
newIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newIds.length === 0) return;
|
||||
setSelectedClaimIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of newIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
// Key on items.length so a re-render with the same set of items
|
||||
// (just new array identity) doesn't re-run the effect.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items.length]);
|
||||
|
||||
const total837InStream = useMemo(
|
||||
() => items.filter((i) => i.kind === "837p").length,
|
||||
[items],
|
||||
);
|
||||
|
||||
function onToggleSelect(claimId: string) {
|
||||
setSelectedClaimIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(claimId)) {
|
||||
next.delete(claimId);
|
||||
} else {
|
||||
next.add(claimId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function onToggleAll() {
|
||||
const streamed837Ids = items
|
||||
.filter((i): i is BatchExportItem & { data: { claim_id: string } } =>
|
||||
i.kind === "837p" && !!i.data.claim_id,
|
||||
)
|
||||
.map((i) => i.data.claim_id);
|
||||
if (
|
||||
streamed837Ids.length > 0 &&
|
||||
streamed837Ids.every((id) => selectedClaimIds.has(id))
|
||||
) {
|
||||
setSelectedClaimIds(new Set());
|
||||
} else {
|
||||
setSelectedClaimIds(new Set(streamed837Ids));
|
||||
}
|
||||
}
|
||||
|
||||
function recordBatchId(batchId: string | null) {
|
||||
lastBatchIdRef.current = batchId;
|
||||
}
|
||||
|
||||
async function onExport() {
|
||||
const batchId = lastBatchIdRef.current;
|
||||
if (!batchId) {
|
||||
toast.error("No batch to export — parse a file first.");
|
||||
return;
|
||||
}
|
||||
const ids = Array.from(selectedClaimIds);
|
||||
if (ids.length === 0) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const { blob, filename, serializeErrors } =
|
||||
await api.exportBatch837(batchId, ids);
|
||||
downloadBlob(filename, blob);
|
||||
if (serializeErrors.length === 0) {
|
||||
toast.success(`Exported ${ids.length} claims as X12`, {
|
||||
description: filename,
|
||||
});
|
||||
} else {
|
||||
toast.warning(
|
||||
`Exported ${ids.length - serializeErrors.length} · ${serializeErrors.length} couldn't be regenerated`,
|
||||
{
|
||||
description: serializeErrors
|
||||
.map((e) => `${e.claim_id}: ${e.reason}`)
|
||||
.join("\n"),
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectedClaimIds,
|
||||
exporting,
|
||||
total837InStream,
|
||||
onToggleSelect,
|
||||
onToggleAll,
|
||||
onExport,
|
||||
recordBatchId,
|
||||
};
|
||||
}
|
||||
+48
-17
@@ -127,36 +127,49 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
/* Subtle "lit from above" wash — gives every page a slightly
|
||||
lighter zone in the upper third without lifting the base
|
||||
color. A vertical gradient + a soft top-center halo, both
|
||||
very low opacity. The composition is additive: a soft glow
|
||||
behind the page header, fading to nothing well before the
|
||||
fold. Pointer-events unaffected (it's a background). */
|
||||
background-image:
|
||||
radial-gradient(ellipse 90% 38% at 50% -6%, hsla(220, 28%, 26%, 0.22), transparent 62%),
|
||||
linear-gradient(180deg, hsla(220, 22%, 14%, 0.30) 0%, hsla(220, 22%, 8%, 0.10) 22%, transparent 42%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* A single, precise light source anchored top-right.
|
||||
One light, not two — an instrument, not a wash. */
|
||||
/* A precise top-right accent + a complementary soft top-center
|
||||
softbox. Two light sources, both very subtle — the result reads
|
||||
as "the chrome is being lit from above" rather than "there's a
|
||||
gradient in the background". The softbox adds the small lift
|
||||
the cards sit in; the accent preserves the instrument character. */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background: radial-gradient(
|
||||
55rem 35rem at 100% -15%,
|
||||
hsla(212, 100%, 60%, 0.07),
|
||||
transparent 62%
|
||||
);
|
||||
background:
|
||||
radial-gradient(ellipse 70% 38% at 50% 0%, hsla(220, 32%, 28%, 0.16), transparent 65%),
|
||||
radial-gradient(55rem 35rem at 100% -15%, hsla(212, 100%, 60%, 0.09), transparent 62%);
|
||||
}
|
||||
|
||||
/* Hairline grid — the substrate of a precision instrument.
|
||||
Sits behind everything, gives the dark surfaces a sense of
|
||||
scale and the work a sense of "field". */
|
||||
scale and the work a sense of "field". Slightly more present
|
||||
than before so the texture actually reads against the softer
|
||||
top wash. */
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
opacity: 0.6;
|
||||
background-image:
|
||||
linear-gradient(to right, hsl(222 10% 16% / 0.18) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(222 10% 16% / 0.18) 1px, transparent 1px);
|
||||
linear-gradient(to right, hsl(222 10% 18% / 0.20) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(222 10% 18% / 0.20) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%);
|
||||
@@ -226,22 +239,40 @@
|
||||
}
|
||||
|
||||
/* Soft card surface — a 1px hairline + a 1px inner highlight to
|
||||
give the surface a sense of depth without a heavy shadow. */
|
||||
give the surface a sense of depth without a heavy shadow.
|
||||
The vertical gradient (lighter at top, darker at bottom) plus
|
||||
a slightly more present inner highlight makes the card read
|
||||
as a subtle "lit from above" panel — each card becomes its
|
||||
own small lighter area without lifting the base card color. */
|
||||
.surface {
|
||||
background-color: hsl(var(--card));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(222 16% 10.5%) 0%,
|
||||
hsl(222 16% 8.5%) 55%,
|
||||
hsl(222 16% 7.5%) 100%
|
||||
);
|
||||
border: 1px solid hsl(var(--border));
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 hsl(0 0% 100% / 0.04),
|
||||
inset 0 1px 0 0 hsl(0 0% 100% / 0.06),
|
||||
0 1px 2px 0 hsl(0 0% 0% / 0.4);
|
||||
}
|
||||
|
||||
/* Subtle elevated surface — used for cards that should lift a
|
||||
little more than the default. */
|
||||
little more than the default. Same "lit from above" treatment
|
||||
as .surface, with a touch more lift in the gradient stops and
|
||||
a more visible inner highlight. The card body becomes a
|
||||
slightly more present lighter area than the surrounding
|
||||
chrome. */
|
||||
.surface-2 {
|
||||
background: linear-gradient(180deg, hsl(222 16% 9%) 0%, hsl(222 16% 7.5%) 100%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(222 16% 11%) 0%,
|
||||
hsl(222 16% 9%) 55%,
|
||||
hsl(222 16% 7.5%) 100%
|
||||
);
|
||||
border: 1px solid hsl(var(--border));
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 hsl(0 0% 100% / 0.05),
|
||||
inset 0 1px 0 0 hsl(0 0% 100% / 0.08),
|
||||
0 1px 3px 0 hsl(0 0% 0% / 0.5);
|
||||
}
|
||||
|
||||
|
||||
@@ -217,4 +217,139 @@ describe("api GET helpers", () => {
|
||||
);
|
||||
await expect(api.serializeClaim837("ghost")).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
|
||||
it("exportBatch837 returns the blob and filename from a 200 ZIP response", async () => {
|
||||
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // ZIP magic
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(zipBytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/zip",
|
||||
"content-disposition":
|
||||
'attachment; filename="batch-abc123-3-claims.zip"',
|
||||
},
|
||||
})
|
||||
);
|
||||
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
|
||||
expect(out.filename).toBe("batch-abc123-3-claims.zip");
|
||||
expect(out.blob).toBeInstanceOf(Blob);
|
||||
// POST to the batch-scoped endpoint with claim_ids in the body.
|
||||
const called = mockFetch.mock.calls[0][0] as string;
|
||||
expect(called).toContain("/api/batches/abc123/export-837");
|
||||
const init = mockFetch.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe("POST");
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body).toEqual({ claim_ids: ["CLM-1", "CLM-2", "CLM-3"] });
|
||||
});
|
||||
|
||||
it("exportBatch837 parses the X-Cyclone-Serialize-Errors header into serializeErrors", async () => {
|
||||
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
|
||||
const errs = [
|
||||
{ claim_id: "CLM-2", reason: "no raw_json" },
|
||||
{ claim_id: "CLM-3", reason: "raw_json invalid: ..." },
|
||||
];
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(zipBytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/zip",
|
||||
"content-disposition":
|
||||
'attachment; filename="batch-abc123-1-claims.zip"',
|
||||
"x-cyclone-serialize-errors": JSON.stringify(errs),
|
||||
},
|
||||
})
|
||||
);
|
||||
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
|
||||
expect(out.serializeErrors).toEqual(errs);
|
||||
});
|
||||
|
||||
it("exportBatch837 returns an empty serializeErrors array when the header is missing", async () => {
|
||||
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(zipBytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/zip",
|
||||
"content-disposition": 'attachment; filename="batch-x-1-claims.zip"',
|
||||
},
|
||||
})
|
||||
);
|
||||
const out = await api.exportBatch837("x", ["CLM-1"]);
|
||||
expect(out.serializeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
it("exportBatch837 falls back to a default filename when Content-Disposition is missing", async () => {
|
||||
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(zipBytes, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/zip" },
|
||||
})
|
||||
);
|
||||
const out = await api.exportBatch837("xyz", ["CLM-1"]);
|
||||
expect(out.filename).toBe("batch-xyz-claims.zip");
|
||||
});
|
||||
|
||||
it("exportBatch837 throws ApiError on 404", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ error: "Not found", detail: "unknown batch: ghost" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } }
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
api.exportBatch837("ghost", ["CLM-1"])
|
||||
).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
|
||||
it("exportBatch837 throws on empty claim_ids without making a request", async () => {
|
||||
await expect(api.exportBatch837("any", [])).rejects.toThrow();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getBatchDiff surfaces a clear ApiError when a backend 400 wraps detail in a nested object", async () => {
|
||||
// The backend can wrap structured errors as
|
||||
// `{"detail": {"error": "Missing param", "detail": "..."}}`. The
|
||||
// client must walk into the nested `detail` so the user sees the
|
||||
// human-readable message rather than a blob of raw JSON.
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
detail: {
|
||||
error: "Missing param",
|
||||
detail: "Both ?a=<batch_id> and ?b=<batch_id> are required.",
|
||||
},
|
||||
}),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
)
|
||||
);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "930f25e07eab4ac0908c9770689385e1");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(ApiError);
|
||||
expect((caught as ApiError).status).toBe(400);
|
||||
// The unwrapped message must surface — not the raw JSON.
|
||||
expect((caught as ApiError).message).toBe(
|
||||
"Both ?a=<batch_id> and ?b=<batch_id> are required."
|
||||
);
|
||||
});
|
||||
|
||||
it("getBatchDiff throws ApiError(400) before hitting the network if either id is empty", async () => {
|
||||
// Defense-in-depth: the hook's `enabled` guard should prevent this
|
||||
// from ever firing, but if it ever does, the client must refuse to
|
||||
// make a malformed request and surface a clear local error.
|
||||
await expect(api.getBatchDiff("", "930f25e07eab4ac0908c9770689385e1")).rejects.toMatchObject({
|
||||
status: 400,
|
||||
message: expect.stringContaining("?a=<batch_id>"),
|
||||
});
|
||||
await expect(api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "")).rejects.toMatchObject({
|
||||
status: 400,
|
||||
message: expect.stringContaining("?b=<batch_id>"),
|
||||
});
|
||||
// Network must not be touched.
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+96
-5
@@ -175,12 +175,24 @@ async function readErrorBody(res: Response): Promise<string> {
|
||||
try {
|
||||
const t = await res.text();
|
||||
if (!t) return "";
|
||||
// FastAPI errors are `{ "error": "...", "detail": "..." }`. Surface the
|
||||
// detail if present, else the raw text.
|
||||
// FastAPI errors can be flat `{ "error": "...", "detail": "..." }`
|
||||
// or wrapped as `{ "detail": { "error": "...", "detail": "..." } }`
|
||||
// (the wrapper our app uses for structured errors). Walk into nested
|
||||
// `detail` objects until we hit a string field — guards against the
|
||||
// raw JSON leaking into the user-visible error message.
|
||||
try {
|
||||
const obj = JSON.parse(t) as { detail?: unknown; error?: unknown };
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
if (typeof obj.error === "string") return obj.error;
|
||||
let current: unknown = JSON.parse(t);
|
||||
for (let depth = 0; depth < 5; depth++) {
|
||||
if (!current || typeof current !== "object") break;
|
||||
const obj = current as { detail?: unknown; error?: unknown };
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
if (typeof obj.error === "string") return obj.error;
|
||||
if (obj.detail && typeof obj.detail === "object") {
|
||||
current = obj.detail;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// not JSON; fall through
|
||||
}
|
||||
@@ -558,6 +570,16 @@ async function getRemittance<T = unknown>(id: string): Promise<T> {
|
||||
*/
|
||||
async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
// Defense-in-depth: even though the `useBatchDiff` hook gates on both
|
||||
// ids being non-empty, refuse to fire the request if either is
|
||||
// missing. Surfaces a clear local error instead of letting the
|
||||
// backend return its generic "Missing param" 400.
|
||||
if (typeof a !== "string" || a.length === 0) {
|
||||
throw new ApiError(400, "Missing param: ?a=<batch_id> is required.");
|
||||
}
|
||||
if (typeof b !== "string" || b.length === 0) {
|
||||
throw new ApiError(400, "Missing param: ?b=<batch_id> is required.");
|
||||
}
|
||||
const res = await fetch(
|
||||
joinUrl(
|
||||
`/api/batch-diff?${qs({ a, b })}`,
|
||||
@@ -786,6 +808,74 @@ async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
|
||||
return { ...mapAck(row), rawJson: row.raw_json };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a ZIP of regenerated X12 837 files for a parsed batch.
|
||||
*
|
||||
* Drives `POST /api/batches/{batchId}/export-837` with the requested
|
||||
* claim_ids in the body. The backend returns a binary ZIP whose
|
||||
* entries follow the HCPF X12 File Naming Standards template
|
||||
* ``{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (one file per
|
||||
* successfully serialized claim, with a per-claim millisecond offset
|
||||
* so every entry has a unique 17-digit timestamp). The ZIP itself is
|
||||
* named ``batch-{batchId}-{N}-claims.zip`` via Content-Disposition,
|
||||
* where N is the success count.
|
||||
*
|
||||
* Per-claim serialization failures are surfaced via the
|
||||
* `X-Cyclone-Serialize-Errors` response header (JSON-encoded array of
|
||||
* `{claim_id, reason}`). The ZIP still contains the successful claims;
|
||||
* the failures are returned alongside so the UI can show a partial-
|
||||
* success toast like "Exported 18 · 2 couldn't be regenerated".
|
||||
*
|
||||
* Throws `ApiError` on non-2xx — 404 (batch missing) is the most
|
||||
* likely case. The client-side guard rejects empty `claimIds` without
|
||||
* making a request.
|
||||
*/
|
||||
export interface BatchExportResult {
|
||||
blob: Blob;
|
||||
filename: string;
|
||||
serializeErrors: Array<{ claim_id: string; reason: string }>;
|
||||
}
|
||||
|
||||
async function exportBatch837(
|
||||
batchId: string,
|
||||
claimIds: string[],
|
||||
): Promise<BatchExportResult> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
if (claimIds.length === 0) {
|
||||
throw new Error("claimIds is empty");
|
||||
}
|
||||
const res = await fetch(
|
||||
joinUrl(`/api/batches/${encodeURIComponent(batchId)}/export-837`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/zip",
|
||||
},
|
||||
body: JSON.stringify({ claim_ids: claimIds }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorBody(res);
|
||||
throw new ApiError(res.status, detail || res.statusText);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get("content-disposition") ?? "";
|
||||
const match = /filename="?([^";]+)"?/i.exec(cd);
|
||||
const filename = match?.[1] ?? `batch-${batchId}-claims.zip`;
|
||||
const errHeader = res.headers.get("x-cyclone-serialize-errors");
|
||||
let serializeErrors: Array<{ claim_id: string; reason: string }> = [];
|
||||
if (errHeader) {
|
||||
try {
|
||||
serializeErrors = JSON.parse(errHeader);
|
||||
} catch {
|
||||
// Malformed header — treat as empty rather than failing the download.
|
||||
serializeErrors = [];
|
||||
}
|
||||
}
|
||||
return { blob, filename, serializeErrors };
|
||||
}
|
||||
|
||||
export const api = {
|
||||
isConfigured,
|
||||
baseUrl: BASE_URL,
|
||||
@@ -799,6 +889,7 @@ export const api = {
|
||||
listClaims,
|
||||
getClaimDetail,
|
||||
serializeClaim837,
|
||||
exportBatch837,
|
||||
listRemittances,
|
||||
getRemittance,
|
||||
listProviders,
|
||||
|
||||
+126
-7
@@ -9,13 +9,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Acks } from "./Acks";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
isConfigured: true,
|
||||
listAcks: vi.fn(),
|
||||
getAck: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
isConfigured: true,
|
||||
listAcks: vi.fn(),
|
||||
getAck: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function renderIntoContainer(element: React.ReactElement): {
|
||||
container: HTMLDivElement;
|
||||
@@ -105,6 +109,10 @@ function hasExactlyOneSelectedRow(): boolean {
|
||||
describe("Acks", () => {
|
||||
beforeEach(() => {
|
||||
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 () => {
|
||||
@@ -448,4 +456,115 @@ describe("Acks", () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
+403
-693
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
isConfigured: true,
|
||||
listActivity: vi.fn(),
|
||||
getRemittance: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -158,6 +159,14 @@ describe("ActivityLog page filters", () => {
|
||||
(api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
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 () => {
|
||||
@@ -447,3 +456,104 @@ describe("ActivityLog page filters", () => {
|
||||
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 { useSearchParams } from "react-router-dom";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { useActivity } from "@/hooks/useActivity";
|
||||
import { useTailStream } from "@/hooks/useTailStream";
|
||||
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||
import { eventKindToUrl } from "@/lib/event-routing";
|
||||
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { ActivityFeed } from "@/components/ActivityFeed";
|
||||
import { ActivityFilters, type SinceValue } from "@/components/ActivityFilters";
|
||||
import { RemitDrawer } from "@/components/RemitDrawer";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
@@ -35,6 +39,13 @@ function useSinceIso(since: SinceValue): string | undefined {
|
||||
|
||||
export function ActivityLog() {
|
||||
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[]>(
|
||||
() =>
|
||||
@@ -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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock("@/lib/api", () => ({
|
||||
isConfigured: true,
|
||||
listBatches: vi.fn(),
|
||||
getBatchDiff: vi.fn(),
|
||||
getRemittance: vi.fn(),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
@@ -180,6 +181,13 @@ function makeDiffPayload(): BatchDiffResponse {
|
||||
describe("BatchDiff page", () => {
|
||||
beforeEach(() => {
|
||||
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(() => {
|
||||
@@ -189,6 +197,42 @@ describe("BatchDiff page", () => {
|
||||
.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 () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
BATCH_A, BATCH_B,
|
||||
@@ -437,8 +481,184 @@ describe("BatchDiff page", () => {
|
||||
).toContain("pick a batch");
|
||||
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
|
||||
// vitest's transformer when the mock factory above is hoisted.
|
||||
void ApiError;
|
||||
|
||||
+378
-573
File diff suppressed because it is too large
Load Diff
+159
-491
@@ -4,7 +4,9 @@ import { Files, GitBranch, Layers, Receipt } from "lucide-react";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { KpiCard } from "@/components/KpiCard";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { BatchesList, BatchesListSkeleton } from "@/components/BatchesList";
|
||||
import {
|
||||
BatchDetailContent,
|
||||
@@ -16,6 +18,7 @@ import { useBatches } from "@/hooks/useBatches";
|
||||
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
|
||||
import { api, ApiError, type BatchSummary } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fmt } from "@/lib/format";
|
||||
import type { ParseResult837, ParseResult835 } from "@/types";
|
||||
|
||||
function readBatchId(): string | null {
|
||||
@@ -120,12 +123,16 @@ function useBatchDetail(id: string | null): {
|
||||
const HELP_NOOP = () => {};
|
||||
|
||||
/**
|
||||
* Batches page — hybrid Magazine Spread treatment.
|
||||
* Batches page — dark instrument treatment, in the same chrome
|
||||
* language as Dashboard / Upload / Reconciliation / Acks.
|
||||
*
|
||||
* 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).
|
||||
* PageHeader hero (eyebrow + display title + status pill + ghost
|
||||
* watermark) → KpiCard strip (4 tiles) → single Card wrapping the
|
||||
* tabular register → quiet hairline footer.
|
||||
*
|
||||
* Click a row to open the detail drawer (rendered above the page,
|
||||
* dimmed via the body opacity). Keyboard: ← / → step through
|
||||
* batches while the drawer is open; Esc closes.
|
||||
*/
|
||||
export function Batches() {
|
||||
const { data, isLoading, isError, error, refetch } = useBatches(100);
|
||||
@@ -166,7 +173,7 @@ export function Batches() {
|
||||
const errKind = batchErrorKind(detailError);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Aggregate metrics for the § 01 KPI strip
|
||||
// Aggregate metrics for the KPI strip + hero copy.
|
||||
// -----------------------------------------------------------------
|
||||
const totals = items.reduce(
|
||||
(acc, b) => ({
|
||||
@@ -178,14 +185,20 @@ export function Batches() {
|
||||
{ count: 0, p837: 0, p835: 0, claims: 0 },
|
||||
);
|
||||
|
||||
const lastParsed = items[0]?.parsedAt;
|
||||
|
||||
// The ghost watermark carries the on-file count, scaled like the
|
||||
// other pages (clamp 72–140px, 4.5% opacity, right-anchored).
|
||||
const watermark = totals.count > 0 ? totals.count.toLocaleString() : "BATCH";
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Stagger choreography (matches Acks / Reconciliation / Upload)
|
||||
// Stagger choreography — hero lands first, KPI strip at 140ms,
|
||||
// register at 240ms, footer at 320ms. Total < 500ms.
|
||||
// -----------------------------------------------------------------
|
||||
const heroDelay = 0;
|
||||
const foldDelay = 220;
|
||||
const titleDelay = 320;
|
||||
const kpiDelay = 460;
|
||||
const tableDelay = 600;
|
||||
const kpiDelay = 140;
|
||||
const tableDelay = 240;
|
||||
const footerDelay = 320;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -219,541 +232,196 @@ export function Batches() {
|
||||
<div
|
||||
data-testid="batches-page-body"
|
||||
className={cn(
|
||||
"space-y-0 animate-fade-in transition-opacity",
|
||||
"space-y-6 lg:space-y-10 animate-fade-in transition-opacity",
|
||||
dimBackground && "pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
{/* =================================================================
|
||||
HERO — DARK EDITORIAL HEADER
|
||||
HERO — dark editorial header
|
||||
================================================================= */}
|
||||
<section
|
||||
className="relative pt-6 pb-8 lg:pt-9 lg:pb-10"
|
||||
className="relative animate-fade-in overflow-hidden"
|
||||
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"
|
||||
className="pointer-events-none select-none absolute -right-4 top-1/2 -translate-y-1/2 whitespace-nowrap display text-foreground"
|
||||
style={{
|
||||
fontSize: "clamp(160px, 20vw, 300px)",
|
||||
letterSpacing: "-0.05em",
|
||||
opacity: 0.05,
|
||||
fontSize: "clamp(72px, 12vw, 140px)",
|
||||
letterSpacing: "-0.04em",
|
||||
opacity: 0.045,
|
||||
lineHeight: 1,
|
||||
color: "hsl(var(--surface-ink))",
|
||||
}}
|
||||
>
|
||||
PARSED
|
||||
{watermark}
|
||||
</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 className="relative z-10 flex items-end justify-between gap-4 sm:gap-6 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<PageHeader
|
||||
eyebrow={`Batches · Register · ${totals.count} on file`}
|
||||
title={
|
||||
<>
|
||||
Parsed{" "}
|
||||
<span className="italic text-muted-foreground/85">
|
||||
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.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</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 className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3 py-1.5 text-[11px] mono uppercase tracking-[0.12em] text-muted-foreground backdrop-blur">
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[hsl(var(--accent))]">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--accent))] opacity-60 animate-ping" />
|
||||
</span>
|
||||
<Files className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
837P · 835 · envelope & summary
|
||||
</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
|
||||
KPI STRIP — four standard KpiCards, accent tokens instead of
|
||||
paper-tinted custom tiles.
|
||||
================================================================= */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative h-14"
|
||||
style={{ animationDelay: `${foldDelay}ms` }}
|
||||
<section
|
||||
aria-label="Batch register metrics"
|
||||
className="grid gap-3.5 grid-cols-2 md:grid-cols-4 animate-fade-in-up"
|
||||
style={{ animationDelay: `${kpiDelay}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)",
|
||||
}}
|
||||
<KpiCard
|
||||
label="On file"
|
||||
value={fmt.num(totals.count)}
|
||||
icon={Layers}
|
||||
accent="default"
|
||||
hint={totals.count === 1 ? "batch" : "batches"}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-1/2"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, hsl(30 14% 14% / 0.18), transparent)",
|
||||
}}
|
||||
<KpiCard
|
||||
label="837P"
|
||||
value={fmt.num(totals.p837)}
|
||||
icon={GitBranch}
|
||||
accent="accent"
|
||||
hint="professional claims"
|
||||
/>
|
||||
<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)",
|
||||
}}
|
||||
<KpiCard
|
||||
label="835"
|
||||
value={fmt.num(totals.p835)}
|
||||
icon={Receipt}
|
||||
accent="warning"
|
||||
hint="remittance advice"
|
||||
/>
|
||||
<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>
|
||||
<KpiCard
|
||||
label="Claims"
|
||||
value={fmt.num(totals.claims)}
|
||||
accent="success"
|
||||
hint="total in register"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* =================================================================
|
||||
PAPER PLANE
|
||||
REGISTER — single Card wrapping the tabular list. Same
|
||||
chrome language as the rest of the app: eyebrow + display
|
||||
title + sidecar hint, dark-tone table.
|
||||
================================================================= */}
|
||||
<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)",
|
||||
}}
|
||||
<section
|
||||
aria-label="Batch register"
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${tableDelay}ms` }}
|
||||
>
|
||||
{/* 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))" }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-6 lg:p-7 space-y-5">
|
||||
<div className="flex items-end justify-between gap-6 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<div className="eyebrow flex items-center gap-2 mb-2">
|
||||
<span className="inline-block h-px w-6 bg-foreground/20" />
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
|
||||
All <span className="italic">ingests</span>, newest first.
|
||||
</h3>
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="text-[12.5px] display italic"
|
||||
style={{ color: "hsl(var(--surface-ink-2))", maxWidth: 380 }}
|
||||
>
|
||||
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
|
||||
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 className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80 mono text-[10.5px] uppercase tracking-[0.14em]">
|
||||
←
|
||||
</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 className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80 mono text-[10.5px] uppercase tracking-[0.14em]">
|
||||
→
|
||||
</kbd>{" "}
|
||||
inside the drawer to step.
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<div
|
||||
className="rounded-md border p-5"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
>
|
||||
<div className="pt-5 border-t border-border/40">
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message="Couldn't load batches from the backend."
|
||||
detail={error instanceof Error ? error.message : String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div
|
||||
className="rounded-md border p-4 space-y-2"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
>
|
||||
) : isLoading ? (
|
||||
<BatchesListSkeleton />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div
|
||||
className="rounded-md border p-10 text-center"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.16)",
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: "hsl(36 22% 96%)",
|
||||
}}
|
||||
>
|
||||
<EmptyState
|
||||
eyebrow="Batches · awaiting first ingest"
|
||||
message="Upload an 837P or 835 file on the Upload page to populate this list."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-md border overflow-hidden"
|
||||
style={{
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
backgroundColor: "hsl(36 22% 98%)",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
|
||||
}}
|
||||
>
|
||||
<BatchesList
|
||||
items={items}
|
||||
openId={batchId}
|
||||
onOpen={open}
|
||||
tone="paper"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/60 bg-card/40">
|
||||
<EmptyState
|
||||
eyebrow="Batches · awaiting first ingest"
|
||||
message="Upload an 837P or 835 file on the Upload page to populate this list."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 overflow-hidden bg-card/40">
|
||||
<BatchesList
|
||||
items={items}
|
||||
openId={batchId}
|
||||
onOpen={open}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
{/* =================================================================
|
||||
FOOTER — single hairline-separated status row. Replaces the
|
||||
warm-paper "End of register" treatment with a quiet
|
||||
instrument-style status line in the same rhythm as the
|
||||
other pages.
|
||||
================================================================= */}
|
||||
<section
|
||||
aria-label="Register status"
|
||||
className="animate-fade-in-up flex items-center justify-between gap-4 flex-wrap pt-5 border-t border-border/40"
|
||||
style={{ animationDelay: `${footerDelay}ms` }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--accent))]" />
|
||||
Cyclone · Batches
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
|
||||
<span>
|
||||
{totals.count} {totals.count === 1 ? "row" : "rows"}
|
||||
</span>
|
||||
<span className="text-muted-foreground/30" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span>last parsed {lastParsed ? fmt.dateShort(lastParsed) : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
|
||||
<span>open drawer</span>
|
||||
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80">
|
||||
↩
|
||||
</kbd>
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+32
-15
@@ -10,6 +10,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Claims } from "./Claims";
|
||||
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||
import { api } from "@/lib/api";
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
import type { Claim, ClaimDetail } from "@/types";
|
||||
@@ -194,7 +195,16 @@ function renderClaimsAt(initialEntries: string[]): {
|
||||
React.createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries },
|
||||
React.createElement(Claims),
|
||||
// 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -313,17 +323,19 @@ describe("Claims page drawer wiring", () => {
|
||||
document.body.querySelector('[data-testid="claim-drawer"]')
|
||||
).not.toBeNull();
|
||||
|
||||
// Header shows the claim id — proves the id propagated end-to-end
|
||||
// (URL → useDrawerUrlState → ClaimDrawer prop → useClaimDetail →
|
||||
// header render), not just that some drawer is mounted.
|
||||
await settle(
|
||||
() =>
|
||||
document.body.querySelector('[data-testid="header-id"]')?.textContent ===
|
||||
"CLM-1"
|
||||
);
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="header-id"]')?.textContent
|
||||
).toBe("CLM-1");
|
||||
// SP21 Phase 5 Task 5.10: the claim id is now the title of the
|
||||
// shared DrillDrawerHeader (rendered as an <h2>). Find the h2
|
||||
// inside the drawer and assert its text matches the claim id —
|
||||
// proves the id propagated end-to-end (URL → useDrawerUrlState
|
||||
// → ClaimDrawer prop → useClaimDetail → header render), not
|
||||
// just that some drawer is mounted.
|
||||
await settle(() => {
|
||||
const drawer = document.body.querySelector('[data-testid="claim-drawer"]');
|
||||
const h2 = drawer?.querySelector("h2");
|
||||
return h2?.textContent === "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 () => {
|
||||
@@ -360,9 +372,14 @@ describe("Claims page drawer wiring", () => {
|
||||
|
||||
// Wait for the header to render — the close button only mounts once
|
||||
// `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(
|
||||
() => 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.
|
||||
@@ -372,7 +389,7 @@ describe("Claims page drawer wiring", () => {
|
||||
// onClose → useDrawerUrlState.close(), which pushState's a URL with
|
||||
// the ?claim= param stripped.
|
||||
const closeBtn = document.body.querySelector(
|
||||
'[data-testid="header-close"]'
|
||||
'[data-testid="claim-drawer"] button[aria-label="Close drawer"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(closeBtn).not.toBeNull();
|
||||
await act(async () => {
|
||||
|
||||
+301
-4
@@ -1,10 +1,60 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
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 * as inboxApi from "@/lib/inbox-api";
|
||||
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(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
@@ -29,7 +79,7 @@ describe("Inbox page", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const { container } = render(<Inbox />);
|
||||
const { container } = renderInbox();
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain("REJECTED");
|
||||
expect(container.textContent).toContain("PAYER REJECTED");
|
||||
@@ -65,7 +115,7 @@ describe("Inbox page", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const { container } = render(<Inbox />);
|
||||
const { container } = renderInbox();
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain("1");
|
||||
expect(container.textContent).toContain("items need eyes");
|
||||
@@ -112,7 +162,7 @@ describe("Inbox page", () => {
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { container, getByTestId } = render(<Inbox />);
|
||||
const { container, getByTestId } = renderInbox();
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain("PR1");
|
||||
});
|
||||
@@ -202,7 +252,7 @@ describe("Inbox page", () => {
|
||||
.spyOn(downloadModule, "downloadBlob")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const { container, getByText } = render(<Inbox />);
|
||||
const { container, getByText } = renderInbox();
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain("C1");
|
||||
expect(container.textContent).toContain("C2");
|
||||
@@ -245,4 +295,251 @@ describe("Inbox page", () => {
|
||||
expect(filename).toBe("resubmit-2-claims.zip");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
+205
-111
@@ -13,10 +13,13 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Lane, type LaneRow } from "@/components/inbox/Lane";
|
||||
import { InboxHeader } from "@/components/inbox/InboxHeader";
|
||||
import { BulkBar } from "@/components/inbox/BulkBar";
|
||||
import { RemitDrawer } from "@/components/RemitDrawer";
|
||||
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||
import { useInboxLanes } from "@/hooks/useInboxLanes";
|
||||
import {
|
||||
exportInboxCsvUrl,
|
||||
@@ -40,6 +43,12 @@ function rowKey(row: LaneRow): string {
|
||||
|
||||
export default function Inbox() {
|
||||
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[]>>({
|
||||
rejected: [],
|
||||
payer_rejected: [],
|
||||
@@ -215,62 +224,65 @@ export default function Inbox() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
className="min-h-screen relative"
|
||||
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
|
||||
>
|
||||
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
|
||||
|
||||
{/* Fold — a thin amber rule + italic serif annotation that
|
||||
transitions the editorial header into the lane grid below. */}
|
||||
{/* Ambient halo — a soft, fixed radial that creates a subtle
|
||||
lighter zone behind the InboxHeader. The inbox has its own
|
||||
dark ticker-tape background so it doesn't receive the body
|
||||
wash; this overlay restores the "lit from above" feel that
|
||||
the main pages get from the body::before softbox, in the
|
||||
same warm-amber accent family as the rest of the surface.
|
||||
Pointer-events disabled. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative h-10 flex items-center"
|
||||
style={{ background: "var(--tt-bg)" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, var(--tt-amber) 12%, var(--tt-amber) 88%, transparent)",
|
||||
opacity: 0.35,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="relative z-10 mx-auto px-3 flex items-center gap-2"
|
||||
style={{ background: "var(--tt-bg)" }}
|
||||
>
|
||||
<span
|
||||
className="display italic"
|
||||
style={{ color: "var(--tt-amber)", fontSize: 14 }}
|
||||
>
|
||||
↘
|
||||
</span>
|
||||
<span
|
||||
className="mono uppercase"
|
||||
style={{
|
||||
color: "var(--tt-ink-dim)",
|
||||
fontSize: 10.5,
|
||||
letterSpacing: "0.24em",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Five lanes · one queue
|
||||
</span>
|
||||
<span
|
||||
className="display italic"
|
||||
style={{ color: "var(--tt-amber)", fontSize: 14 }}
|
||||
>
|
||||
↙
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
className="pointer-events-none fixed inset-x-0 top-0 z-0 h-[55vh]"
|
||||
style={{
|
||||
background: `
|
||||
radial-gradient(ellipse 60% 38% at 50% 0%, hsla(36, 60%, 30%, 0.10), transparent 65%),
|
||||
radial-gradient(ellipse 35% 28% at 88% 8%, hsla(36, 70%, 50%, 0.07), transparent 60%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
{/* 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} />
|
||||
|
||||
{/* Lane grid transitions directly into the summary strip below.
|
||||
The previous "fold" divider with italic serif arrows and
|
||||
"Five lanes · one queue" caption was the page's last warm-
|
||||
paper print-shop artifact — removed so the ticker-tape
|
||||
aesthetic carries all the way through. */}
|
||||
|
||||
<main className="px-6 lg:px-10 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
|
||||
<Lane
|
||||
name="REJECTED"
|
||||
accent="oxblood"
|
||||
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)}
|
||||
/>
|
||||
{/*
|
||||
@@ -283,91 +295,158 @@ export default function Inbox() {
|
||||
name="PAYER REJECTED"
|
||||
accent="oxblood"
|
||||
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)}
|
||||
/>
|
||||
<Lane
|
||||
name="CANDIDATES"
|
||||
accent="amber"
|
||||
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)}
|
||||
/>
|
||||
<Lane
|
||||
name="UNMATCHED"
|
||||
accent="ink-blue"
|
||||
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)}
|
||||
/>
|
||||
<Lane
|
||||
name="DONE"
|
||||
accent="muted"
|
||||
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)}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* ----------------------------------------------------------------
|
||||
PAPER-TONED SUMMARY STRIP
|
||||
A cream "ledger" panel below the lanes that shows the aggregate
|
||||
eye-flow across the queue. Mirrors the "Magazine Spread" pattern
|
||||
used on the Dashboard/Claims/etc pages so the Inbox feels part
|
||||
of the same document set, not a stand-alone terminal.
|
||||
TICKER-TAPE QUEUE SUMMARY
|
||||
A dark terminal panel below the lanes that aggregates the
|
||||
eye-flow across the queue. Sits on the same --tt-bg-elev
|
||||
surface as the Lane cards so the page reads as one continuous
|
||||
instrument instead of mixing a paper-toned "ledger" panel
|
||||
into the terminal aesthetic.
|
||||
---------------------------------------------------------------- */}
|
||||
<section
|
||||
aria-label="Queue summary"
|
||||
className="relative mx-6 lg:mx-10 mt-6 mb-4"
|
||||
className="relative mx-6 lg:mx-10 mt-5 mb-6"
|
||||
>
|
||||
<div
|
||||
className="relative rounded-xl overflow-hidden border"
|
||||
className="relative rounded-md overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: "hsl(var(--surface))",
|
||||
borderColor: "hsl(30 14% 14% / 0.10)",
|
||||
// "Lit from above" gradient — same treatment as the lane
|
||||
// cards and the main .surface panels, so the queue
|
||||
// summary reads as a slightly lifted, subtly lighter
|
||||
// surface against the ticker-tape backdrop.
|
||||
background:
|
||||
"linear-gradient(180deg, hsl(220 18% 11.5%) 0%, hsl(220 18% 9.5%) 55%, hsl(220 18% 8.5%) 100%)",
|
||||
border: "1px solid hsl(220 8% 18%)",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 12px 40px -16px hsl(0 0% 0% / 0.45)",
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
|
||||
}}
|
||||
>
|
||||
{/* Title row */}
|
||||
{/* Header row — machine-tag eyebrow + editorial headline +
|
||||
aggregate Need eyes count, separated from the lane metrics
|
||||
by a hairline. */}
|
||||
<div
|
||||
className="px-5 lg:px-7 py-4 border-b flex items-center justify-between gap-4 flex-wrap"
|
||||
style={{ borderColor: "hsl(30 14% 14% / 0.10)" }}
|
||||
className="px-5 lg:px-6 py-3.5 flex items-center justify-between gap-4 flex-wrap"
|
||||
style={{ borderBottom: "1px solid hsl(220 8% 16%)" }}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="mono text-[10.5px] uppercase tracking-[0.20em] font-semibold mb-1.5"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
>
|
||||
Queue ledger
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2.5 mb-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
|
||||
style={{ background: "var(--tt-amber)" }}
|
||||
/>
|
||||
<span
|
||||
className="mono uppercase"
|
||||
style={{
|
||||
color: "var(--tt-amber)",
|
||||
fontSize: 10.5,
|
||||
letterSpacing: "0.22em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Eye-flow
|
||||
</span>
|
||||
<span
|
||||
className="mono uppercase"
|
||||
style={{
|
||||
color: "var(--tt-muted)",
|
||||
fontSize: 10,
|
||||
letterSpacing: "0.18em",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
· five lanes, one queue
|
||||
</span>
|
||||
</div>
|
||||
<h2
|
||||
className="display leading-[0.95] tracking-[-0.03em]"
|
||||
className="display italic"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(22px, 2.2vw, 28px)",
|
||||
color: "var(--tt-ink)",
|
||||
fontSize: "clamp(20px, 1.9vw, 24px)",
|
||||
lineHeight: 1.1,
|
||||
fontWeight: 400,
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
The eye-flow, <span className="italic">at a glance.</span>
|
||||
at a glance.
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="text-right"
|
||||
aria-label="Aggregate metrics"
|
||||
>
|
||||
<div className="text-right" aria-label="Aggregate metrics">
|
||||
<div
|
||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
className="mono uppercase"
|
||||
style={{
|
||||
color: "var(--tt-ink-dim)",
|
||||
fontSize: 10,
|
||||
letterSpacing: "0.20em",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Need eyes
|
||||
</div>
|
||||
<div
|
||||
className="display tabular-nums"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: 24,
|
||||
lineHeight: 1.1,
|
||||
color: "var(--tt-amber)",
|
||||
fontSize: 28,
|
||||
lineHeight: 1,
|
||||
fontWeight: 400,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{needEyes}
|
||||
@@ -375,8 +454,13 @@ export default function Inbox() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lane metrics row — paper tiles that mirror each lane's accent */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 divide-x divide-y lg:divide-y-0" style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}>
|
||||
{/* Lane metrics row — each tile carries its own accent dot
|
||||
and count, so the operator can read the queue breakdown
|
||||
at a glance without scanning back up to the Lane grid. */}
|
||||
<div
|
||||
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
|
||||
style={{ borderTop: "1px solid hsl(220 8% 16%)" }}
|
||||
>
|
||||
<SummaryTile
|
||||
label="Rejected"
|
||||
value={lanes.rejected.length}
|
||||
@@ -565,33 +649,31 @@ export default function Inbox() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SummaryTile — paper-toned metric for the queue ledger strip. Mirrors
|
||||
// the lane accent (oxblood / amber / ink-blue / muted) so the eye-flow
|
||||
// reads at a glance.
|
||||
// SummaryTile — ticker-tape metric for the eye-flow strip. Each tile
|
||||
// carries the lane's accent dot + label + count in the same color
|
||||
// family the Lane component uses (oxblood / amber / ink-blue / muted),
|
||||
// so the operator can read the queue breakdown at a glance without
|
||||
// scanning back up to the lane grid.
|
||||
// ---------------------------------------------------------------------------
|
||||
const SUMMARY_ACCENTS: Record<
|
||||
"oxblood" | "amber" | "ink" | "muted",
|
||||
{ dot: string; tint: string; text: string }
|
||||
{ dot: string; value: string }
|
||||
> = {
|
||||
oxblood: {
|
||||
dot: "hsl(358 70% 42%)",
|
||||
tint: "hsl(358 70% 92%)",
|
||||
text: "hsl(358 70% 30%)",
|
||||
dot: "var(--tt-oxblood)",
|
||||
value: "var(--tt-ink)",
|
||||
},
|
||||
amber: {
|
||||
dot: "hsl(36 92% 50%)",
|
||||
tint: "hsl(36 82% 88%)",
|
||||
text: "hsl(36 92% 30%)",
|
||||
dot: "var(--tt-amber)",
|
||||
value: "var(--tt-amber)",
|
||||
},
|
||||
ink: {
|
||||
dot: "hsl(212 80% 45%)",
|
||||
tint: "hsl(212 85% 92%)",
|
||||
text: "hsl(212 80% 30%)",
|
||||
dot: "var(--tt-ink-blue)",
|
||||
value: "var(--tt-ink)",
|
||||
},
|
||||
muted: {
|
||||
dot: "hsl(30 8% 50%)",
|
||||
tint: "hsl(36 22% 90%)",
|
||||
text: "hsl(30 8% 30%)",
|
||||
dot: "var(--tt-muted)",
|
||||
value: "var(--tt-ink-dim)",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -608,41 +690,53 @@ function SummaryTile({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative px-4 py-4 first:pl-5 lg:first:pl-7 last:pr-5 lg:last:pr-7"
|
||||
"relative px-4 py-4 first:pl-5 lg:first:pl-6 last:pr-5 lg:last:pr-6"
|
||||
)}
|
||||
style={{ borderRight: "1px solid hsl(220 8% 14%)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="block h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: a.dot }}
|
||||
style={{
|
||||
backgroundColor: a.dot,
|
||||
boxShadow: `0 0 6px ${a.dot}`,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="mono text-[10px] uppercase tracking-[0.20em] font-semibold"
|
||||
style={{ color: "hsl(var(--surface-ink-3))" }}
|
||||
className="mono uppercase"
|
||||
style={{
|
||||
color: "var(--tt-ink-dim)",
|
||||
fontSize: 10.5,
|
||||
letterSpacing: "0.18em",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="display tabular-nums tracking-[-0.03em]"
|
||||
className="display tabular-nums"
|
||||
style={{
|
||||
color: "hsl(var(--surface-ink))",
|
||||
fontSize: "clamp(24px, 2vw, 30px)",
|
||||
color: a.value,
|
||||
fontSize: "clamp(22px, 1.9vw, 28px)",
|
||||
lineHeight: 1,
|
||||
fontWeight: 400,
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<div
|
||||
className="mono text-[10px] uppercase tracking-[0.16em] mt-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm"
|
||||
className="mono uppercase mt-1.5"
|
||||
style={{
|
||||
color: a.text,
|
||||
backgroundColor: a.tint,
|
||||
color: "var(--tt-muted)",
|
||||
fontSize: 9.5,
|
||||
letterSpacing: "0.16em",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{value === 0 ? "Clear" : value === 1 ? "row" : "rows"}
|
||||
{value === 0 ? "clear" : value === 1 ? "row" : "rows"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
|
||||
import React, { act } from "react";
|
||||
import React, { act, useEffect } from "react";
|
||||
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 { ReconciliationPage } from "./Reconciliation";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -19,6 +20,7 @@ vi.mock("@/lib/api", () => ({
|
||||
listUnmatched: vi.fn(),
|
||||
matchRemit: vi.fn(),
|
||||
unmatchClaim: vi.fn(),
|
||||
getRemittance: vi.fn(),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
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
|
||||
* `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
|
||||
* tree). Returns the rendered container so tests can assert against
|
||||
* 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;
|
||||
unmount: () => void;
|
||||
tracker?: { pathname: string; search: string };
|
||||
} {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
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);
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: qc },
|
||||
element
|
||||
inner
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -60,6 +107,7 @@ function renderIntoContainer(element: React.ReactElement): {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
tracker,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,6 +134,101 @@ async function waitForText(
|
||||
describe("ReconciliationPage", () => {
|
||||
beforeEach(() => {
|
||||
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 () => {
|
||||
@@ -120,7 +263,8 @@ describe("ReconciliationPage", () => {
|
||||
});
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
React.createElement(ReconciliationPage)
|
||||
React.createElement(ReconciliationPage),
|
||||
{ withRouter: true },
|
||||
);
|
||||
await waitForText("CLM-1");
|
||||
|
||||
@@ -138,7 +282,8 @@ describe("ReconciliationPage", () => {
|
||||
).mockResolvedValue({ claims: [], remittances: [] });
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
React.createElement(ReconciliationPage)
|
||||
React.createElement(ReconciliationPage),
|
||||
{ withRouter: true },
|
||||
);
|
||||
await waitForText("nothing pending");
|
||||
|
||||
@@ -148,4 +293,213 @@ describe("ReconciliationPage", () => {
|
||||
expect(document.body.textContent).not.toContain("Unmatched claims (");
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+459
-792
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,11 @@ vi.mock("@/lib/api", () => ({
|
||||
listRemittances: 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
|
||||
@@ -128,6 +133,17 @@ function rowAt(idx: number): 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"`. */
|
||||
function hasExactlyOneSelectedRow(): boolean {
|
||||
const selected = document.querySelectorAll(
|
||||
@@ -191,6 +207,11 @@ const SAMPLE_REMITS = [
|
||||
describe("Remittances", () => {
|
||||
beforeEach(() => {
|
||||
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
|
||||
// so a tail-arrival case (if added later) doesn't see rows from a
|
||||
// previous test.
|
||||
@@ -203,18 +224,26 @@ describe("Remittances", () => {
|
||||
returned: SAMPLE_REMITS.length,
|
||||
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));
|
||||
await waitForText("PCN-1");
|
||||
|
||||
// The chevron + "Adjustments" header should not yet be visible because
|
||||
// the row hasn't been expanded yet.
|
||||
expect(document.body.textContent).not.toContain("Adjustments (2)");
|
||||
// No drawer in the DOM yet — the URL has no `?remit=`.
|
||||
expect(document.body.querySelector('[data-testid="remit-drawer"]')).toBeNull();
|
||||
|
||||
// Expand the row by clicking on the remit ID cell. We click the parent
|
||||
// row by selecting the cell containing "PCN-1" and bubbling up.
|
||||
// Click the row containing PCN-1.
|
||||
const cell = Array.from(document.querySelectorAll("td")).find(
|
||||
(td) => td.textContent === "PCN-1"
|
||||
);
|
||||
@@ -223,16 +252,36 @@ describe("Remittances", () => {
|
||||
await act(async () => {
|
||||
(row as HTMLTableRowElement).click();
|
||||
});
|
||||
await waitForText("Adjustments (2)");
|
||||
|
||||
// Both CAS labels must surface (not the raw codes alone).
|
||||
expect(document.body.textContent).toContain(
|
||||
"Charge exceeds fee schedule/maximum allowable"
|
||||
// The drawer must mount into document.body via Radix's portal.
|
||||
await settle(
|
||||
() => document.body.querySelector('[data-testid="remit-drawer"]') !== null
|
||||
);
|
||||
expect(document.body.textContent).toContain("Deductible amount");
|
||||
// Group/reason pills show the CARC code alongside.
|
||||
expect(document.body.textContent).toContain("CO-45");
|
||||
expect(document.body.textContent).toContain("PR-1");
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="remit-drawer"]')
|
||||
).not.toBeNull();
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
|
||||
+67
-125
@@ -1,5 +1,4 @@
|
||||
import { Fragment, useCallback, useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -17,13 +16,15 @@ import { Pagination } from "@/components/ui/pagination";
|
||||
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||
import { RemitDrawer } from "@/components/RemitDrawer";
|
||||
import { useRemittances } from "@/hooks/useRemittances";
|
||||
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
||||
import { useTailStream } from "@/hooks/useTailStream";
|
||||
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CasAdjustment, Remittance, RemittanceStatus } from "@/types";
|
||||
import type { Remittance, RemittanceStatus } from "@/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -33,39 +34,20 @@ const STATUS_OPTIONS: FilterChipOption[] = [
|
||||
{ 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() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<RemittanceStatus | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
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({
|
||||
sort: "receivedDate",
|
||||
order: "desc",
|
||||
@@ -93,15 +75,6 @@ export function Remittances() {
|
||||
{ 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(() => {
|
||||
setSelectedIndex((i) => {
|
||||
if (items.length === 0) return null;
|
||||
@@ -119,19 +92,42 @@ export function Remittances() {
|
||||
}, [items.length]);
|
||||
|
||||
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,
|
||||
onPrev: movePrev,
|
||||
onClose: () => setHelpOpen(false),
|
||||
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 (
|
||||
<>
|
||||
<KeyboardCheatsheet
|
||||
open={helpOpen}
|
||||
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">
|
||||
<PageHeader
|
||||
eyebrow="Remittances"
|
||||
@@ -204,7 +200,6 @@ export function Remittances() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" aria-label="Expand" />
|
||||
<TableHead>Remit</TableHead>
|
||||
<TableHead>Claim</TableHead>
|
||||
<TableHead>Payer</TableHead>
|
||||
@@ -216,92 +211,39 @@ export function Remittances() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((r, idx) => {
|
||||
const isOpen = expanded.has(r.id);
|
||||
const hasAdjustments =
|
||||
!!r.adjustments && r.adjustments.length > 0;
|
||||
const isSelected = selectedIndex === idx;
|
||||
return (
|
||||
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
|
||||
<TableRow
|
||||
data-row-index={idx}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"animate-row-flash",
|
||||
isSelected && [
|
||||
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
|
||||
],
|
||||
)}
|
||||
onClick={() =>
|
||||
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] text-muted-foreground">
|
||||
{r.claimId}
|
||||
</TableCell>
|
||||
<TableCell>{r.payerName}</TableCell>
|
||||
<TableCell className="text-right display mono">
|
||||
{fmt.usdPrecise(r.paidAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display mono text-muted-foreground">
|
||||
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RemitStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground mono text-[12px]">
|
||||
{fmt.dateShort(r.receivedDate)}
|
||||
</TableCell>
|
||||
</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>
|
||||
<TableRow
|
||||
key={`${r.id}-${dataUpdatedAt}`}
|
||||
data-row-index={idx}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"animate-row-flash cursor-pointer drillable",
|
||||
isSelected && [
|
||||
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
|
||||
],
|
||||
)}
|
||||
onClick={() => open(r.id)}
|
||||
>
|
||||
<TableCell className="display mono text-[12.5px]">{r.id}</TableCell>
|
||||
<TableCell className="display mono text-[12.5px] text-muted-foreground">
|
||||
{r.claimId}
|
||||
</TableCell>
|
||||
<TableCell>{r.payerName}</TableCell>
|
||||
<TableCell className="text-right display mono">
|
||||
{fmt.usdPrecise(r.paidAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display mono text-muted-foreground">
|
||||
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RemitStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground mono text-[12px]">
|
||||
{fmt.dateShort(r.receivedDate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// @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 } from "@/components/ClaimCard837";
|
||||
import { 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();
|
||||
});
|
||||
});
|
||||
+573
-983
File diff suppressed because it is too large
Load Diff
@@ -299,6 +299,13 @@ export interface ParseResult837 {
|
||||
envelope: Envelope | null;
|
||||
claims: ClaimOutput[];
|
||||
summary: BatchSummary;
|
||||
/**
|
||||
* Server-side batch id (UUID). Present on the JSON response from
|
||||
* POST /api/parse-837 — null/missing on the NDJSON-streaming path
|
||||
* (use the final `summary.batch_id` from the NDJSON stream instead).
|
||||
* Required by /api/batches/{batch_id}/export-837.
|
||||
*/
|
||||
batch_id?: string | null;
|
||||
}
|
||||
|
||||
// --- 835 ERA ---------------------------------------------------------------
|
||||
@@ -379,6 +386,8 @@ export interface ParseResult835 {
|
||||
claims: ClaimPayment[];
|
||||
summary: BatchSummary;
|
||||
validation?: ValidationReport | null;
|
||||
/** See ParseResult837.batch_id. */
|
||||
batch_id?: string | null;
|
||||
}
|
||||
|
||||
// --- NDJSON streaming ------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user