diff --git a/audit-uiux.mjs b/audit-uiux.mjs new file mode 100644 index 0000000..0022435 --- /dev/null +++ b/audit-uiux.mjs @@ -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); +}); diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 0a7dd6f..0db7478 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -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" diff --git a/backend/src/cyclone/api_helpers.py b/backend/src/cyclone/api_helpers.py index 77f9e3f..e406b57 100644 --- a/backend/src/cyclone/api_helpers.py +++ b/backend/src/cyclone/api_helpers.py @@ -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: diff --git a/backend/src/cyclone/edi/filenames.py b/backend/src/cyclone/edi/filenames.py index 52576a4..92157d1 100644 --- a/backend/src/cyclone/edi/filenames.py +++ b/backend/src/cyclone/edi/filenames.py @@ -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\d+)-(?P[A-Z0-9]+)-(?P\d{17})-1of1\.(?P[A-Za-z0-9]+)$" + r"^tp(?P\d+)-(?P[A-Z0-9]+)-(?P\d{17})-1of1\.(?P[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}" # --------------------------------------------------------------------------- diff --git a/backend/src/cyclone/parsers/models.py b/backend/src/cyclone/parsers/models.py index 7c66e3a..8506d9f 100644 --- a/backend/src/cyclone/parsers/models.py +++ b/backend/src/cyclone/parsers/models.py @@ -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): diff --git a/backend/src/cyclone/parsers/parse_837.py b/backend/src/cyclone/parsers/parse_837.py index e05c5b0..e242513 100644 --- a/backend/src/cyclone/parsers/parse_837.py +++ b/backend/src/cyclone/parsers/parse_837.py @@ -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, ) diff --git a/backend/src/cyclone/parsers/serialize_837.py b/backend/src/cyclone/parsers/serialize_837.py index 92ab5d1..6ec6b7e 100644 --- a/backend/src/cyclone/parsers/serialize_837.py +++ b/backend/src/cyclone/parsers/serialize_837.py @@ -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, ) \ No newline at end of file diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index bb07b5f..0f2a942 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -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={ diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 29f98e1..c879131 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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): diff --git a/backend/tests/test_api_835.py b/backend/tests/test_api_835.py index 212968d..bc3602e 100644 --- a/backend/tests/test_api_835.py +++ b/backend/tests/test_api_835.py @@ -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): diff --git a/backend/tests/test_api_batch_export_837.py b/backend/tests/test_api_batch_export_837.py new file mode 100644 index 0000000..b532cda --- /dev/null +++ b/backend/tests/test_api_batch_export_837.py @@ -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}" diff --git a/backend/tests/test_api_parse_includes_batch_id.py b/backend/tests/test_api_parse_includes_batch_id.py new file mode 100644 index 0000000..2cd139f --- /dev/null +++ b/backend/tests/test_api_parse_includes_batch_id.py @@ -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"] diff --git a/backend/tests/test_filenames.py b/backend/tests/test_filenames.py index eb70ae3..b802539 100644 --- a/backend/tests/test_filenames.py +++ b/backend/tests/test_filenames.py @@ -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") diff --git a/backend/tests/test_serialize_837.py b/backend/tests/test_serialize_837.py index be6c7b8..6b09611 100644 --- a/backend/tests/test_serialize_837.py +++ b/backend/tests/test_serialize_837.py @@ -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**EM*.""" + 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) diff --git a/backend/tests/test_sftp_stub.py b/backend/tests/test_sftp_stub.py index 882687e..8ce3548 100644 --- a/backend/tests/test_sftp_stub.py +++ b/backend/tests/test_sftp_stub.py @@ -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): diff --git a/docs/goodclaim.x12 b/docs/goodclaim.x12 new file mode 100644 index 0000000..0795202 --- /dev/null +++ b/docs/goodclaim.x12 @@ -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~ \ No newline at end of file diff --git a/src/components/ClaimCard/shared.tsx b/src/components/ClaimCard/shared.tsx new file mode 100644 index 0000000..fe0ac58 --- /dev/null +++ b/src/components/ClaimCard/shared.tsx @@ -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 ( + + + Passed + + ); + } + if (hasWarnings) { + return ( + + + Warnings + + ); + } + return ( + + + Failed + + ); +} + +/** + * 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 ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/src/components/ClaimCard837.test.tsx b/src/components/ClaimCard837.test.tsx new file mode 100644 index 0000000..2eb9c4d --- /dev/null +++ b/src/components/ClaimCard837.test.tsx @@ -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> = {}, +): { + 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( + '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( + '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( + '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 + {open ? ( +
+
+ + + + +
+ + {claim.diagnoses.length > 0 ? ( +
+
+ Diagnoses +
+
+ {claim.diagnoses.map((d, i) => ( + + {d.qualifier ? `${d.qualifier}·` : ""} + {d.code} + + ))} +
+
+ ) : null} + + {claim.service_lines.length > 0 ? ( +
+
+ Service lines +
+
+ + + + + + + + + + + + + {claim.service_lines.map((line) => ( + + ))} + +
#CodeModsDateUnitsCharge
+
+
+ ) : null} + + {(claim.validation.errors.length > 0 || + claim.validation.warnings.length > 0) ? ( +
+
+ Validation +
+
    + {claim.validation.errors.map((issue, i) => ( +
  • + + + {issue.rule} — {issue.message} + +
  • + ))} + {claim.validation.warnings.map((issue, i) => ( +
  • + + + {issue.rule} — {issue.message} + +
  • + ))} +
+
+ ) : 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 ? ( +
+ +
+ ) : null} +
+ ) : null} + + ); +} + +function ServiceLine837Row({ line }: { line: ServiceLine }) { + return ( + + + {line.line_number} + + + {line.procedure.qualifier}·{line.procedure.code} + + + {line.procedure.modifiers.length > 0 + ? line.procedure.modifiers.join(", ") + : "—"} + + + {line.service_date ?? "—"} + + + {line.units ? `${toNum(line.units)} ${line.unit_type ?? ""}`.trim() : "—"} + + + {fmt.usdDecimal(line.charge)} + + + ); +} diff --git a/src/components/ClaimDrawer/ClaimDrawer.tsx b/src/components/ClaimDrawer/ClaimDrawer.tsx index 96bef87..b78a4a3 100644 --- a/src/components/ClaimDrawer/ClaimDrawer.tsx +++ b/src/components/ClaimDrawer/ClaimDrawer.tsx @@ -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({ >
{activeTab === "line-reconciliation" ? ( lr.loading ? (

Loading line reconciliation…

) : lr.error ? (

Failed to load line reconciliation. @@ -224,7 +226,7 @@ export function ClaimDrawer({ ) : null ) : ( -

+
- + {eyebrow}
-

{message}

+

{message}

{kind === "network" && onRetry ? ( ) : ( -
+
{name}
)}
{identity}
@@ -120,7 +120,7 @@ export function PartiesGrid({ parties }: PartiesGridProps) { >

Parties

diff --git a/src/components/ClaimDrawer/RawSegmentsPanel.tsx b/src/components/ClaimDrawer/RawSegmentsPanel.tsx index 626d35c..6c98b24 100644 --- a/src/components/ClaimDrawer/RawSegmentsPanel.tsx +++ b/src/components/ClaimDrawer/RawSegmentsPanel.tsx @@ -33,17 +33,17 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) { >

Raw Segments ({rawSegments.length})

-
+
- + {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 ? (

No segments

) : (
             {rawSegments.map((segment, i) => (
diff --git a/src/components/ClaimDrawer/ServiceLinesTable.tsx b/src/components/ClaimDrawer/ServiceLinesTable.tsx
index e68c869..4e320bd 100644
--- a/src/components/ClaimDrawer/ServiceLinesTable.tsx
+++ b/src/components/ClaimDrawer/ServiceLinesTable.tsx
@@ -49,7 +49,7 @@ export function ServiceLinesTable({
     

Service Lines ({serviceLines.length})

@@ -57,7 +57,7 @@ export function ServiceLinesTable({ {serviceLines.length === 0 ? (

No service lines

@@ -84,34 +84,34 @@ export function ServiceLinesTable({ data-line-number={line.lineNumber} className="row-hover" > - + {line.lineNumber} - + {formatProcedure(line)} {fmt.usdPrecise(line.charge)} - + {formatUnits(line)} - + {line.serviceDate ? fmt.date(line.serviceDate) : ""} {formatMoneyOrDash(lr?.paid ?? null)} {formatMoneyOrDash(lr?.adjustmentsSum ?? null)} diff --git a/src/components/ClaimDrawer/StateHistoryTimeline.tsx b/src/components/ClaimDrawer/StateHistoryTimeline.tsx index 3a0eb1f..92a8c39 100644 --- a/src/components/ClaimDrawer/StateHistoryTimeline.tsx +++ b/src/components/ClaimDrawer/StateHistoryTimeline.tsx @@ -64,7 +64,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) { >

State History ({history.length})

@@ -72,14 +72,14 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) { {history.length === 0 ? (

No history events

) : (
    {history.map((event, i) => (
  1. {kindLabel(event.kind)} {fmt.date(event.ts)} · {fmt.time(event.ts)} @@ -110,7 +110,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) { {event.remittanceId ? ( ↳ Remit {event.remittanceId} diff --git a/src/components/ClaimDrawer/ValidationPanel.tsx b/src/components/ClaimDrawer/ValidationPanel.tsx index 9a5c879..f959416 100644 --- a/src/components/ClaimDrawer/ValidationPanel.tsx +++ b/src/components/ClaimDrawer/ValidationPanel.tsx @@ -63,12 +63,12 @@ function IssueGroup({ onClick={() => openPeek({ kind: "rule", rule })} data-testid={`${testId}-rule-drill`} aria-label={`Drill into rule ${rule}`} - className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)] cursor-pointer drillable rounded-sm px-0 -mx-0" + className="mono text-[12px] font-semibold tracking-tight text-foreground cursor-pointer drillable rounded-sm px-0 -mx-0" > {rule} {issues.length} @@ -78,7 +78,7 @@ function IssueGroup({ {issues.map((issue, idx) => (
  2. - + All checks passed - + Valid
@@ -140,10 +140,10 @@ export function ValidationPanel({ validation }: ValidationPanelProps) { return (
- + Validation @@ -152,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" >
- + Errors ({validation.errors.length})
@@ -185,11 +185,11 @@ export function ValidationPanel({ validation }: ValidationPanelProps) { >
- + Warnings ({validation.warnings.length})
diff --git a/src/components/DominantKpiCard.tsx b/src/components/DominantKpiCard.tsx new file mode 100644 index 0000000..d95310a --- /dev/null +++ b/src/components/DominantKpiCard.tsx @@ -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 ( +
+ {/* Accent rail on the left — thicker than the small cards */} +
+ + {/* Header row: label + icon */} +
+
+ {label} + {hint ? ( + + · {hint} + + ) : null} +
+ {Icon ? ( +
+ +
+ ) : null} +
+ + {/* Big number */} +
+ {rawValue !== undefined && format ? ( + + ) : ( + value + )} +
+ + {/* Delta + sparkline */} +
+
+ {delta ? ( + + {delta.direction === "up" ? ( + + ) : ( + + )} + {delta.value} + + ) : null} + + vs last 6 mo + +
+ + {sparkline ? ( +
+ +
+ ) : null} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/EditorialNote.tsx b/src/components/EditorialNote.tsx new file mode 100644 index 0000000..f6fac0a --- /dev/null +++ b/src/components/EditorialNote.tsx @@ -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 ( +
+ {lead ? ( + + {lead} + + ) : null} +
+

+ {children} +

+ {caption ? ( +

+ {caption} +

+ ) : null} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ExportBar.test.tsx b/src/components/ExportBar.test.tsx new file mode 100644 index 0000000..b2dd6f2 --- /dev/null +++ b/src/components/ExportBar.test.tsx @@ -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> = {}, +): { + 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( + '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( + '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( + '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( + '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("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("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("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("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("button")!; + expect(btn.textContent).toContain("7"); + unmount(); + }); +}); diff --git a/src/components/ExportBar.tsx b/src/components/ExportBar.tsx new file mode 100644 index 0000000..c3d2e62 --- /dev/null +++ b/src/components/ExportBar.tsx @@ -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 ( +
+ + + + {selectedCount} of{" "} + {total} selected + + +
+ +
+
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 6e0d9a1..df679ca 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -24,6 +24,24 @@ export function Layout() { return (
+ {/* 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). */} +
+ {/* Left rail — amber signal accent for the LIVE indicator */} + {live ? ( +
+ + + + + + {liveLabel} + +
+ ) : null} + + {/* Cells */} +
+ {cells.map((c, i) => ( +
0 ? "border-l border-border/40" : "" + )} + > + + {c.label} + +
+ + {c.value} + + {c.delta ? ( + + {c.delta} + + ) : null} +
+
+ ))} +
+ + {/* Right rail — perforated tick pattern, decoratively marks the + end of the tape without using a heavy border. */} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/charts/AgingBars.tsx b/src/components/charts/AgingBars.tsx new file mode 100644 index 0000000..aacd0f1 --- /dev/null +++ b/src/components/charts/AgingBars.tsx @@ -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 ( +
+ Nothing outstanding. +
+ ); + } + + return ( +
+ {caption ? ( +
+ Aging buckets + {caption} +
+ ) : null} + + {/* The stacked bar */} +
+ {buckets.map((b, i) => { + const pct = (b.amount / sum) * 100; + if (pct <= 0) return null; + return ( +
+ ); + })} +
+ + {/* Legend rows */} +
    + {buckets.map((b) => { + const share = sum > 0 ? (b.amount / sum) * 100 : 0; + return ( +
  • +
    + + + {b.label}d + +
    +
    +
    +
    +
    + + {fmt.usd(b.amount)} + + + {share.toFixed(0)}% + +
    +
  • + ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx new file mode 100644 index 0000000..d731bf5 --- /dev/null +++ b/src/components/charts/BarChart.tsx @@ -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(null); + const [width, setWidth] = useState(800); + const [hover, setHover] = useState(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 ( +
+ setHover(null)} + > + {/* Y-axis grid + labels */} + {showGrid && + Array.from({ length: yTicks + 1 }, (_, i) => { + const v = (yMax / yTicks) * i; + const y = yToPx(v); + return ( + + + + {fmtY(v)} + + + ); + })} + + {/* Highlight band on the current month (subtle, like a sticky note) */} + {highlightIndex !== undefined && + highlightIndex >= 0 && + highlightIndex < categories.length ? ( + + + + + NOW + + + ) : 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 ( + + setHover({ + cat: ci, + series: si, + x: x + barW / 2, + y, + }) + } + /> + ); + }) + )} + + {/* X-axis labels */} + {categories.map((cat, i) => ( + + {cat} + + ))} + + {/* 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 ( + + {fmtY(total)} + + ); + })} + + {/* 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 ( + + + + {text} + + + {valueText} + + + ); + })() + ) : null} + + + {/* Legend */} +
+ {series.map((s) => ( +
+ + + {s.label} + +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/charts/HBarChart.tsx b/src/components/charts/HBarChart.tsx new file mode 100644 index 0000000..acb333d --- /dev/null +++ b/src/components/charts/HBarChart.tsx @@ -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(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 ( +
+ No data yet. +
+ ); + } + + const max = Math.max(...rows.map((r) => r.value), 1); + + return ( +
+ {rows.map((r, i) => { + const pct = (r.value / max) * 100; + const color = r.color ?? defaultColor; + return ( +
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 ? ( +
{r.leading}
+ ) : null} + + {/* Label column */} +
+
+ {r.label} +
+ {r.sublabel ? ( +
+ {r.sublabel} +
+ ) : null} +
+ + {/* Bar */} +
+ {/* Track (hairline) */} +
+
+ {/* Rank tick on the bar */} + 12 ? 1 : 0, + }} + > + {String(i + 1).padStart(2, "0")} + +
+ + {/* Trailing value */} +
+ {r.formatted ?? r.value.toLocaleString("en-US")} +
+
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/src/components/charts/SegmentedBar.tsx b/src/components/charts/SegmentedBar.tsx new file mode 100644 index 0000000..859a8a4 --- /dev/null +++ b/src/components/charts/SegmentedBar.tsx @@ -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 ( +
+ No claims yet. +
+ ); + } + + return ( +
+ {caption ? ( +
+ {caption} +
+ ) : null} + + {/* The bar */} +
+ {segments.map((s, i) => { + const pct = (s.count / total) * 100; + if (pct <= 0) return null; + return ( +
+ ); + })} +
+ + {/* Legend */} +
    + {segments.map((s) => { + const pct = (s.count / total) * 100; + if (s.count === 0) return null; + return ( +
  • +
    + + + {s.label} + +
    +
    + + {fmt.num(s.count)} + + + {pct.toFixed(0)}% + +
    +
  • + ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/inbox/Lane.tsx b/src/components/inbox/Lane.tsx index b0b043d..365603d 100644 --- a/src/components/inbox/Lane.tsx +++ b/src/components/inbox/Lane.tsx @@ -102,10 +102,15 @@ export function Lane({
diff --git a/src/hooks/useBatchExport.test.ts b/src/hooks/useBatchExport.test.ts new file mode 100644 index 0000000..6684a3f --- /dev/null +++ b/src/hooks/useBatchExport.test.ts @@ -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 | 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 { + 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, + ); + + 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(); + }); +}); diff --git a/src/hooks/useBatchExport.ts b/src/hooks/useBatchExport.ts new file mode 100644 index 0000000..a903755 --- /dev/null +++ b/src/hooks/useBatchExport.ts @@ -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; + /** 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; + /** + * 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>( + () => new Set(), + ); + const [exporting, setExporting] = useState(false); + const lastBatchIdRef = useRef(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>(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, + }; +} diff --git a/src/index.css b/src/index.css index 678cab8..e95a4c3 100644 --- a/src/index.css +++ b/src/index.css @@ -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); } diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 7c1bcbd..7ce46a3 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -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= and ?b= 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= and ?b= 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="), + }); + await expect(api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "")).rejects.toMatchObject({ + status: 400, + message: expect.stringContaining("?b="), + }); + // Network must not be touched. + expect(mockFetch).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/api.ts b/src/lib/api.ts index 5e47806..1715543 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -175,12 +175,24 @@ async function readErrorBody(res: Response): Promise { 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(id: string): Promise { */ async function getBatchDiff(a: string, b: string): Promise { 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= is required."); + } + if (typeof b !== "string" || b.length === 0) { + throw new ApiError(400, "Missing param: ?b= is required."); + } const res = await fetch( joinUrl( `/api/batch-diff?${qs({ a, b })}`, @@ -786,6 +808,74 @@ async function getAck(id: number): Promise { 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 { + 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, diff --git a/src/pages/Acks.tsx b/src/pages/Acks.tsx index 42663af..f707e11 100644 --- a/src/pages/Acks.tsx +++ b/src/pages/Acks.tsx @@ -11,6 +11,9 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { EmptyState } from "@/components/ui/empty-state"; import { ErrorState } from "@/components/ui/error-state"; +import { Card, CardContent } from "@/components/ui/card"; +import { KpiCard } from "@/components/KpiCard"; +import { PageHeader } from "@/components/PageHeader"; import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet"; import { AckDrawer } from "@/components/AckDrawer"; import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState"; @@ -22,103 +25,18 @@ import { cn } from "@/lib/utils"; import type { Ack } from "@/types"; /** - * Renders one persisted 999 ACK with the per-status badge color and a - * "Download 999" button that fetches the raw X12 via `api.getAck` - * and triggers a browser download. + * 999 ACK register. The page reads the persisted 999 Implementation + * Acknowledgments produced in response to 837P ingests (or parsed + * from inbound 999 uploads) and exposes a single j/k-navigable + * table. A click drills into the AckDrawer via `?ack=ID` URL state. + * + * Chrome language matches the rest of Cyclone: dark instrument + * surface, PageHeader hero with a ghost editorial watermark, + * KpiCard strip, single Card-wrapped register, hairline-separated + * keyboard cheatsheet affordance. The page is *not* a paper + * register — it sits next to Upload / Reconciliation as another + * instrument view of the same data. */ -function AckCodeBadge({ code }: { code: Ack["ackCode"] }) { - const color = - code === "A" - ? { - text: "hsl(152 64% 30%)", - bg: "hsl(152 50% 88%)", - border: "hsl(152 64% 38% / 0.30)", - } - : code === "R" - ? { - text: "hsl(358 70% 36%)", - bg: "hsl(358 70% 92%)", - border: "hsl(358 70% 50% / 0.30)", - } - : { - text: "hsl(36 92% 30%)", - bg: "hsl(36 82% 88%)", - border: "hsl(36 92% 50% / 0.30)", - }; - return ( - - {code} - - ); -} - -function downloadBlob(filename: string, content: string) { - const blob = new Blob([content], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); -} - -function DownloadButton({ id, sourceBatchId }: { id: number; sourceBatchId: string }) { - const [busy, setBusy] = useState(false); - const onClick = async () => { - if (busy) return; - setBusy(true); - try { - const detail = await api.getAck(id); - const raw = - (detail as unknown as { raw_999_text?: string }).raw_999_text ?? - (() => { - return ""; - })(); - if (raw) { - downloadBlob(`ack-${sourceBatchId}.999`, raw); - } - } catch (err) { - console.error("download 999 failed", err); - } finally { - setBusy(false); - } - }; - return ( - - ); -} - export function Acks() { const { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 }); const items = data?.items ?? []; @@ -155,7 +73,7 @@ export function Acks() { }); // ----------------------------------------------------------------- - // Aggregate metrics for the KPI strip + // Aggregate metrics for the KPI strip + hero copy. // ----------------------------------------------------------------- const totals = items.reduce( (acc, a) => ({ @@ -170,14 +88,25 @@ export function Acks() { ? Math.round((totals.accepted / totals.received) * 1000) / 10 : 0; + // The ghost watermark carries the on-file count, scaled like the + // Dashboard/Upload/Reconciliation watermarks (clamp 72–140px, 4.5% + // opacity, right-anchored). + const watermark = items.length > 0 ? items.length.toLocaleString() : "999"; + + // Tone for the status pill: green when nothing rejected, amber when + // the register holds a partial/rejected payload. + const anyRejected = items.some( + (a) => a.ackCode === "R" || a.ackCode === "E", + ); + // ----------------------------------------------------------------- - // Stagger choreography + // 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 ( <> @@ -187,668 +116,428 @@ export function Acks() { /> {/* SP21 Phase 5 Task 5.3: AckDrawer mount. Row click drills into the matching ack; the drawer portals into document.body - (Radix Dialog), so the surrounding paper plane stays put + (Radix Dialog), so the surrounding dark surface stays put while the drawer is open. Deep links via /acks?ack=ID restore the open ack on reload. */} -
+ +
{/* ================================================================= - HERO — DARK EDITORIAL HEADER + HERO — dark editorial header ================================================================= */}
- {/* Ghost "999" watermark — a print-shop stamp behind the title */} + {/* Ghost editorial watermark — anchors the page's scale. */} -
-
-
-
- - 999 ACKs · Reference - - - · {items.length} on file - -
-

- Acknowledgments,{" "} - received. -

+
+
+ + Acknowledgments,{" "} + + received. + + + } + subtitle={ + <> + 999 Implementation Acknowledgments — generated in response + to 837P ingests, or parsed from inbound 999 uploads. Each + row below is a single transaction set with its accept / + reject counts and the original X12 to download. + + } + />
-
-
- - Envelope · accepted / rejected / partial -
- + - Press ? for shortcuts - + + + + {anyRejected ? "Mixed envelope" : "Envelope accepted"}
-
-

- 999 Implementation Acknowledgments — generated automatically in - response to 837P ingests, or parsed from inbound 999 uploads. - Each row below is a single transaction set with its accept / - reject counts and the original X12 to download. -

+ {/* Keyboard hint — same rhythm as Reconciliation, sits under + the header so the j/k affordance reads on first scan. */} +
+ Navigate + + j + + / + + k + + · + Press + + ? + + for shortcuts
{/* ================================================================= - FOLD — TORN PAGE + KPI STRIP — four standard KpiCards, accent tokens instead of + paper-tinted custom tiles. Each card carries one hairline- + width left accent in its signal color. ================================================================= */} -
-
-
-
-
- {Array.from({ length: 48 }, (_, i) => ( - - ))} -
-
- - ↘ - - - 999 register - - - ↙ - -
-
+ 0 ? `${acceptRate.toFixed(1)}%` : "—" + } + accent={acceptRate >= 95 ? "success" : "warning"} + hint="accepted / received" + /> +
{/* ================================================================= - PAPER PLANE + REGISTER — single Card wrapping the table. Same chrome + language as the rest of the app: hairline header, eyebrow + + display title + sidecar kbd hint, dark-tone table. ================================================================= */} -
- {/* Paper grain */} -
\")", - backgroundSize: "160px 160px", - mixBlendMode: "multiply", - }} - /> - - {/* Title block */} -
-
-
-
-
- Register · 999 Implementation ACKs -
-

- The register. -

-
- One row per inbound 999 — the accept / reject / partial - count, the source batch it answers, and the original - transaction set to download. -
-
-
-
- On file -
-
- {items.length} -
-
- 999 ACK - {items.length === 1 ? "" : "s"} -
-
-
-
- - {/* KPI strip — § 01 Vital signs */} -
-
- - § 01 - -
- - Vital signs - -
-
- - - - -
-
- - {/* Table — § 02 The register */} -
-
- - § 02 - -
- - The register - -
- -
-
-
-
+ + + {/* Section header */} +
+
+
+ The register
-

+

All 999s, newest first. -

-
-
- Navigate rows with{" "} - - j - {" "} - /{" "} - - k - - . +
+

+ Each row is a single 999. The accept / reject / partial + code, the source batch it answers, and the original + transaction set to download. +

- {isError ? ( -
+
+ {isError ? ( refetch()} /> -
- ) : isLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- ) : items.length === 0 ? ( -
- -
- ) : ( -
- - - - - - ID - - - Source Batch - - - Accepted - - - Rejected - - - Received - - - ACK Code - - - Parsed - - - - - - {items.map((a, idx) => { - const isSelected = selectedIndex === idx; - return ( - open(String(a.id))} - className={cn( - "cursor-pointer", - isSelected && [ - "ring-1 ring-inset", - ], - )} - style={ - isSelected - ? { - backgroundColor: "hsl(212 85% 95%)", - boxShadow: - "inset 2px 0 0 0 hsl(212 100% 45%)", - } - : undefined - } - > - - - - + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + ) : items.length === 0 ? ( +
+ +
+ ) : ( +
+
+ + + + ID + Source Batch + + Accepted + + + Rejected + + + Received + + ACK Code + Parsed + + + + + {items.map((a, idx) => { + const isSelected = selectedIndex === idx; + return ( + open(String(a.id))} + className={cn( + "cursor-pointer", + isSelected && + "bg-accent/[0.06] shadow-[inset_2px_0_0_0_hsl(var(--accent))] hover:bg-accent/[0.06]" + )} > - {a.id} - - - {a.sourceBatchId} - - - {a.acceptedCount} - - - {a.rejectedCount} - - - {a.receivedCount} - - - - - - {a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"} - - - - - - ); - })} - -
-
- )} -
-
+ + + + + {a.id} + + + {a.sourceBatchId} + + + {a.acceptedCount} + + + {a.rejectedCount} + + + {a.receivedCount} + + + + + + {a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"} + + + + + + ); + })} + + +
+ )} +
+ + +
- {/* Footer */} -
- End of register - Cyclone · 999 ACKs + {/* ================================================================= + 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. + ================================================================= */} +
+
+ + Cyclone · 999 ACKs +
+
{items.length} {items.length === 1 ? "row" : "rows"} + + · + + + accepted {fmt.num(totals.accepted)} · rejected{" "} + {fmt.num(totals.rejected)} +
-
+ +
); } // --------------------------------------------------------------------------- -// AckKpiTile — paper-toned metric for the ACK register. +// AckCodeBadge — dark-theme pill for the 999 accept / reject / partial code. +// A → success, R → destructive, anything else (E / P) → warning. // --------------------------------------------------------------------------- -function AckKpiTile({ - label, - value, - tone, - numeric = true, -}: { - label: string; - value: number | string; - tone: "success" | "destructive" | "ink" | "blue"; - numeric?: boolean; -}) { - const accentMap = { - success: "hsl(152 64% 38%)", - destructive: "hsl(358 70% 42%)", - ink: "hsl(var(--surface-ink))", - blue: "hsl(212 100% 45%)", - } as const; - const tintMap = { - success: "hsl(152 50% 88%)", - destructive: "hsl(358 70% 92%)", - ink: "hsl(36 22% 90%)", - blue: "hsl(212 85% 92%)", - } as const; - const accent = accentMap[tone]; - const tint = tintMap[tone]; +function AckCodeBadge({ code }: { code: Ack["ackCode"] }) { + const color = + code === "A" + ? { + text: "hsl(var(--success))", + bg: "hsl(var(--success) / 0.10)", + border: "hsl(var(--success) / 0.30)", + } + : code === "R" + ? { + text: "hsl(var(--destructive))", + bg: "hsl(var(--destructive) / 0.10)", + border: "hsl(var(--destructive) / 0.30)", + } + : { + text: "hsl(var(--warning))", + bg: "hsl(var(--warning) / 0.10)", + border: "hsl(var(--warning) / 0.30)", + }; return ( -
-
-
-
- {label} -
-
- -
-
-
- {value} -
-
+ {code} + ); } + +// --------------------------------------------------------------------------- +// DownloadButton — fetches the raw 999 via api.getAck and streams it to +// disk as a text blob. Styled to match the rest of the dark surface. +// --------------------------------------------------------------------------- +function DownloadButton({ + id, + sourceBatchId, +}: { + id: number; + sourceBatchId: string; +}) { + const [busy, setBusy] = useState(false); + const onClick = async () => { + if (busy) return; + setBusy(true); + try { + const detail = await api.getAck(id); + const raw = + (detail as unknown as { raw_999_text?: string }).raw_999_text ?? ""; + if (raw) { + downloadBlob(`ack-${sourceBatchId}.999`, raw); + } + } catch (err) { + console.error("download 999 failed", err); + } finally { + setBusy(false); + } + }; + return ( + + ); +} + +function downloadBlob(filename: string, content: string) { + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/pages/BatchDiff.tsx b/src/pages/BatchDiff.tsx index b4762cf..6520a5a 100644 --- a/src/pages/BatchDiff.tsx +++ b/src/pages/BatchDiff.tsx @@ -17,6 +17,8 @@ import { } from "@/components/ui/select"; import { EmptyState } from "@/components/ui/empty-state"; import { ErrorState } from "@/components/ui/error-state"; +import { Card, CardContent } from "@/components/ui/card"; +import { PageHeader } from "@/components/PageHeader"; import { BatchDiffView, BatchDiffViewSkeleton, @@ -49,10 +51,10 @@ function readIdsFromParams(params: URLSearchParams): { // --------------------------------------------------------------------------- // Kind badge — small inline label so the operator can spot which batch -// is which at a glance. Identical contract to the one in -// `BatchesList.tsx`; duplicated here because each lives inside a -// different page surface (the picker is part of this page, the row -// badge belongs to Batches). +// is which at a glance. Re-themed for the dark surface; the +// data-testid contract from `BatchesList.KindBadge` is preserved +// (prefixed `diff-picker-` so the BatchDiff tests can still assert +// on it). // --------------------------------------------------------------------------- function KindBadge({ kind }: { kind: ApiBatchSummary["kind"] }) { @@ -73,6 +75,12 @@ function KindBadge({ kind }: { kind: ApiBatchSummary["kind"] }) { ); } +/** + * Side A / Side B picker card. Dark-theme chrome with a hairline-width + * left accent line in the side color (electric blue for A, amber for B). + * Excludes the value already chosen on the other side so the operator + * can't pick the same batch twice. + */ function BatchPicker({ label, side, @@ -90,105 +98,95 @@ function BatchPicker({ excludeId: string | null; testid: string; }) { - // Filter out the value already chosen on the *other* side so the - // operator can't accidentally pick the same batch twice (which - // produces a meaningless diff). Disabled rather than hidden so the - // selection stays transparent. const visible = useMemo( () => items.filter((it) => it.id !== excludeId), [items, excludeId], ); const selected = items.find((it) => it.id === value); - const accent = side === "A" ? "hsl(212 100% 45%)" : "hsl(36 92% 50%)"; - const tint = side === "A" ? "hsl(212 85% 95%)" : "hsl(36 82% 92%)"; + // A is electric blue (project --accent). B is amber (project --warning). + const accentClass = + side === "A" + ? "bg-accent" + : "bg-[hsl(var(--warning))]"; + const sideBadgeClass = + side === "A" + ? "bg-accent/15 text-accent" + : "bg-[hsl(var(--warning)/0.15)] text-[hsl(var(--warning))]"; return (
+ {/* Left accent line — the only "color" element on the card, 2px + wide so it reads as a marker, not decoration. */}
-
+
{side}
- + {label}
{selected ? ( - + {selected.claimCount}{" "} - + claims ) : null}
- onChange(v)}> + + {selected ? ( + + + {selected.id} + + · {selected.inputFilename} - - )) - )} - - + + ) : ( + + )} + + + {visible.length === 0 ? ( +
+ No other batches available. +
+ ) : ( + visible.map((b) => ( + + + + {b.id} + + · {b.inputFilename} + + + + )) + )} +
+ +
); } @@ -218,50 +216,6 @@ function NotFoundState({ ); } -// --------------------------------------------------------------------------- -// Section header — § N + small italic vertical-rl label. Reused by -// both the picks and diff sections so the folio is uniform. -// --------------------------------------------------------------------------- - -function Folio({ section, label, topClass = "top-7" }: { - section: string; - label: string; - topClass?: string; -}) { - return ( -
- - {section} - -
- - {label} - -
- ); -} - // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- @@ -327,387 +281,156 @@ export function BatchDiff() { : "network" : null; + // The ghost watermark carries the picker state, scaled like the + // other pages. When both picks are in: "A→B", otherwise the count + // of available batches, otherwise "DIFF". + const watermark = ready + ? `${a}→${b}` + : batches && batches.length > 0 + ? batches.length.toLocaleString() + : "DIFF"; + // ----------------------------------------------------------------- - // Stagger choreography + // Stagger choreography — hero lands first, picks at 140ms, + // diff at 240ms, footer at 320ms. Total < 500ms. // ----------------------------------------------------------------- const heroDelay = 0; - const foldDelay = 220; - const titleDelay = 320; - const picksDelay = 460; - const diffDelay = 600; + const picksDelay = 140; + const diffDelay = 240; + const footerDelay = 320; // --- render ------------------------------------------------------- return (
{/* ================================================================= - HERO — DARK EDITORIAL HEADER + HERO — dark editorial header ================================================================= */}
- {/* Ghost "DIFF" watermark — a print-shop stamp behind the title. */} -
-
-
-
- - Diff · Sheet 01 - - - · A vs B - -
-

- Compare{" "} - batches. -

+
+
+ + Compare{" "} + + batches. + + + } + subtitle={ + <> + Pick two parsed batches — typically a submitted 837P and its + corrected follow-up — and see what was added, removed, or + changed between them. + + } + />
-
-
- - 837P · 835 · first vs second -
- + - Pick a batch for each side - + {ready ? ( + + ) : null} + + + {ready ? "Ready to diff" : "Awaiting picks"}
-
-

- Pick two parsed batches — typically a submitted 837P and its - corrected follow-up — and see what was added, removed, or changed - between them. -

+ {/* On the wire — small inline status row showing A → B, + mirrors the watermark visually and tells the operator at a + glance which picks are armed. */} +
+ On the wire + + {a ? "A" : "—"} + + + + {b ? "B" : "—"} + + + · + + {ready ? "ready to diff" : "two picks needed"}
{/* ================================================================= - FOLD — TORN PAGE + PICKS — single Card wrapping the two BatchPicker cards. + Same chrome language as the rest of the app. ================================================================= */} -
-
-
-
-
- {Array.from({ length: 48 }, (_, i) => ( - - ))} -
-
- - ↘ - - - A → B - - - ↙ - -
-
- - {/* ================================================================= - PAPER PLANE - ================================================================= */} -
- {/* Paper grain */} -
\")", - backgroundSize: "160px 160px", - mixBlendMode: "multiply", - }} - /> - - {/* Title block */} -
-
-
-
-
- Sheet 01 · Compare two batches -
-

- Compare them. -

-
- Two picks, three buckets. What the second batch added, what - the first batch dropped, and which claims moved between them. -
-
-
-
- On the wire -
-
- - {a ? "A" : "—"} - - - - {b ? "B" : "—"} - -
-
- {ready ? "ready to diff" : "two picks needed"} -
-
-
-
- - {/* § 01 The picks */} -
- -
-
-
-
+ + +
+
+
+ The picks
-

+

One on each side. -

+
-
- A is the "before" — B is the "after". A picked batch can't - be picked again on the other side. -
-
-
- - -
-
-
- - {/* § 02 The diff */} -
- -
-
-
-
- The diff -
-

- What moved. -

-
- {ready ? ( -
- Three buckets — added in B, removed from A, changed in - place. Unchanged claims are summarized but not listed. -
- ) : null} +

+ A is the “before” — B is the “after”. + A picked batch can’t be picked again on the other side. +

- {batchesError ? ( -
+
+ {batchesError ? ( refetchBatches()} /> + ) : ( +
+ + +
+ )} +
+ + +
+ + {/* ================================================================= + DIFF — single Card wrapping the BatchDiffView. Same chrome + language as the rest of the app. Branches on `ready` to + pick between the awaiting-picks empty state, the loading + skeleton, the not-found error, the network error, and the + actual diff view. + ================================================================= */} +
+ + +
+
+
+ + The diff +
+

+ What moved. +

- ) : !ready ? ( -
- - } - eyebrow="Batch diff · awaiting picks" - message="Choose one batch for A and one for B to compute the diff." - /> -
- ) : loadingBatches ? ( - - ) : errKind === "not_found" ? ( - - ) : diff.isError ? ( -
+ {ready ? ( +

+ Three buckets — added in B, removed from A, changed in place. + Unchanged claims are summarized but not listed. +

+ ) : null} +
+ +
+ {!ready ? ( +
+ + } + eyebrow="Batch diff · awaiting picks" + message="Choose one batch for A and one for B to compute the diff." + /> +
+ ) : loadingBatches ? ( + + ) : errKind === "not_found" ? ( + + ) : diff.isError ? ( diff.refetch()} /> -
- ) : diff.isLoading || !diff.data ? ( - - ) : ( - - )} -
-
- - {/* Action footer — always visible when ready so the operator can - clear or force-refresh without scrolling back to the picker. */} - {ready ? ( -
-
- {diff.isFetching ? ( - - - Refreshing… - + ) : diff.isLoading || !diff.data ? ( + ) : ( - <> - Comparing{" "} - - {a} - {" "} - with{" "} - - {b} - - . - + )}
-
- - -
-
- ) : null} - {/* Footer */} -
- End of sheet 01 - Cyclone · Batch diff - {ready ? "A vs B" : "awaiting picks"} + {/* Action row — refresh + clear, only when ready. Lives + inside the Card so it sits at the bottom of the diff + surface, not as a separate paper footer. */} + {ready ? ( +
+
+ {diff.isFetching ? ( + + + Refreshing… + + ) : ( + <> + Comparing{" "} + + {a} + {" "} + with{" "} + + {b} + + . + + )} +
+
+ + +
+
+ ) : null} + + + + + {/* ================================================================= + FOOTER — single hairline-separated status row. Replaces the + warm-paper "End of sheet 01" treatment with a quiet + instrument-style status line in the same rhythm as the + other pages. + ================================================================= */} +
+
+ + Cyclone · Batch diff
-
+
+ {ready ? "A vs B" : "awaiting picks"} + + · + + + {batches?.length ?? 0} batch{(batches?.length ?? 0) === 1 ? "" : "es"}{" "} + on file + +
+
+ refresh + + R + +
+ {/* SP21 Phase 4 Task 4.5: RemitDrawer mount. There are no per-remit rows in the diff payload (the page is a diff --git a/src/pages/Batches.tsx b/src/pages/Batches.tsx index 97a2564..e16b5e1 100644 --- a/src/pages/Batches.tsx +++ b/src/pages/Batches.tsx @@ -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() {
{/* ================================================================= - HERO — DARK EDITORIAL HEADER + HERO — dark editorial header ================================================================= */}
- {/* Ghost "PARSED" watermark — a print-shop stamp behind the title. */} -
-
-
-
- - Batches · Register - - - · {totals.count} on file - -
-

- Parsed{" "} - batches. -

+
+
+ + Parsed{" "} + + batches. + + + } + subtitle={ + <> + Every 837P and 835 file the backend has ingested. Click a + row for envelope, summary, and a peek at the contained + claims. + + } + />
-
-
- - 837P · 835 · envelope & summary -
- - Tap a row to open the drawer - +
+ + + + + 837P · 835 · envelope & summary
- -
-

- Every 837P and 835 file the backend has ingested. Click a row - for envelope, summary, and a peek at the contained claims. -

-
{/* ================================================================= - FOLD — TORN PAGE + KPI STRIP — four standard KpiCards, accent tokens instead of + paper-tinted custom tiles. ================================================================= */} -
-
-
-
-
- {Array.from({ length: 48 }, (_, i) => ( - - ))} -
-
- - ↘ - - - The register - - - ↙ - -
-
+ + {/* ================================================================= - 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. ================================================================= */} -
- {/* Paper grain */} -
\")", - backgroundSize: "160px 160px", - mixBlendMode: "multiply", - }} - /> - - {/* Title block */} -
-
-
-
-
- Register · Parsed batches -
-

- The register. -

-
- One row per ingested file — the kind, the input filename, - how many claims it carried, and the day it was parsed. -
-
-
-
- On file -
-
- {totals.count} -
-
- batch{totals.count === 1 ? "" : "es"} · {totals.claims}{" "} - claim{totals.claims === 1 ? "" : "s"} -
-
-
-
- - {/* KPI strip — § 01 Vital signs */} -
-
- - § 01 - -
- - Vital signs - -
-
- } - tone="ink" - /> - } - tone="blue" - /> - } - tone="amber" - /> - -
-
- - {/* Table — § 02 The register */} -
-
- - § 02 - -
- - The register - -
- -
-
-
-
+ + +
+
+
+ The register
-

+

All ingests, newest first. -

+
-
+

Tap a row to open the envelope drawer. Use{" "} - + {" "} /{" "} - + {" "} inside the drawer to step. -

+

- {isError ? ( -
+
+ {isError ? ( refetch()} /> -
- ) : isLoading ? ( -
+ ) : isLoading ? ( -
- ) : items.length === 0 ? ( -
- -
- ) : ( -
- -
- )} -
-
+ ) : items.length === 0 ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ + + - {/* Footer */} -
- End of register - Cyclone · Batches + {/* ================================================================= + 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. + ================================================================= */} +
+
+ + Cyclone · Batches +
+
{totals.count} {totals.count === 1 ? "row" : "rows"} + + · + + last parsed {lastParsed ? fmt.dateShort(lastParsed) : "—"}
-
+
+ open drawer + + ↩ + +
+
); } - -// --------------------------------------------------------------------------- -// 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 ( -
-
-
-
- {label} -
-
- {icon ?? ( - - )} -
-
-
- {value} -
-
- ); -} diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx index ff41bb8..48bb075 100644 --- a/src/pages/Inbox.tsx +++ b/src/pages/Inbox.tsx @@ -224,9 +224,26 @@ export default function Inbox() { return (
+ {/* 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. */} +
{/* 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), @@ -245,50 +262,11 @@ export default function Inbox() { /> - {/* Fold — a thin amber rule + italic serif annotation that - transitions the editorial header into the lane grid below. */} -
-
-
- - ↘ - - - Five lanes · one queue - - - ↙ - -
-
+ {/* 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. */}
{/* ---------------------------------------------------------------- - 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. ---------------------------------------------------------------- */}
- {/* Title row */} + {/* Header row — machine-tag eyebrow + editorial headline + + aggregate Need eyes count, separated from the lane metrics + by a hairline. */}
-
-
- Queue ledger +
+
+ + + Eye-flow + + + · five lanes, one queue +

- The eye-flow, at a glance. + at a glance.

-
+
Need eyes
{needEyes} @@ -439,8 +454,13 @@ export default function Inbox() {
- {/* Lane metrics row — paper tiles that mirror each lane's accent */} -
+ {/* 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. */} +
= { 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)", }, }; @@ -672,41 +690,53 @@ function SummaryTile({ return (
{label}
{value}
- {value === 0 ? "Clear" : value === 1 ? "row" : "rows"} + {value === 0 ? "clear" : value === 1 ? "row" : "rows"}
); diff --git a/src/pages/Reconciliation.tsx b/src/pages/Reconciliation.tsx index 520c173..b1b844b 100644 --- a/src/pages/Reconciliation.tsx +++ b/src/pages/Reconciliation.tsx @@ -10,10 +10,16 @@ import { toast } from "sonner"; import { useReconciliation } from "@/hooks/useReconciliation"; import { ApiError } from "@/lib/api"; import { Skeleton } from "@/components/ui/skeleton"; +import { EmptyState } from "@/components/ui/empty-state"; import { ErrorState } from "@/components/ui/error-state"; import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { KpiCard } from "@/components/KpiCard"; +import { PageHeader } from "@/components/PageHeader"; import { RemitDrawer } from "@/components/RemitDrawer"; import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState"; +import { fmt } from "@/lib/format"; import { cn } from "@/lib/utils"; /** @@ -23,9 +29,8 @@ import { cn } from "@/lib/utils"; * cascade refreshes the unmatched bucket + claims + remits + activity feeds * on success. * - * Hybrid layout: dark editorial hero → torn-page fold → cream paper plane - * with the two pairing columns, a footer with the match action, and a - * "currently selected" annotation between them. + * Same chrome language as the rest of Cyclone: dark instrument surface, + * PageHeader hero, KPI strip, two-column Card layout, action bar. */ export function ReconciliationPage() { const { unmatched, match } = useReconciliation(); @@ -43,47 +48,38 @@ export function ReconciliationPage() { const [selectedRemit, setSelectedRemit] = useState(null); // ----------------------------------------------------------------- - // Stagger choreography + // Stagger choreography — hero lands first, KPI strip at 140ms, + // pairing surface at 240ms, action bar at 320ms. Total < 500ms. // ----------------------------------------------------------------- const heroDelay = 0; - const foldDelay = 220; - const titleDelay = 320; - const kpiDelay = 460; - const columnsDelay = 600; - const actionDelay = 760; + const kpiDelay = 140; + const columnsDelay = 240; + const actionDelay = 320; // ---------- Loading state ---------- if (unmatched.isLoading) { return ( -
+
-
-
-
- - Reconciliation - -
-

- Manual{" "} - pairing. -

+
+
+ Reconciliation +
+
+ Loading pairs…
-
-
- - -
+
+ + + +
+
+ +
); @@ -96,39 +92,36 @@ export function ReconciliationPage() { ? unmatched.error.message : String(unmatched.error); return ( -
+
-
-
-
- - Reconciliation - -
-

- Manual{" "} - pairing. -

-
-
-
- unmatched.refetch()} + + Manual pairing. + + } + subtitle={ + <> + Pick one claim and one remittance, then match them. The system + reconciles automatically when amounts and providers line up; + the ones that don't are dropped here for you to triage. + + } /> -
+
+ + + unmatched.refetch()} + /> + +
); } @@ -138,6 +131,7 @@ export function ReconciliationPage() { remittances: [], }; const empty = claims.length === 0 && remittances.length === 0; + const queueTotal = claims.length + remittances.length; const handleMatch = async () => { if (!selectedClaim || !selectedRemit) return; @@ -165,498 +159,220 @@ export function ReconciliationPage() { // ---------- Empty state ---------- if (empty) { return ( -
+
+ {/* HERO — eyebrow says "Reconciliation · nothing pending" so the + Reconciliation.test.tsx empty-state assertion still resolves + (it concatenates textContent and looks for that exact string). + Title is the standard editorial display with an italic accent. */}
-
- PAIRED -
-
-
-
- - Reconciliation - +
+
+ + Manual{" "} + pairing. + + } + subtitle={ + <> + Pick one claim and one remittance, then match them. The + system reconciles automatically when amounts and + providers line up; the ones that don't are dropped here + for you to triage. + + } + /> +
+
+ + + + + All paired
-

- Manual{" "} - pairing. -

-

- Pick one claim and one remittance, then match them. The system - reconciles automatically when amounts and providers line up; - the ones that don't are dropped here for you to triage. -

- {/* Fold */} -
-
-
-
-
- {Array.from({ length: 48 }, (_, i) => ( - - ))} -
-
- - ↘ - - - Pair them by hand - - - ↙ - -
-
+ + -
-
\")", - backgroundSize: "160px 160px", - mixBlendMode: "multiply", - }} - /> -
- -
- Nothing pending. -
-
- Every claim and remittance is paired. -
-
-
+ + + } + /> + + + + + {/* SP21 Phase 4 Task 4.6: RemitDrawer mount, same as below. */} + { + // Reconciliation has no cheatsheet; `?` is a no-op here. + }} + />
); } // ---------- Main rendering ---------- return ( -
- {/* HERO */} +
+ {/* HERO — same PageHeader pattern as Dashboard / Upload. Ghost + watermark carries the queue total so the page's scale reads + at a glance. */}
- {/* Ghost watermark */}
- PAIRED + {queueTotal}
-
-
-
-
- - Reconciliation - - - ·{" "} - {claims.length + remittances.length} need eyes - -
-

- Manual{" "} - pairing. -

+
+
+ + Manual{" "} + pairing. + + } + subtitle={ + <> + Pick one claim and one remittance, then match them. The + system reconciles automatically when amounts and providers + line up; the ones that don't are dropped here for you to + triage. + + } + />
-
-
- - - - - Auto-match paused -
+
+ + + + + Auto-match paused
- -
-

- Pick one claim and one remittance, then match them. The system - reconciles automatically when amounts and providers line up; the - ones that don't are dropped here for you to triage. -

-
- {/* FOLD */} -
-
-
-
-
- {Array.from({ length: 48 }, (_, i) => ( - - ))} -
-
- - ↘ - - - Pair them by hand - - - ↙ - -
-
+ - {/* PAPER PLANE */} -
- {/* Paper grain */} -
\")", - backgroundSize: "160px 160px", - mixBlendMode: "multiply", - }} - /> - - {/* Title block */} -
-
-
-
-
- Reconciliation · Sheet 01 -
-

- Pair them. -

-
- One claim, one remit. The system learns from every pair you - confirm — make them count. -
-
-
-
- In the queue -
-
- {claims.length + remittances.length} -
-
- {claims.length} claim{claims.length === 1 ? "" : "s"} ·{" "} - {remittances.length} remit - {remittances.length === 1 ? "" : "s"} -
-
-
-
- - {/* KPI strip — § 01 Vital signs */} -
-
- - § 01 - -
- - Vital signs - -
-
- - - -
-
- - {/* Two-column pairing surface — § 02 The match */} -
-
- - § 02 - -
- - The match - -
- -
-
-
-
+ + + {/* Section header */} +
+
+
+ The match
-

- Pick one of each. -

+

+ Pair them. +

-
+

Tap a row on the left, tap a row on the right, then send the pair. -

+

-
+
{/* Claims column */} @@ -675,10 +391,10 @@ export function ReconciliationPage() { key={c.id} data-testid="recon-claim-card" className={cn( - "w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors", + "relative w-full text-left p-3.5 min-h-[44px] rounded-md border transition-all overflow-hidden", active - ? "border-[hsl(212_100%_45%)] bg-[hsl(212_85%_95%)] ring-1 ring-inset ring-[hsl(212_100%_45%_/_0.30)]" - : "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]" + ? "border-accent/60 bg-accent/[0.06] shadow-[inset_2px_0_0_0_hsl(var(--accent))]" + : "border-border/70 bg-card/40 hover:bg-card/80 hover:border-border" )} > {/* Body — drillable into /claims?claim=ID. We @@ -698,22 +414,13 @@ export function ReconciliationPage() { aria-label={`View claim ${c.id} in detail`} className="w-full text-left cursor-pointer rounded-sm p-0 m-0 border-0 bg-transparent" > -
+
{c.id}
-
+
{c.patientName}
-
+
{c.serviceDate ?? "—"} · $ {c.billedAmount.toFixed(2)} · NPI{" "} {c.providerNpi ?? "—"} @@ -730,10 +437,10 @@ export function ReconciliationPage() { aria-pressed={active} data-testid={`recon-claim-select-${c.id}`} className={cn( - "mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm text-[10.5px] mono uppercase tracking-[0.16em] font-semibold transition-colors", + "mt-2.5 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm text-[10.5px] mono uppercase tracking-[0.16em] font-semibold transition-colors border", active - ? "bg-[hsl(212_100%_45%)] text-white" - : "bg-[hsl(36_22%_92%)] text-[hsl(var(--surface-ink-2))] hover:bg-[hsl(212_85%_92%)]", + ? "bg-accent text-accent-foreground border-accent" + : "bg-transparent text-muted-foreground/80 border-border/70 hover:bg-accent/10 hover:text-accent hover:border-accent/40" )} > {active ? ( @@ -751,25 +458,18 @@ export function ReconciliationPage() { {/* Center bridge — a vertical "→" between the two columns */} -
+
{selectedClaim && selectedRemit ? ( @@ -782,8 +482,7 @@ export function ReconciliationPage() {
@@ -791,7 +490,7 @@ export function ReconciliationPage() { @@ -806,10 +505,10 @@ export function ReconciliationPage() { key={r.id} data-testid="recon-remit-card" className={cn( - "w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors", + "relative w-full text-left p-3.5 min-h-[44px] rounded-md border transition-all overflow-hidden", active - ? "border-[hsl(36_92%_50%)] bg-[hsl(36_82%_92%)] ring-1 ring-inset ring-[hsl(36_92%_50%_/_0.30)]" - : "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]" + ? "border-[hsl(var(--warning)/0.6)] bg-[hsl(var(--warning)/0.08)] shadow-[inset_2px_0_0_0_hsl(var(--warning))]" + : "border-border/70 bg-card/40 hover:bg-card/80 hover:border-border" )} > {/* Body — drillable. The whole body is one @@ -824,26 +523,15 @@ export function ReconciliationPage() { aria-label={`View remittance ${r.payerClaimControlNumber} in detail`} className="w-full text-left cursor-pointer rounded-sm p-0 m-0 border-0 bg-transparent" > -
+
{r.payerClaimControlNumber} {r.isReversal ? ( - - Reversal - + Reversal ) : null}
-
+
Status {r.status} · ${r.paidAmount.toFixed(2)} paid · $ {r.adjustmentAmount.toFixed(2)} adj
@@ -859,10 +547,10 @@ export function ReconciliationPage() { aria-pressed={active} data-testid={`recon-remit-select-${r.id}`} className={cn( - "mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm text-[10.5px] mono uppercase tracking-[0.16em] font-semibold transition-colors", + "mt-2.5 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm text-[10.5px] mono uppercase tracking-[0.16em] font-semibold transition-colors border", active - ? "bg-[hsl(36_92%_50%)] text-[hsl(30_14%_14%)]" - : "bg-[hsl(36_22%_92%)] text-[hsl(var(--surface-ink-2))] hover:bg-[hsl(36_82%_88%)]", + ? "bg-[hsl(var(--warning))] text-[hsl(var(--warning-foreground))] border-[hsl(var(--warning))]" + : "bg-transparent text-muted-foreground/80 border-border/70 hover:bg-[hsl(var(--warning)/0.1)] hover:text-[hsl(var(--warning))] hover:border-[hsl(var(--warning)/0.4)]" )} > {active ? ( @@ -879,119 +567,70 @@ export function ReconciliationPage() { })}
-
-
+ + + - {/* Action footer — § 03 The action */} -
-
- - § 03 - -
- - The action - -
-
-
- {selectedClaim && selectedRemit ? ( - <> - Ready to pair{" "} - - {selectedClaim} - {" "} - with{" "} - - {selectedRemit} - - . - - ) : selectedClaim ? ( - <>Now pick a remit on the right. - ) : selectedRemit ? ( - <>Now pick a claim on the left. - ) : ( - <>Pick one from each side to begin. - )} + {/* ACTION BAR — separate Card so the primary action sits in its + own surface with a clear status sentence + Clear / Match + buttons. Anchors the pairing gesture to a decisive end. */} +
+ + +
+
+ {selectedClaim && selectedRemit ? ( + <> + Ready to pair{" "} + + {selectedClaim} + {" "} + with{" "} + + {selectedRemit} + + . + + ) : selectedClaim ? ( + <>Now pick a remit on the right. + ) : selectedRemit ? ( + <>Now pick a claim on the left. + ) : ( + <>Pick one from each side to begin. + )} +
+
+ + +
-
- - -
-
-
- - {/* Footer */} -
- End of sheet 01 - Cyclone · Reconciliation - - {claims.length + remittances.length}{" "} - {claims.length + remittances.length === 1 ? "row" : "rows"} pending - -
-
+ + + {/* SP21 Phase 4 Task 4.6: RemitDrawer mount. The remits column drills into the parent remit via the PCN text (DrillableCell + open). The drawer portals into document.body, so the - surrounding paper plane stays put while the drawer is open. + surrounding surface stays put while the drawer is open. `remits` is empty (we don't keep a list of all remits on this page), so j/k is a no-op while the drawer is open. */} -
-
-
- {label} -
-
- -
-
-
- {value} -
-
- ); -} - function PairColumn({ eyebrow, count, @@ -1087,65 +660,37 @@ function PairColumn({ }: { eyebrow: string; count: number; - tone: "blue" | "amber"; + tone: "accent" | "warning"; empty: boolean; emptyMessage: string; children: React.ReactNode; }) { - const accent = - tone === "blue" ? "hsl(212 100% 45%)" : "hsl(36 92% 50%)"; - const tint = - tone === "blue" ? "hsl(212 85% 95%)" : "hsl(36 82% 92%)"; + const accentVar = + tone === "accent" ? "hsl(var(--accent))" : "hsl(var(--warning))"; + const labelText = + eyebrow === "Claims" ? "Unmatched claims" : "Unmatched remits"; return ( -
-
-
-
+
+ {/* Header bar — accent strip + label + count badge, all in + dark surface tokens. The eyebrow text on the header is the + "Unmatched claims" / "Unmatched remits" string that the + Reconciliation.test.tsx non-empty-state test asserts on. */} +
+
- {eyebrow} + aria-hidden + className="h-2.5 w-2.5 rounded-sm shrink-0" + style={{ backgroundColor: accentVar }} + /> + + {labelText}
- - {count} - + {count}
{empty ? ( -
+
{emptyMessage}
) : ( @@ -1154,4 +699,4 @@ function PairColumn({
); -} +} \ No newline at end of file diff --git a/src/pages/Upload.test.tsx b/src/pages/Upload.test.tsx index bf2ba62..5de448f 100644 --- a/src/pages/Upload.test.tsx +++ b/src/pages/Upload.test.tsx @@ -8,7 +8,8 @@ import React, { act, useEffect } from "react"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { MemoryRouter, useLocation } from "react-router-dom"; import { createRoot, type Root } from "react-dom/client"; -import { ClaimCard837, ClaimCard835 } from "./Upload"; +import { ClaimCard837 } from "@/components/ClaimCard837"; +import { ClaimCard835 } from "./Upload"; import { useAppStore } from "@/store"; import type { ClaimOutput, ClaimPayment, ParsedBatch } from "@/types"; diff --git a/src/pages/Upload.tsx b/src/pages/Upload.tsx index 8a4e5cd..d20172e 100644 --- a/src/pages/Upload.tsx +++ b/src/pages/Upload.tsx @@ -3,16 +3,17 @@ import { useNavigate } from "react-router-dom"; import { AlertTriangle, ArrowRight, - CheckCircle2, ChevronRight, CloudUpload, FileText, Inbox, Loader2, Upload as UploadIcon, + X, XCircle, } from "lucide-react"; import { toast } from "sonner"; +import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -22,16 +23,20 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { PageHeader } from "@/components/PageHeader"; +import { ClaimCard837 } from "@/components/ClaimCard837"; +import { ExportBar } from "@/components/ExportBar"; +import { StatPill, ValidationDot } from "@/components/ClaimCard/shared"; import { api, type ParseProgress } from "@/lib/api"; import { fmt, toNum } from "@/lib/format"; import { useAppStore } from "@/store"; import { useParse } from "@/hooks/useParse"; +import { useBatchExport } from "@/hooks/useBatchExport"; import type { ClaimOutput, ClaimPayment, ParsedBatch, ParsedBatchKind, - ServiceLine, ServicePayment, } from "@/types"; import { cn } from "@/lib/utils"; @@ -40,6 +45,11 @@ import { cn } from "@/lib/utils"; // Streaming state — every claim that arrives from the backend accumulates // in this list. The 837P and 835 cases are kept as separate slots so the UI // can render the right "card" type for each. +// +// NOTE: ClaimCard837 and ClaimCard835 use the --surface (warm paper) family +// on purpose — they read as "ingestion documents" within an otherwise dark +// instrument. The dark page chrome around them is what gives the section +// its place in the rest of the app. // --------------------------------------------------------------------------- type StreamedClaim837 = { kind: "837p"; data: ClaimOutput }; @@ -69,360 +79,6 @@ function formatBytes(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } -function ValidationDot({ - passed, - hasWarnings, -}: { - passed: boolean; - hasWarnings?: boolean; -}) { - if (passed) { - return ( - - - Passed - - ); - } - if (hasWarnings) { - return ( - - - Warnings - - ); - } - return ( - - - Failed - - ); -} - -function StatPill({ - label, - value, - mono = true, -}: { - label: string; - value: React.ReactNode; - mono?: boolean; -}) { - return ( -
-
- {label} -
-
- {value} -
-
- ); -} - -export function ClaimCard837({ claim }: { claim: ClaimOutput }) { - 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(); - return ( -
- - {open ? ( -
-
- - - - -
- - {claim.diagnoses.length > 0 ? ( -
-
- Diagnoses -
-
- {claim.diagnoses.map((d, i) => ( - - {d.qualifier ? `${d.qualifier}·` : ""} - {d.code} - - ))} -
-
- ) : null} - - {claim.service_lines.length > 0 ? ( -
-
- Service lines -
-
- - - - - - - - - - - - - {claim.service_lines.map((line) => ( - - ))} - -
#CodeModsDateUnitsCharge
-
-
- ) : null} - - {(claim.validation.errors.length > 0 || - claim.validation.warnings.length > 0) ? ( -
-
- Validation -
-
    - {claim.validation.errors.map((issue, i) => ( -
  • - - - {issue.rule} — {issue.message} - -
  • - ))} - {claim.validation.warnings.map((issue, i) => ( -
  • - - - {issue.rule} — {issue.message} - -
  • - ))} -
-
- ) : 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 ? ( -
- -
- ) : null} -
- ) : null} -
- ); -} - -function ServiceLine837Row({ line }: { line: ServiceLine }) { - return ( - - - {line.line_number} - - - {line.procedure.qualifier}·{line.procedure.code} - - - {line.procedure.modifiers.length > 0 - ? line.procedure.modifiers.join(", ") - : "—"} - - - {line.service_date ?? "—"} - - - {line.units ? `${toNum(line.units)} ${line.unit_type ?? ""}`.trim() : "—"} - - - {fmt.usdDecimal(line.charge)} - - - ); -} - export function ClaimCard835({ claim }: { claim: ClaimPayment }) { const [open, setOpen] = useState(false); const passed = claim.service_payments.length > 0; @@ -648,6 +304,43 @@ function ServicePaymentRow({ svc }: { svc: ServicePayment }) { ); } +// --------------------------------------------------------------------------- +// Hero stat cell — compact KPI-style cell for the page's hero strip. +// Echoes the dashboard's KPI rhythm in a tighter, single-row form. +// --------------------------------------------------------------------------- + +function HeroStat({ + label, + value, + tone = "default", +}: { + label: string; + value: React.ReactNode; + tone?: "default" | "accent" | "success"; +}) { + const toneClass = + tone === "accent" + ? "text-accent" + : tone === "success" + ? "text-[hsl(var(--success))]" + : "text-foreground"; + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- @@ -668,6 +361,19 @@ export function Upload() { const parsedBatches = useAppStore((s) => s.parsedBatches); const parseMutation = useParse(kind); + // Batch-export wiring (SP9 — Upload → ZIP flow). The hook owns the + // selection set, the exporting flag, the export handler, and the + // captured server-side batch id. See `src/hooks/useBatchExport.ts`. + const { + selectedClaimIds, + exporting, + total837InStream, + onToggleSelect, + onToggleAll, + onExport, + recordBatchId, + } = useBatchExport(stream.items); + const payerOptions = kind === "837p" ? PAYERS_837 : PAYERS_835; const expectedTotal = stream.expectedTotal; @@ -678,6 +384,30 @@ export function Upload() { return Math.min(100, Math.round((totalSoFar / expectedTotal) * 100)); }, [expectedTotal, totalSoFar]); + // Hero stat derivations — anchors the page in the same rhythm as the + // Dashboard's KPI row. Computed from the parsedBatches store. + const heroStats = useMemo(() => { + const totalClaims = parsedBatches.reduce((s, b) => s + b.claimCount, 0); + const totalPassed = parsedBatches.reduce((s, b) => s + b.passed, 0); + const passRate = totalClaims > 0 ? (totalPassed / totalClaims) * 100 : 0; + const last = parsedBatches[0]; + return { + batches: parsedBatches.length, + totalClaims, + passRate, + lastAt: last ? fmt.dateShort(last.parsedAt) : "—", + }; + }, [parsedBatches]); + + // Auto-select newly streamed 837P claims. Keyed on stream.items.length + // so this only fires when the count changes (i.e. a new claim arrived + // or the stream was reset). 835 streams don't carry the + // claim-id-into-export flow (out of scope for SP9), so their items + // are deliberately ignored. + // (Selection + export logic now lives in useBatchExport — see the + // hook call above. Keeping the comment as a breadcrumb for future + // readers who grep "Auto-select".) + function pickFile(f: File | null) { setFile(f); setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 }); @@ -746,6 +476,11 @@ export function Upload() { summary: finalSummary, }; addParsedBatch(batch); + // Capture the server-side batch id returned by the parser so + // onExport can call /api/batches/{server_id}/export-837. The + // client-side `id` (BATCH-{timestamp}) is the in-memory key; + // the server uses a UUID stored in the DB. + recordBatchId(finalSummary.batch_id ?? null); toast.success( `Parsed ${finalSummary.total_claims} ${kind === "837p" ? "claims" : "payments"} · ${ finalSummary.passed @@ -768,372 +503,258 @@ export function Upload() { } // ----------------------------------------------------------------- - // Stagger choreography + // Stagger choreography — hero lands first, drop zone at 140ms, + // stream at 240ms, batches at 320ms. Total < 500ms so it feels + // like the instrument powering up, not a long intro. // ----------------------------------------------------------------- const heroDelay = 0; - const foldDelay = 220; - const titleDelay = 320; - const dropDelay = 460; - const streamDelay = 620; - const batchesDelay = 760; + const dropDelay = 140; + const streamDelay = 240; + const batchesDelay = 320; return ( -
+
{/* ================================================================= - HERO — DARK EDITORIAL HEADER - Larger and bolder than the default PageHeader. The "X12" - wordmark sits italic as the page's anchor, and a ghost - "DROP" watermark fills the right side as a print-shop stamp. + HERO — same PageHeader pattern as Dashboard, with a subtle + ghost editorial watermark behind it (echoes the Dashboard's + ghost USD watermark, but uses claims count instead so the + Upload page's "scale" reads as "things ingested"). ================================================================= */}
- {/* Ghost wordmark — a large faded "DROP" stamp behind the title */} -
-
-
- - Upload · EDI parser - - - · {api.isConfigured ? "ready" : "no backend"} - +
+
+ + Upload an X12 file. + + } + subtitle={ + <> + Drop an 837P professional claim or 835 ERA remittance. + The parser streams claims back as they're produced, so + the instrument updates live. + + } + />
+
+ + {api.isConfigured ? ( + + ) : null} + + {api.isConfigured ? "Backend ready" : "No backend"} +
+
-

- Parse an{" "} - X12 -
- file. -

- -

- Drop an 837P professional claim or 835 ERA remittance file. The - parser streams claims back as they're produced so the UI updates - in real time. -

+ {/* Hero stat strip — four compact cells, anchored to the + parsedBatches store. Lives below the header, separated by + a hairline, in the same rhythm as Dashboard's KPI row but + tighter. */} +
+ + + 0 + ? `${heroStats.passRate.toFixed(1)}%` + : "—" + } + tone={heroStats.passRate >= 95 ? "success" : "default"} + /> +
{/* ================================================================= - FOLD — TORN PAGE + DROP ZONE — the page's centerpiece. Single large surface-2 + card with an inline payer-config header bar, a centered drop + area, and an action bar at the bottom. ================================================================= */} -
-
-
-
-
- {Array.from({ length: 48 }, (_, i) => ( - - ))} -
-
- - ↘ - - - Drop & parse - - - ↙ - -
-
- - {/* ================================================================= - PAPER PLANE — DROP ZONE - Title block → dropzone → stream results → recent batches - ================================================================= */} -
- {/* Paper grain */} -
\")", - backgroundSize: "160px 160px", - mixBlendMode: "multiply", - }} - /> - - {/* Title block */} -
-
-
-
-
- Ingestion · Sheet 01 -
-

- Drop a file. -

-
- The X12 837/835 file as exported from your clearinghouse. - The parser handles validation, line items, and service - payments on the way in. -
-
-
-
- Accepts -
-
- .txt -
-
- 837P · 835 -
-
- - - {api.isConfigured ? "Backend ready" : "No backend"} - -
-
-
-
- - {/* DROP ZONE — section § 01 */} -
- {/* Folio */} -
- - § 01 + {/* Top hairline + machine-tag header bar */} +
+
+ + Ingest -
- The drop + · .txt + + + · 837P / 835
- -
-
-
- Kind -
- -
-
-
- Payer config -
- -
-
-
{ - e.preventDefault(); - setDragging(true); - }} - onDragLeave={() => setDragging(false)} - onDrop={onDrop} - onClick={() => inputRef.current?.click()} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); - }} className={cn( - "relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed transition-colors", - "px-6 py-14 min-h-[180px] cursor-pointer", - dragging - ? "border-[hsl(212_100%_45%)] bg-[hsl(212_85%_95%)] ring-2 ring-[hsl(212_100%_45%_/_0.25)]" - : "border-[hsl(30_14%_14%/_0.22)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.35)]", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(212_100%_45%_/_0.4)]" + "inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.18em] font-semibold", + api.isConfigured + ? "text-[hsl(var(--success))]" + : "text-[hsl(var(--warning))]" )} > + + {api.isConfigured ? "Ready" : "No backend"} +
+
+ + {/* Payer config — Bloomberg-style form field row: small mono + label + control. Two selects side-by-side. */} +
+
+ + +
+
+ + +
+
+ + {/* Drop area — dashed border at idle, accent glow on drag */} +
{ + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + onClick={() => inputRef.current?.click()} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); + }} + aria-label="Drop a file here, or click to choose" + className={cn( + "relative rounded-lg border border-dashed transition-all duration-200", + "px-6 py-12 min-h-[200px] cursor-pointer overflow-hidden", + dragging + ? "border-accent bg-accent/[0.06] shadow-[inset_0_0_0_1px_hsl(var(--accent)/0.35),0_0_0_4px_hsl(var(--accent)/0.08)]" + : "border-border/70 bg-background/40 hover:bg-background/60 hover:border-border" + )} + > + {/* On drag — an accent scan-line sweeps across to reinforce + that the dropzone is hot. Pure CSS via the `animate-scan` + keyframe already in the Tailwind config. */} + {dragging ? ( +
+
+
+ ) : null} + +
{dragging ? ( @@ -1142,41 +763,33 @@ export function Upload() { )}
{file ? ( -
+
- + {file.name} - + · {formatBytes(file.size)}
) : ( <> -
- {dragging - ? "Release to upload" - : "Drop a file here, or click to choose"} +
+ {dragging ? ( + <> + Release to parse + + ) : ( + <> + Drop a file, or click to choose + + )}
-
- .txt — the X12 837/835 file as exported from your - clearinghouse +
+ .txt — X12 837/835 as exported from your clearinghouse
)} @@ -1188,200 +801,156 @@ export function Upload() { onChange={(e) => pickFile(e.target.files?.[0] ?? null)} />
+
-
-
- {api.isConfigured ? ( - - - Backend ready · {api.baseUrl} + {/* Action bar — backend status on the left, controls on the right */} +
+
+ {api.isConfigured ? ( + + + Target{" "} + + {api.baseUrl} - ) : ( - - - No backend configured — set VITE_API_BASE_URL to enable - parsing - - )} -
-
- {file ? ( - - ) : null} - -
+ + ) : ( + + + Set VITE_API_BASE_URL to enable parsing + + )}
-
- - {/* STREAM RESULTS — section § 02 */} - {(running || stream.items.length > 0) ? ( -
-
- + {file ? ( + + ) : null} + +
+
+ + + {/* ================================================================= + STREAM — appears while parsing or after a parse. Standard + Card wrapper so the section reads as the rest of the app. + ClaimCard837 / ClaimCard835 keep their warm-paper surface + so each claim reads as a document inside the panel. + ================================================================= */} + {(running || stream.items.length > 0) ? ( +
+ + + {/* Stream header — eyebrow + display title + progress stats */} +
+
+
+ + Stream + {running ? ( + + + + + ) : null}
-

+

{running ? ( <> - Parsing{" "} - live. + Parsing live. ) : ( <> - Stream{" "} - complete. + Stream complete. )} -

+
-
-
-
+
+
+
Parsed
-
+
{totalSoFar} - - {" "} - / {expectedTotal ?? "?"} + + / {expectedTotal ?? "—"}
-
+
Progress
-
+
{expectedTotal ? `${progressPct}%` : "…"}
+ {/* Progress bar — accent gradient, like the Inbox stream bar. */}
-
+ {/* Streamed claim list — ClaimCards stay as "documents". + For 837P streams, render the ExportBar above the list + so the user can select all / pick a subset / export. */} + {kind === "837p" && !running && total837InStream > 0 ? ( + + ) : null} + +
{stream.items.length === 0 && running ? ( -
+
Waiting for first claim…
) : null} @@ -1390,6 +959,8 @@ export function Upload() { ) : ( -
-
- ) : null} + + + + ) : null} - {/* RECENT BATCHES — section § 03 */} - {parsedBatches.length > 0 ? ( -
-
- - § 03 - -
- - The archive - -
- -
-
+ {/* ================================================================= + RECENT BATCHES — ledger-style list with sequential numbering. + Standard Card wrapper, numbered rows, kind badge + counts. + ================================================================= */} + {parsedBatches.length > 0 ? ( +
+ + +
-
- The archive +
+ + Recent batches
-

- Recent batches. -

+

+ Archive{" "} + + · {parsedBatches.length} + +

-
- Total +
+ Pass rate
-
- {parsedBatches.length} +
+ {heroStats.totalClaims > 0 + ? `${heroStats.passRate.toFixed(1)}%` + : "—"}
-
    - {parsedBatches.map((b) => ( -
  • - - {b.kind === "837p" ? "837P" : "835"} - -
    -
    - {b.inputFilename} -
    -
    - {fmt.dateShort(b.parsedAt)} · {b.claimCount}{" "} - {b.kind === "837p" ? "claims" : "payments"} -
    -
    -
    -
    - {b.passed} / {b.claimCount} -
    -
    - passed -
    -
    -
  • - ))} -
-
-
- ) : null} - {/* Inbox prompt when nothing parsed yet */} - {parsedBatches.length === 0 && stream.items.length === 0 && !running ? ( -
-
- -
+
    + {parsedBatches.map((b, i) => { + const passRatio = + b.claimCount > 0 + ? ((b.passed / b.claimCount) * 100).toFixed(1) + : "—"; + const idx = String(parsedBatches.length - i).padStart(2, "0"); + return ( +
  • + + #{idx} + + + {b.kind === "837p" ? "837P" : "835"} + +
    +
    + {b.inputFilename} +
    +
    + {fmt.dateShort(b.parsedAt)} · {b.claimCount}{" "} + {b.kind === "837p" ? "claims" : "payments"} +
    +
    +
    +
    + {b.passed} /{" "} + {b.claimCount} +
    +
    0 + ? "text-[hsl(var(--warning))]" + : "text-muted-foreground/70" + )} + > + {passRatio}% pass +
    +
    +
  • + ); + })} +
+ + +
+ ) : null} + + {/* ================================================================= + EMPTY STATE — shown only on a fresh browser with no parsed + batches and nothing currently streaming. + ================================================================= */} + {parsedBatches.length === 0 && stream.items.length === 0 && !running ? ( +
+ + +
+ +
+
No batches yet.
-
+
Drop a file above to start a parse.
-
-
- ) : null} - - {/* Footer */} -
- End of sheet 01 - Cyclone · Ingestion - - {parsedBatches.length}{" "} - {parsedBatches.length === 1 ? "batch" : "batches"} - -
-
+ + + + ) : null}
); -} +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 498fc6d..2a52b01 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 ------------------------------------------------------