feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens

Backend:

- New POST /api/batches/{id}/export-837: regenerate X12 837 files
  for a list of claim_ids into a ZIP using HCPF file naming standards,
  with a unique interchange/group control number per export. Wire
  the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
  (NM1*40) blocks so the serializer no longer falls back to
  CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
  batch_id in both JSON and NDJSON response shapes so the frontend
  can hit batch-scoped endpoints without an extra listBatches
  round-trip.
- Filename helpers and the 837 serializer updated to match the new
  HCPF envelope; tests cover batch export, parse batch_id, and the
  serializer's control-number uniqueness guarantee.

Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
  EditorialNote, ExportBar, TickerTape, and a charts/ set
  (BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
  the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
  colors to Tailwind theme tokens (bg-card, text-foreground,
  border/60, etc.) for consistency with the rest of the instrument
  chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
  reworked to compose the new shared components and consume the new
  batch-scoped API surface (notably ExportBar wired into Batches).

Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
  components and the useBatchExport hook.

Rolls up into the v0.2.0 release tag.
This commit is contained in:
Tyler
2026-06-22 11:01:58 -06:00
parent 35298907bc
commit d9bfb268f4
54 changed files with 6224 additions and 3871 deletions
+233
View File
@@ -0,0 +1,233 @@
// UI/UX Score Loop — pass 1 driver.
// Loads each route at three sizes in Chrome Canary, captures screenshots,
// logs console errors, runs a small interaction probe per flow, and
// writes a JSON report. Does not modify any source files.
import puppeteer from "puppeteer-core";
import { mkdir, writeFile } from "node:fs/promises";
const BASE = "http://127.0.0.1:5173";
const SHOTS = "/tmp/cyclone-uiux/shots";
const REPORT = "/tmp/cyclone-uiux/report.json";
const SIZES = [
{ name: "desktop", w: 1440, h: 900 },
{ name: "tablet", w: 768, h: 1024 },
{ name: "mobile", w: 375, h: 812 },
];
// Routes to load. path = the route; name = the flow label; ready = a
// selector we wait for to consider the page "rendered".
const FLOWS = [
{ name: "dashboard", path: "/", ready: "aside nav, h1, h2" },
{ name: "upload", path: "/upload", ready: "section[aria-label='File upload']" },
{ name: "inbox", path: "/inbox", ready: "main, section[aria-label='Queue summary']" },
{ name: "claims", path: "/claims", ready: "table, [data-testid='claims-page-body']" },
{ name: "claims-denied", path: "/claims?status=denied", ready: "table, [data-testid='claims-page-body']" },
{ name: "remittances", path: "/remittances", ready: "main, table" },
{ name: "providers", path: "/providers", ready: "main, table" },
{ name: "reconciliation",path: "/reconciliation", ready: "main" },
{ name: "acks", path: "/acks", ready: "main, table" },
{ name: "batches", path: "/batches", ready: "main, table" },
{ name: "batch-diff", path: "/batch-diff", ready: "main" },
{ name: "activity", path: "/activity", ready: "main" },
{ name: "404", path: "/does-not-exist", ready: "main" },
];
async function setupViewports(browser) {
const pages = [];
for (const size of SIZES) {
const page = await browser.newPage();
await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 });
pages.push({ page, size });
}
return pages;
}
async function probeFlow(page, flow) {
const consoleErrors = [];
const pageErrors = [];
const failedRequests = [];
const onConsole = (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
};
const onPageError = (err) => pageErrors.push(err.message);
const onRequestFailed = (req) => failedRequests.push(`${req.method()} ${req.url()} :: ${req.failure()?.errorText}`);
page.on("console", onConsole);
page.on("pageerror", onPageError);
page.on("requestfailed", onRequestFailed);
const t0 = Date.now();
let rendered = false;
let readyError = null;
try {
await page.goto(`${BASE}${flow.path}`, { waitUntil: "networkidle2", timeout: 15000 });
if (flow.ready) {
try {
await page.waitForSelector(flow.ready, { timeout: 5000 });
rendered = true;
} catch (e) {
readyError = e.message;
}
} else {
rendered = true;
}
} catch (e) {
readyError = e.message;
}
const loadMs = Date.now() - t0;
page.off("console", onConsole);
page.off("pageerror", onPageError);
page.off("requestfailed", onRequestFailed);
return { rendered, loadMs, readyError, consoleErrors, pageErrors, failedRequests };
}
async function probeInteractions(page, flow) {
const findings = [];
// Generic a11y / structural probes per flow.
try {
// Sidebar visible? (md+ shows it; < md hides it)
const aside = await page.$("aside");
findings.push({ check: "sidebar-present", pass: !!aside });
} catch (e) {
findings.push({ check: "sidebar-present", pass: false, err: e.message });
}
try {
// Top bar present?
const main = await page.$("main#main-content");
findings.push({ check: "main-present", pass: !!main });
} catch (e) {
findings.push({ check: "main-present", pass: false, err: e.message });
}
try {
// H1 or page heading?
const heading = await page.evaluate(() => {
const h = document.querySelector("h1, h2");
return h ? h.textContent?.trim().slice(0, 60) : null;
});
findings.push({ check: "heading-present", pass: !!heading, value: heading });
} catch (e) {
findings.push({ check: "heading-present", pass: false, err: e.message });
}
// Flow-specific probes.
if (flow.name === "claims" || flow.name === "claims-denied") {
try {
const chips = await page.$$("[role='radio'], button[role='radio']");
findings.push({ check: "status-chips", pass: chips.length >= 1, count: chips.length });
} catch (e) {
findings.push({ check: "status-chips", pass: false, err: e.message });
}
try {
const search = await page.$("input[placeholder*='Search']");
findings.push({ check: "search-input", pass: !!search });
} catch (e) {
findings.push({ check: "search-input", pass: false, err: e.message });
}
}
if (flow.name === "upload") {
try {
const dropzone = await page.$("section[aria-label='File upload']");
findings.push({ check: "dropzone-present", pass: !!dropzone });
const selects = await page.$$("button[role='combobox']");
findings.push({ check: "payer-kind-selects", pass: selects.length >= 2, count: selects.length });
} catch (e) {
findings.push({ check: "upload-elements", pass: false, err: e.message });
}
}
if (flow.name === "inbox") {
try {
const lanes = await page.$$("main > div > div");
findings.push({ check: "lane-cards", pass: lanes.length >= 1, count: lanes.length });
} catch (e) {
findings.push({ check: "lane-cards", pass: false, err: e.message });
}
}
if (flow.name === "404") {
try {
const text = await page.evaluate(() => document.body.innerText);
findings.push({ check: "404-text", pass: text.includes("404") || text.toLowerCase().includes("doesn't exist") });
} catch (e) {
findings.push({ check: "404-text", pass: false, err: e.message });
}
}
return findings;
}
async function main() {
await mkdir(SHOTS, { recursive: true });
const browser = await puppeteer.launch({
executablePath: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
headless: "new",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const startedAt = new Date().toISOString();
const results = [];
for (const size of SIZES) {
const page = await browser.newPage();
await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 });
for (const flow of FLOWS) {
const probe = await probeFlow(page, flow);
const interactions = await probeInteractions(page, flow);
const shot = `${SHOTS}/${flow.name}--${size.name}.png`;
try {
await page.screenshot({ path: shot, fullPage: false });
} catch (e) {
// ignore — recording in result
}
results.push({
flow: flow.name,
path: flow.path,
size: size.name,
viewport: { w: size.w, h: size.h },
probe,
interactions,
shot,
});
console.log(
`${size.name.padEnd(7)} ${flow.name.padEnd(20)} ` +
`render=${probe.rendered} load=${probe.loadMs}ms ` +
`consoleErr=${probe.consoleErrors.length} pageErr=${probe.pageErrors.length}`
);
}
await page.close();
}
await browser.close();
// Aggregate.
const summary = {
startedAt,
endedAt: new Date().toISOString(),
flows: results.length,
sizes: SIZES.map((s) => s.name),
renderedOk: results.filter((r) => r.probe.rendered).length,
withConsoleErrors: results.filter((r) => r.probe.consoleErrors.length > 0).length,
withPageErrors: results.filter((r) => r.probe.pageErrors.length > 0).length,
withFailedRequests: results.filter((r) => r.probe.failedRequests.length > 0).length,
results,
};
await writeFile(REPORT, JSON.stringify(summary, null, 2));
console.log("\nSummary:", JSON.stringify({
flows: summary.flows,
renderedOk: summary.renderedOk,
withConsoleErrors: summary.withConsoleErrors,
withPageErrors: summary.withPageErrors,
withFailedRequests: summary.withFailedRequests,
}, null, 2));
console.log("\nReport:", REPORT);
console.log("Shots:", SHOTS);
}
main().catch((e) => {
console.error("FATAL", e);
process.exit(1);
});
+316 -11
View File
@@ -35,7 +35,7 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import ValidationError from pydantic import ValidationError
from cyclone import __version__, db from cyclone import __version__, db
from cyclone.db import Claim, ClaimState, Remittance from cyclone.db import Batch, Claim, ClaimState, Remittance
from sqlalchemy import desc, or_ from sqlalchemy import desc, or_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from cyclone.inbox_state import apply_999_rejections from cyclone.inbox_state import apply_999_rejections
@@ -420,11 +420,18 @@ async def parse_837(
ack_body = _build_and_persist_ack(rec.id) ack_body = _build_and_persist_ack(rec.id)
if ack_body is not None: if ack_body is not None:
body["ack"] = ack_body 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) 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( return StreamingResponse(
_ndjson_stream(result), _ndjson_stream(result, batch_id=rec.id),
media_type="application/x-ndjson", media_type="application/x-ndjson",
) )
@@ -606,9 +613,12 @@ async def parse_835_endpoint(
body["reconciliation"] = _reconciliation_summary_for_batch(rec.id) body["reconciliation"] = _reconciliation_summary_for_batch(rec.id)
return JSONResponse(content=body) 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( return StreamingResponse(
_ndjson_stream_835(result), _ndjson_stream_835(result, batch_id=rec.id),
media_type="application/x-ndjson", 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") @app.get("/api/inbox/export.csv")
def inbox_export_csv(lane: str): def inbox_export_csv(lane: str):
"""Stream a CSV for a single lane.""" """Stream a CSV for a single lane."""
@@ -2321,6 +2540,51 @@ def submit_to_clearhouse(body: dict):
if ch is None: if ch is None:
raise HTTPException(status_code=500, detail="clearhouse not seeded") 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) client = make_client(ch.sftp_block)
results = [] results = []
for cid in claim_ids: for cid in claim_ids:
@@ -2329,6 +2593,34 @@ def submit_to_clearhouse(body: dict):
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
results.append({"claim_id": cid, "ok": False, "error": str(exc)}) results.append({"claim_id": cid, "ok": False, "error": str(exc)})
continue 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") filename = build_outbound_filename(ch.tpid, "837P")
remote = f"{ch.sftp_block.paths['outbound']}/{filename}" remote = f"{ch.sftp_block.paths['outbound']}/{filename}"
staging_path = client.write_file(remote, x12_text.encode("utf-8")) 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} 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 """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.parsers.serialize_837 import serialize_837
from cyclone import db from cyclone import db
with db.SessionLocal()() as s: 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 from cyclone.parsers.parse_837 import parse
raw = row.raw_json or {} raw = row.raw_json or {}
# Reconstruct minimal ClaimOutput from raw_json; this is best-effort. # 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. """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 For SP9 this delegates to the existing serialize_837 helper if the
claim has a complete raw_segments array. Otherwise it returns a 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.serialize_837 import serialize_837
from cyclone.parsers.parse_837 import parse 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"): if isinstance(raw, dict) and raw.get("x12_text"):
result = parse(raw["x12_text"]) result = parse(raw["x12_text"])
if result.claims: if result.claims:
return serialize_837(result.claims[0]) return serialize_837(result.claims[0], **kwargs)
# Fallback: raise so the caller sees an error. # Fallback: raise so the caller sees an error.
raise RuntimeError( raise RuntimeError(
f"claim {claim_row.id!r} cannot be re-serialized: no stored x12_text" f"claim {claim_row.id!r} cannot be re-serialized: no stored x12_text"
+30 -6
View File
@@ -93,19 +93,40 @@ def ndjson_stream_list(
}) + "\n" }) + "\n"
def ndjson_stream_837(result: ParseResult) -> Iterator[bytes]: def ndjson_stream_837(
"""Yield one JSON object per line: envelope → claims → summary.""" 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 = ( envelope_obj = (
result.envelope.model_dump() if result.envelope is not None else None result.envelope.model_dump() if result.envelope is not None else None
) )
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8") yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
for claim in result.claims: 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": "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]: def ndjson_stream_835(
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary.""" 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": "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": "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") 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") yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
for claim in result.claims: 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": "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: def strict_rewrite_837(result: ParseResult) -> ParseResult:
+11 -6
View File
@@ -4,8 +4,8 @@ SP9. Source-of-truth spec:
https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide) https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide)
Outbound (we send): Outbound (we send):
{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext} tp{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
Example: 11525703-837P-20260620132243505-1of1.x12 Example: tp11525703-837P-20260620132243505-1of1.x12
Inbound (HPE sends to our ToHPE): Inbound (HPE sends to our ToHPE):
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12 TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
@@ -28,14 +28,15 @@ from cyclone.providers import InboundFilename
# Regexes # Regexes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Outbound: 11525703-837P-20260620132243505-1of1.x12 # Outbound: tp11525703-837P-20260620132243505-1of1.x12
# - tp: literal "tp" prefix
# - tpid: 1+ digits # - tpid: 1+ digits
# - tx: 1+ alnum # - tx: 1+ alnum
# - ts: 17 digits (yyyymmddhhmmssSSS) # - ts: 17 digits (yyyymmddhhmmssSSS)
# - seq: literal "1of1" # - seq: literal "1of1"
# - ext: 1+ alnum # - ext: 1+ alnum
OUTBOUND_RE = re.compile( OUTBOUND_RE = re.compile(
r"^(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$" r"^tp(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
) )
# Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12 # Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
@@ -79,7 +80,8 @@ def build_outbound_filename(
time in ``America/Denver`` is used. time in ``America/Denver`` is used.
Returns: Returns:
Filename like "11525703-837P-20260620132243505-1of1.x12" Filename like "tp11525703-837P-20260620132243505-1of1.x12"
(note the ``tp`` prefix per HCPF outbound spec).
Raises: Raises:
ValueError: If tpid is non-numeric, tx contains invalid chars, or ValueError: If tpid is non-numeric, tx contains invalid chars, or
@@ -99,7 +101,10 @@ def build_outbound_filename(
# Format: yyyymmddhhmmssSSS — 17 digits total # Format: yyyymmddhhmmssSSS — 17 digits total
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}" ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
assert len(ts) == 17 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}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+9
View File
@@ -63,6 +63,7 @@ class ClaimHeader(_Base):
frequency_code: str | None = None frequency_code: str | None = None
provider_signature: str | None = None provider_signature: str | None = None
assignment: str | None = None assignment: str | None = None
benefits_assignment_certification: str | None = None # CLM08 (Y/N)
release_of_info: str | None = None release_of_info: str | None = None
prior_auth: str | None = None prior_auth: str | None = None
@@ -87,6 +88,14 @@ class ServiceLine(_Base):
place_of_service: str | None = None place_of_service: str | None = None
service_date: date | None = None service_date: date | None = None
provider_reference: str | 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): class ValidationIssue(_Base):
+8
View File
@@ -202,6 +202,7 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
frequency_code=freq or None, frequency_code=freq or None,
provider_signature=clm[6] if len(clm) > 6 else None, provider_signature=clm[6] if len(clm) > 6 else None,
assignment=clm[7] if len(clm) > 7 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, 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: except Exception:
units = None units = None
place_of_service = seg[5] if len(seg) > 5 else 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 service_date: date | None = None
provider_ref: str | 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, place_of_service=place_of_service,
service_date=service_date, service_date=service_date,
provider_reference=provider_ref, provider_reference=provider_ref,
dx_pointer=dx_pointer,
), ),
idx, idx,
) )
+149 -49
View File
@@ -112,7 +112,10 @@ def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> st
_FUNCTIONAL_ID_HEALTH_CARE, _FUNCTIONAL_ID_HEALTH_CARE,
sender_id, sender_id,
receiver_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(), _today_hhmm(),
group_control_number, group_control_number,
"X", "X",
@@ -186,17 +189,30 @@ def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
return _ELEM.join(parts) + _SEG return _ELEM.join(parts) + _SEG
def _build_per(contact_name: str | None, contact_phone: str | None) -> str: def _build_per(
"""PER segment — submitter contact. Returns empty when no contact info.""" contact_name: str | None,
if not contact_name and not contact_phone: contact_phone: str | None,
return "" contact_email: str | None = None,
parts = [ email_qual: str = "EM",
"PER", ) -> str:
"IC", # PER01 — contact function code (Information Contact) """PER segment — submitter contact (Loop 1000A).
contact_name or "",
"TE", # PER03 — phone qualifier X12 005010X222A1 *requires* at least one PER segment in Loop 1000A
contact_phone or "", (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 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 return _ELEM.join(parts) + _SEG
def _build_sbr(relationship_code: str | None, member_id: str | None, def _build_sbr(
payer_name: str | None) -> str: individual_relationship_code: str | None,
claim_filing_indicator_code: str | None,
) -> str:
"""SBR segment — subscriber information. """SBR segment — subscriber information.
SBR01 (relationship code) defaults to ``"P"`` (Patient = self) which is Slot layout (X12 005010X222A1):
the most common case for professional claims; the parser does not store SBR01 — Payer Responsibility Sequence Number Code. Default ``"P"``
this on the canonical Subscriber model so we cannot thread it through (Patient = primary). The parser does not capture this
without adding a model field. 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 = [ parts = [
"SBR", "SBR",
relationship_code or "P", "P", # SBR01 — primary
"", # SBR02 — group number individual_relationship_code or "18", # SBR02 — self
"", # SBR03 — group name "", # SBR03 — group number
"", # SBR04 — claim filing indicator code "", # SBR04 — group name
"", # SBR05 — sequence number code "", # SBR05 — insurance type code
payer_name or "", # SBR06 — claim filing indicator code (CO uses MC) "", # SBR06 — coordination of benefits
"", # SBR07 "", # SBR07 — yes/no condition
"", # SBR08 "", # SBR08 — employment status code
member_id or "", # SBR09 — claim submitter's id claim_filing_indicator_code or "", # SBR09 — claim filing indicator
] ]
return _ELEM.join(parts) + _SEG return _ELEM.join(parts) + _SEG
@@ -300,7 +330,11 @@ def _build_clm(claim) -> str:
clm05, # CLM05 — composite POS:qualifier:frequency_code clm05, # CLM05 — composite POS:qualifier:frequency_code
claim.provider_signature or "Y", # CLM06 claim.provider_signature or "Y", # CLM06
claim.assignment or "Y", # CLM07 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 claim.release_of_info or "Y", # CLM09
] ]
return _ELEM.join(parts) + _SEG return _ELEM.join(parts) + _SEG
@@ -325,21 +359,41 @@ def _build_lx(line_number: int) -> str:
return _ELEM.join(["LX", str(line_number)]) + _SEG return _ELEM.join(["LX", str(line_number)]) + _SEG
def _build_sv1(line) -> str: def _build_sv1(line, *, dx_pointer: str | None = None) -> str:
"""SV1 segment — professional service line.""" """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 proc = line.procedure
code = proc.code if proc else "" code = proc.code if proc else ""
mods = proc.modifiers if proc else [] mods = proc.modifiers if proc else []
composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4]) composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
charge = f"{Decimal(line.charge or 0):.2f}" charge = f"{Decimal(line.charge or 0):.2f}"
units = f"{Decimal(line.units):g}" if line.units is not None else "1" units = f"{Decimal(line.units):g}" if line.units is not None else "1"
sv1_07 = dx_pointer or ""
parts = [ parts = [
"SV1", "SV1",
composite, composite, # SV1-01
charge, charge, # SV1-02
line.unit_type or "UN", line.unit_type or "UN", # SV1-03 — unit basis code
units, units, # SV1-04
line.place_of_service or "", line.place_of_service or "", # SV1-05
"", # SV1-06 — NOT USED in 837P
sv1_07, # SV1-07 — diagnosis pointer
] ]
return _ELEM.join(parts) + _SEG 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, def _build_submitter_block(
contact_name: str | None, sender_id: str,
contact_phone: str | None) -> list[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 = [ out = [
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id), _build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
] ]
per = _build_per(contact_name, contact_phone) # PER is required by X12 (at least PER01). _build_per always emits
if per: # the segment; the submitter block always has exactly one.
out.append(per) out.append(_build_per(contact_name, contact_phone, contact_email, email_qual))
return out return out
@@ -395,11 +454,14 @@ def _build_billing_provider_block(provider) -> list[str]:
return out 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.""" """HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
out = [ out = [
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children _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( _build_nm1(
"IL", "IL", "IL", "IL",
f"{subscriber.last_name} {subscriber.first_name}".strip(), 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]: def _build_service_lines_block(service_lines, *, has_diagnoses: bool = False) -> list[str]:
"""Per line: LX / SV1 / DTP*472 / REF*6R.""" """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] = [] out: list[str] = []
for idx, line in enumerate(service_lines or [], start=1): for idx, line in enumerate(service_lines or [], start=1):
out.append(_build_lx(idx)) 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) dtp = _build_dtp_472(line.service_date)
if dtp: if dtp:
out.append(dtp) out.append(dtp)
@@ -450,7 +524,10 @@ def serialize_837(
submitter_name: str | None = None, submitter_name: str | None = None,
submitter_contact_name: str | None = None, submitter_contact_name: str | None = None,
submitter_contact_phone: 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, receiver_name: str | None = None,
claim_filing_indicator_code: str | None = None,
interchange_control_number: str = "000000001", interchange_control_number: str = "000000001",
group_control_number: str = "1", group_control_number: str = "1",
) -> str: ) -> str:
@@ -462,6 +539,16 @@ def serialize_837(
(``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass (``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
the configured values. 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 Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are
emitted from the canonical ``ClaimOutput`` fields, so post-parse emitted from the canonical ``ClaimOutput`` fields, so post-parse
edits propagate to the output. edits propagate to the output.
@@ -481,11 +568,13 @@ def serialize_837(
), ),
] ]
segments.extend(_build_submitter_block( 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_receiver_block(receiver_id, receiver_name))
segments.extend(_build_billing_provider_block(claim.billing_provider)) 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)) segments.extend(_build_payer_block(claim.payer))
# Claim-level editable segments. # Claim-level editable segments.
@@ -497,7 +586,10 @@ def serialize_837(
segments.append(_build_hi(claim.diagnoses)) segments.append(_build_hi(claim.diagnoses))
# Service lines (LX / SV1 / DTP*472 / REF*6R). # 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 # SE segment count includes ST (line 3, 1-based) through SE itself
# — i.e. the entire ST..SE block inclusive. # — i.e. the entire ST..SE block inclusive.
@@ -514,15 +606,23 @@ def serialize_837_for_resubmit(
claim: ClaimOutput, claim: ClaimOutput,
*, *,
interchange_index: int, interchange_index: int,
**kwargs,
) -> str: ) -> str:
"""Like :func:`serialize_837` but assigns deterministic-but-unique """Like :func:`serialize_837` but assigns deterministic-but-unique
interchange + group control numbers for a bundle position. interchange + group control numbers for a bundle position.
Interchange number = ``f"{interchange_index:09d}"``. Interchange number = ``f"{interchange_index:09d}"``.
Group number = ``str(interchange_index)``. 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( return serialize_837(
claim, claim,
interchange_control_number=f"{interchange_index:09d}", interchange_control_number=f"{interchange_index:09d}",
group_control_number=str(interchange_index), group_control_number=str(interchange_index),
**kwargs,
) )
+1 -1
View File
@@ -2277,7 +2277,7 @@ class CycloneStore:
submitter_contact_email="tyler@dzinesco.com", submitter_contact_email="tyler@dzinesco.com",
filename_block={ filename_block={
"tz": "America/Denver", "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", "inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
}, },
sftp_block={ sftp_block={
+8
View File
@@ -101,6 +101,14 @@ def test_parse_837_endpoint_streams_ndjson(client: TestClient):
# Summary numbers match the JSON path. # Summary numbers match the JSON path.
assert parsed[3]["data"]["total_claims"] == 2 assert parsed[3]["data"]["total_claims"] == 2
assert parsed[3]["data"]["passed"] == 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): def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
+7
View File
@@ -82,6 +82,13 @@ def test_parse_835_endpoint_streams_ndjson(client: TestClient):
# Summary numbers match the JSON path. # Summary numbers match the JSON path.
assert parsed[7]["data"]["total_claims"] == 2 assert parsed[7]["data"]["total_claims"] == 2
assert parsed[7]["data"]["passed"] == 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): def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
+317
View File
@@ -0,0 +1,317 @@
"""Tests for POST /api/batches/{batch_id}/export-837.
Reads Claim.raw_json for each requested claim_id and returns a ZIP of
regenerated X12 837 files. No DB state mutation. Mirrors the
X-Cyclone-Serialize-Errors convention from /api/inbox/rejected/resubmit?download=true.
"""
import io
import json
import zipfile
from pathlib import Path
from fastapi.testclient import TestClient
from cyclone.api import app
def _seed_batch(client: TestClient, filename: str = "claim.txt") -> dict:
"""Parse a single 837P file and return a dict with ``batch_id`` and
``claims``.
The parse-837 happy-path response does not currently surface
``batch_id`` at the top level (it's a server-side UUID, surfaced in
409 errors but not in 200s). We look it up from the DB — the Batch
row is already persisted by the time the parse endpoint returns.
"""
from cyclone import db
from cyclone.db import Batch
fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
r = client.post(
"/api/parse-837",
files={"file": (filename, io.BytesIO(fixture.encode()), "text/plain")},
headers={"Accept": "application/json"},
)
assert r.status_code == 200, r.text
body = r.json()
with db.SessionLocal()() as s:
most_recent = s.query(Batch).order_by(Batch.parsed_at.desc()).first()
assert most_recent is not None, "expected at least one Batch row after parse"
batch_id = most_recent.id
return {"batch_id": batch_id, "claims": body["claims"]}
def _claim_ids_from_seed(seeded: dict) -> list[str]:
return [c["claim_id"] for c in seeded["claims"]]
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
def test_happy_path_returns_zip_with_one_x12_per_claim():
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 200, r.text
assert r.headers["content-type"].startswith("application/zip")
assert "attachment" in r.headers["content-disposition"]
assert (
f"batch-{batch_id}-{len(claim_ids)}-claims.zip"
in r.headers["content-disposition"]
)
# Per-claim failures header absent on full success.
assert "x-cyclone-serialize-errors" not in {k.lower() for k in r.headers.keys()}
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
names = zf.namelist()
assert len(names) == len(claim_ids)
# Each entry follows the HCPF outbound naming template
# "{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12" (the seeded
# clearhouse TPID is "11525703"). Every name must be unique.
from cyclone.edi.filenames import is_outbound_filename
seen = set()
for name in names:
assert is_outbound_filename(name), (
f"expected HCPF outbound filename, got {name!r}"
)
assert name not in seen, f"duplicate filename: {name}"
seen.add(name)
with zf.open(name) as f:
first_line = f.readline().decode("ascii", errors="replace")
assert first_line.startswith("ISA*"), f"{name} didn't start with ISA"
def test_each_x12_in_zip_uses_clearhouse_submit_and_payer_receiver():
"""Regression: regenerated 837s used to emit 'CYCLONE' / 'RECEIVER'
placeholders. The export endpoint must thread the clearhouse
submitter (dzinesco's TPID 11525703) and payer receiver
(COMEDASSISTPROG) through to the serializer."""
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 200, r.text
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
for name in zf.namelist():
with zf.open(name) as f:
text = f.read().decode("ascii")
# Submitter block must use the clearhouse TPID + name, not
# the 'CYCLONE' placeholder.
assert "CYCLONE" not in text, f"{name} still emits CYCLONE placeholder"
assert "11525703" in text, f"{name} missing clearhouse TPID"
assert "Dzinesco" in text, f"{name} missing clearhouse name"
# Receiver block must use the CO_TXIX payer config
# (COMEDASSISTPROG), not the 'RECEIVER' placeholder.
assert "RECEIVER" not in text, f"{name} still emits RECEIVER placeholder"
assert "COMEDASSISTPROG" in text, f"{name} missing receiver id"
# Loop 1000A requires PER — must be present, not omitted.
assert "PER*IC*" in text, f"{name} missing required PER segment"
# SBR09 should be 'MC' (Medicaid claim filing indicator),
# not the member id.
sbr_line = next(seg for seg in text.split("~") if seg.startswith("SBR*"))
sbr09 = sbr_line.rstrip("~").split("*")[9]
assert sbr09 == "MC", f"{name} SBR09 expected 'MC', got {sbr09!r}"
def test_each_x12_in_zip_round_trips_through_parser():
"""Fidelity check: each .x12 must parse back to a ClaimOutput deep-equal
to the source row's raw_json (modulo recomputed validation)."""
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.payer import PayerConfig
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 200, r.text
by_id = {c["claim_id"]: c for c in seeded["claims"]}
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
for name in zf.namelist():
with zf.open(name) as f:
text = f.read().decode("ascii")
result = parse(text, PayerConfig(name="CO_MEDICAID"))
assert result.claims, f"{name} didn't parse back to any claims"
# Claim ids must round-trip (proves the serializer didn't drop
# or rewrite the id).
assert result.claims[0].claim.claim_id in by_id, (
f"{name} parsed to an unknown claim_id"
)
# ---------------------------------------------------------------------------
# Partial-failure surface
# ---------------------------------------------------------------------------
def test_partial_failure_one_claim_with_no_raw_json_returns_zip_and_errors_header():
"""If one claim has raw_json=None, the ZIP still returns for the others
and the failure is surfaced via X-Cyclone-Serialize-Errors."""
from cyclone import db
from cyclone.edi.filenames import is_outbound_filename
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
assert len(claim_ids) >= 2, "fixture must produce at least 2 claims"
# Wipe raw_json on one claim to simulate a corrupted row.
with db.SessionLocal()() as s:
from cyclone.db import Claim
target = s.get(Claim, claim_ids[0])
target.raw_json = None
s.commit()
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 200, r.text
# Filename uses SUCCESS count, not requested count.
expected_success = len(claim_ids) - 1
assert (
f"batch-{batch_id}-{expected_success}-claims.zip"
in r.headers["content-disposition"]
)
err_header = r.headers.get("x-cyclone-serialize-errors")
assert err_header, "expected X-Cyclone-Serialize-Errors header"
errs = json.loads(err_header)
assert len(errs) == 1
assert errs[0]["claim_id"] == claim_ids[0]
assert "raw_json" in errs[0]["reason"].lower()
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
names = zf.namelist()
assert len(names) == expected_success
# Every entry follows HCPF outbound template; none is the
# 'claim-CLM-X.x12' placeholder from the old endpoint.
for name in names:
assert is_outbound_filename(name), f"non-HCPF name: {name!r}"
def test_all_claims_fail_to_serialize_returns_422():
from cyclone import db
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
assert len(claim_ids) >= 1
with db.SessionLocal()() as s:
from cyclone.db import Claim
for cid in claim_ids:
c = s.get(Claim, cid)
c.raw_json = None
s.commit()
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 422, r.text
body = r.json()
# The 422 body surfaces the failure list so the UI can show details
# without parsing a header.
errs = body.get("detail", {}).get("serialize_errors") or body.get("serialize_errors")
assert errs is not None
assert len(errs) == len(claim_ids)
for entry in errs:
assert entry["claim_id"] in claim_ids
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
def test_empty_claim_ids_returns_400():
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": []},
)
assert r.status_code == 400, r.text
def test_missing_claim_ids_key_returns_400():
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={},
)
assert r.status_code == 400, r.text
def test_unknown_batch_id_returns_404():
with TestClient(app) as client:
r = client.post(
"/api/batches/BATCH-DOES-NOT-EXIST/export-837",
json={"claim_ids": ["CLM-1"]},
)
assert r.status_code == 404, r.text
body = r.json()
assert "BATCH-DOES-NOT-EXIST" in (body.get("detail") or "")
def test_unknown_claim_id_silently_omitted():
"""A claim_id that doesn't exist is omitted from the ZIP and surfaced
in the errors header — same convention as the resubmit endpoint."""
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
real_ids = _claim_ids_from_seed(seeded)
assert real_ids, "fixture must produce at least 1 claim"
requested = real_ids + ["CLM-GHOST"]
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": requested},
)
assert r.status_code == 200, r.text
# Filename uses success count = len(real_ids), not len(requested).
assert (
f"batch-{batch_id}-{len(real_ids)}-claims.zip"
in r.headers["content-disposition"]
)
err_header = r.headers.get("x-cyclone-serialize-errors")
assert err_header
errs = json.loads(err_header)
assert any(e["claim_id"] == "CLM-GHOST" for e in errs)
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
names = zf.namelist()
# HCPF outbound filenames, one per real claim. We don't pin the
# exact ts (it depends on the wall clock at test time), but the
# count and the HCPF template must match.
from cyclone.edi.filenames import is_outbound_filename
assert len(names) == len(real_ids)
for name in names:
assert is_outbound_filename(name), f"non-HCPF name: {name!r}"
@@ -0,0 +1,29 @@
"""Tests that /api/parse-837 includes the persisted batch_id in its
JSON response, so the frontend can correlate its in-memory batch with
the server's row (and later call /api/batches/{id}/export-837).
The 409 (duplicate) path has always included batch_id; the 200 path
did not. This brings the 200 path in line so the frontend doesn't have
to query listBatches after every parse just to learn the server's id.
"""
import io
from pathlib import Path
from fastapi.testclient import TestClient
from cyclone.api import app
def test_parse_837_happy_path_includes_batch_id_in_response():
with TestClient(app) as client:
fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
r = client.post(
"/api/parse-837",
files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")},
headers={"Accept": "application/json"},
)
assert r.status_code == 200, r.text
body = r.json()
assert "batch_id" in body, "parse-837 response must include batch_id so the frontend can call /api/batches/{id}/export-837"
# Sanity: the batch_id should be a non-empty string (UUID-shaped).
assert isinstance(body["batch_id"], str) and body["batch_id"]
+15 -5
View File
@@ -27,7 +27,8 @@ MT = ZoneInfo("America/Denver")
def test_build_outbound_with_explicit_mt(): def test_build_outbound_with_explicit_mt():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT) now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", now_mt=now) 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(): def test_build_outbound_default_extension():
@@ -39,15 +40,17 @@ def test_build_outbound_default_extension():
def test_build_outbound_custom_extension(): def test_build_outbound_custom_extension():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT) now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", ext="txt", now_mt=now) 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(): def test_build_outbound_uses_mt_when_no_arg():
# Snapshot test — the timestamp will be very recent; check format only # Snapshot test — the timestamp will be very recent; check format only
name = build_outbound_filename("11525703", "837P") name = build_outbound_filename("11525703", "837P")
assert OUTBOUND_RE.match(name), name assert OUTBOUND_RE.match(name), name
# tp11525703-837P-YYYYMMDDhhmmssSSS-1of1.x12 — 4 dash-separated parts
parts = name.split("-") parts = name.split("-")
assert len(parts) == 4 assert len(parts) == 4
assert parts[0] == "tp11525703"
assert len(parts[2]) == 17 # yyyymmddhhmmssSSS assert len(parts[2]) == 17 # yyyymmddhhmmssSSS
@@ -128,8 +131,9 @@ def test_parse_inbound_rejects_non_x12_ext():
def test_roundtrip_outbound_to_inbound(): def test_roundtrip_outbound_to_inbound():
# Outbound tpid is bare (no TP); inbound tpid is bare inside TP{...} # Outbound uses tp{...}, inbound uses TP{...} (case differs but both
# The two regexes use different shapes — round-trip via tpid only. # 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) now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
out = build_outbound_filename("11525703", "837P", now_mt=now) out = build_outbound_filename("11525703", "837P", now_mt=now)
assert OUTBOUND_RE.match(out) assert OUTBOUND_RE.match(out)
@@ -142,13 +146,19 @@ def test_roundtrip_outbound_to_inbound():
def test_is_outbound_filename(): 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("TP11525703-837P-20260620132243505-1of1.x12")
assert not is_outbound_filename("not-a-filename") assert not is_outbound_filename("not-a-filename")
def test_is_inbound_filename(): def test_is_inbound_filename():
assert is_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.x12") 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") assert not is_inbound_filename("11525703-837P-20260620132243505-1of1.x12")
+215 -8
View File
@@ -65,6 +65,21 @@ def test_build_gs_emits_gs_segment_with_hc_functional_id():
assert parts[6] == "1" 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(): def test_build_st_emits_837_segment():
st = _build_st("0001") st = _build_st("0001")
assert st.startswith("ST*837*0001*005010X222A1~") assert st.startswith("ST*837*0001*005010X222A1~")
@@ -100,12 +115,15 @@ def test_build_nm1_person_entity_splits_first_last():
assert parts[4] == "Jane" assert parts[4] == "Jane"
def test_build_per_returns_empty_when_no_contact(): def test_build_per_emits_per01_even_with_no_contact():
assert _build_per(None, None) == "" """PER is required by X12 Loop 1000A — at least PER01 must be present."""
assert _build_per("", "") == "" 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") per = _build_per("Jane Doe", "5551234567")
parts = per.rstrip("~").split("*") parts = per.rstrip("~").split("*")
assert parts[0] == "PER" assert parts[0] == "PER"
@@ -115,6 +133,25 @@ def test_build_per_emits_segment_with_contact():
assert parts[4] == "5551234567" 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(): def test_build_n3_returns_empty_when_no_address():
assert _build_n3(None, None) == "" assert _build_n3(None, None) == ""
assert _build_n3("", "") == "" assert _build_n3("", "") == ""
@@ -133,12 +170,33 @@ def test_build_hl_emits_segment():
assert hl == "HL*1**20*1~" assert hl == "HL*1**20*1~"
def test_build_sbr_emits_segment(): def test_build_sbr_emits_segment_with_correct_slots():
sbr = _build_sbr("18", "M123", "PAYER") """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("*") parts = sbr.rstrip("~").split("*")
assert parts[0] == "SBR" assert parts[0] == "SBR"
assert parts[1] == "18" # SBR01 — primary
assert parts[9] == "M123" 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(): 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" 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(): def test_build_ref_g1_returns_empty_when_no_prior_auth():
assert _build_ref_g1(None) == "" assert _build_ref_g1(None) == ""
assert _build_ref_g1("") == "" assert _build_ref_g1("") == ""
@@ -242,6 +328,57 @@ def test_build_sv1_emits_procedure_modifiers_charge_units():
assert parts[4] == "1" 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(): def test_build_dtp_472_emits_service_date():
assert _build_dtp_472(date(2026, 6, 15)) == "DTP*472*D8*20260615~" 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) parse(text, _CFG)
def test_serialize_837_emits_per_segment_in_submitter_block():
"""X12 Loop 1000A (Submitter Name) requires a PER segment after
NM1*41. The serializer must emit one even with no contact info
(PER01='IC' is the only required element)."""
claim = _load_claim()
text = serialize_837(claim)
# The first NM1*41 should be followed immediately by a PER segment.
seg_ids = [seg.split("*")[0] for seg in text.split("~") if seg]
nm1_41_idx = seg_ids.index("NM1") # first NM1 is the submitter
assert nm1_41_idx >= 0
# The very next segment must be PER (PER01='IC' is required by spec).
assert seg_ids[nm1_41_idx + 1] == "PER"
per_line = next(seg for seg in text.split("~") if seg.startswith("PER*IC"))
assert per_line.startswith("PER*IC")
def test_serialize_837_per_segment_includes_email_from_kwargs():
"""Passing submitter_contact_email should emit PER*IC*<name>*EM*<email>."""
claim = _load_claim()
text = serialize_837(
claim,
sender_id="DZINESCO",
submitter_name="Dzinesco",
submitter_contact_name="Tyler Martinez",
submitter_contact_email="tyler@dzinesco.com",
)
assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text
# And the ISA sender id should be the clearhouse TPID, not "CYCLONE".
assert "ZZ*DZINESCO" in text
assert "ZZ*CYCLONE" not in text
def test_serialize_837_sbr09_uses_claim_filing_indicator_code_kwarg():
"""SBR09 must be the claim filing indicator (e.g. 'MC' for Medicaid),
not the member id. The serializer takes it from the kwarg."""
claim = _load_claim()
text = serialize_837(claim, claim_filing_indicator_code="MC")
sbr_line = next(seg for seg in text.split("~") if seg.startswith("SBR*"))
parts = sbr_line.rstrip("~").split("*")
# SBR01 = P (primary), SBR02 = 18 (self), SBR09 = MC
assert parts[1] == "P"
assert parts[2] == "18"
assert parts[9] == "MC"
# And the member id should NOT be in SBR.
assert claim.subscriber.member_id not in sbr_line
def test_serialize_837_for_resubmit_forwards_kwargs_to_serialize_837():
"""serialize_837_for_resubmit is a thin wrapper — it must forward
clearhouse + payer kwargs so the export endpoint can use it."""
claim = _load_claim()
text = serialize_837_for_resubmit(
claim,
interchange_index=7,
sender_id="DZINESCO",
submitter_name="Dzinesco",
submitter_contact_name="Tyler Martinez",
submitter_contact_email="tyler@dzinesco.com",
receiver_id="COMEDASSISTPROG",
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
claim_filing_indicator_code="MC",
)
assert "ZZ*DZINESCO" in text
assert "ZZ*COMEDASSISTPROG" in text
assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text
# Control numbers reflect the resubmit index.
isa = next(seg for seg in text.split("~") if seg.startswith("ISA*"))
assert "000000007" in isa
def test_serialize_error_is_an_exception(): def test_serialize_error_is_an_exception():
assert issubclass(SerializeError, Exception) assert issubclass(SerializeError, Exception)
+2 -2
View File
@@ -31,13 +31,13 @@ def sftp_block(tmp_path):
def test_stub_writes_preserving_remote_path(sftp_block, tmp_path): def test_stub_writes_preserving_remote_path(sftp_block, tmp_path):
client = SftpClient(sftp_block) 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~") target = client.write_file(remote, b"ISA*00*...~IEA*1*1~")
assert target.exists() assert target.exists()
assert target.read_bytes() == b"ISA*00*...~IEA*1*1~" assert target.read_bytes() == b"ISA*00*...~IEA*1*1~"
# Confirm the full nested MFT path is preserved under staging # Confirm the full nested MFT path is preserved under staging
rel = target.relative_to(sftp_block.staging_dir) 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): def test_stub_creates_parent_dirs(sftp_block):
+1
View File
@@ -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~
+86
View File
@@ -0,0 +1,86 @@
// Shared visual primitives used by ClaimCard837 and ClaimCard835.
//
// Both cards render validation status (Passed / Warnings / Failed) and
// a grid of stat pills (Member, Place of service, …). The exact same
// mark-up was being copy-pasted inside Upload.tsx for both cards; this
// file is the shared home.
import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Tri-state validation indicator.
*
* - `passed` → green "Passed" with check icon
* - `hasWarnings` → amber "Warnings" with triangle
* - else → red "Failed" with X
*/
export function ValidationDot({
passed,
hasWarnings,
}: {
passed: boolean;
hasWarnings?: boolean;
}) {
if (passed) {
return (
<span
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--success))] font-medium"
title="Validation passed"
>
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.75} />
Passed
</span>
);
}
if (hasWarnings) {
return (
<span
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--warning))] font-medium"
title="Validation passed with warnings"
>
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={1.75} />
Warnings
</span>
);
}
return (
<span
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-destructive font-medium"
title="Validation failed"
>
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
Failed
</span>
);
}
/**
* Small label + value cell for the expanded-card detail grid.
*/
export function StatPill({
label,
value,
mono = true,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
}) {
return (
<div className="flex flex-col gap-1">
<div className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
{label}
</div>
<div
className={cn(
"text-[13px]",
mono && "display mono"
)}
style={{ color: "hsl(var(--surface-ink))" }}
>
{value}
</div>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
import React, { act } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { createRoot, type Root } from "react-dom/client";
import { useAppStore } from "@/store";
import { ClaimCard837 } from "./ClaimCard837";
import type { ClaimOutput } from "@/types";
// Tiny fixture — just enough to exercise the card's UI surface.
const CLAIM: ClaimOutput = {
claim_id: "CLM-TEST",
subscriber: { first_name: "Jane", last_name: "Doe", member_id: "MEM-1" },
payer: { name: "Test Payer", id: "P1" },
billing_provider: { npi: "1234567890" },
claim: {
total_charge: 100,
place_of_service: "11",
frequency_code: "1",
prior_auth: null,
},
service_lines: [
{
line_number: 1,
procedure: { qualifier: "HC", code: "99213", modifiers: [] },
charge: "100",
units: "1",
unit_type: "UN",
service_date: "2026-06-01",
},
],
diagnoses: [{ qualifier: "ABK", code: "J20.9" }],
validation: { passed: true, errors: [], warnings: [] },
};
function renderCard(
props: Partial<React.ComponentProps<typeof ClaimCard837>> = {},
): {
container: HTMLDivElement;
unmount: () => void;
} {
const container = document.createElement("div");
document.body.appendChild(container);
const root: Root = createRoot(container);
const onToggleSelect = props.onToggleSelect ?? (() => {});
act(() => {
root.render(
React.createElement(
MemoryRouter,
null,
React.createElement(ClaimCard837, {
claim: CLAIM,
selected: false,
onToggleSelect,
...props,
}),
),
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
describe("ClaimCard837 — checkbox + select interaction", () => {
beforeEach(() => {
useAppStore.setState({ parsedBatches: [] });
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders a checkbox with checked={selected}", () => {
const { container, unmount } = renderCard({ selected: true });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
);
expect(cb).not.toBeNull();
expect(cb!.checked).toBe(true);
unmount();
});
it("renders an unchecked checkbox when selected={false}", () => {
const { container, unmount } = renderCard({ selected: false });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
);
expect(cb!.checked).toBe(false);
unmount();
});
it("clicking the checkbox fires onToggleSelect with the claim_id", () => {
const onToggleSelect = vi.fn();
const { container, unmount } = renderCard({ onToggleSelect });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
act(() => {
cb.click();
});
expect(onToggleSelect).toHaveBeenCalledWith("CLM-TEST");
unmount();
});
it("clicking the checkbox does NOT toggle the card's expand state", () => {
// Regression guard: the card body is a <button> that toggles expand
// on click. The new checkbox must not bubble its click into that
// button (which would also fire onToggleSelect, double-counting).
// We assert this by checking the expanded content (the service lines
// table) is NOT in the DOM after a checkbox click.
const { container, unmount } = renderCard();
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
act(() => {
cb.click();
});
// The service lines table only renders when the card is expanded.
const table = container.querySelector("table");
expect(table).toBeNull();
unmount();
});
it("clicking the card body still toggles expand (existing behavior preserved)", () => {
const { container, unmount } = renderCard();
// The card body is a <button aria-expanded="false">. Clicking it
// should reveal the expanded details (service lines table).
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-expanded]',
);
expect(expandButton).not.toBeNull();
act(() => {
expandButton!.click();
});
// After expand, the service lines table is in the DOM.
const table = container.querySelector("table");
expect(table).not.toBeNull();
unmount();
});
it("checkbox has an accessible label that names the claim", () => {
const { container, unmount } = renderCard();
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
);
// aria-label or wrapping <label> — at minimum the input must be
// findable by an accessible name. The simplest assertion is that an
// aria-label exists on the input itself.
expect(
cb!.getAttribute("aria-label") ||
cb!.closest("label")?.textContent,
).toContain("CLM-TEST");
unmount();
});
});
+352
View File
@@ -0,0 +1,352 @@
// ClaimCard837 — the warm-paper card that represents one 837P claim in
// the Upload page's streaming section. Originally inlined in
// `src/pages/Upload.tsx`; extracted into its own file in SP9 (June 2026)
// so the per-claim select-for-export interaction can live here without
// bloating the page.
//
// Two click targets, by design (see approach A in the batch-export
// design spec): a leading checkbox column for selection, and the rest
// of the card (a <button>) for expand/collapse. Putting the checkbox
// inside the button would be an a11y anti-pattern (nested interactive
// elements).
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertTriangle,
ArrowRight,
ChevronRight,
XCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useAppStore } from "@/store";
import { fmt, toNum } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ClaimOutput, ServiceLine } from "@/types";
import { StatPill, ValidationDot } from "./ClaimCard/shared";
export interface ClaimCard837Props {
claim: ClaimOutput;
/**
* Whether this card is currently selected for the batch export.
* Controlled by the parent (Upload.tsx) via the per-claim checkbox.
* The checkbox column only renders when the parent passes
* `onToggleSelect` — keeps the component backward-compatible with
* callers that don't need the export flow (e.g. the persisted
* batch's "Recent batches" view, when we add it).
*/
selected?: boolean;
/** Called with the claim's id when the user clicks the checkbox. */
onToggleSelect?: (claimId: string) => void;
}
export function ClaimCard837({ claim, selected = false, onToggleSelect }: ClaimCard837Props) {
const [open, setOpen] = useState(false);
const passed = claim.validation.passed;
const hasWarnings = claim.validation.warnings.length > 0;
// SP21 Phase 5 Task 5.7: a "See claim in detail →" link drills to
// /claims?claim=ID — but ONLY when this streamed claim_id has
// actually been persisted to a parsed batch. The Upload page
// streams claims as the parser emits them; until the user clicks
// "Save batch" the claim isn't visible to ClaimDrawer.
const parsedBatches = useAppStore((s) => s.parsedBatches);
const persistedClaimIds = useMemo(
() => new Set(parsedBatches.flatMap((b) => b.claimIds)),
[parsedBatches],
);
const canDrill = persistedClaimIds.has(claim.claim_id);
const navigate = useNavigate();
const showCheckbox = typeof onToggleSelect === "function";
return (
<div
className="rounded-lg overflow-hidden border flex"
style={{
backgroundColor: "hsl(36 22% 96%)",
borderColor: "hsl(30 14% 14% / 0.10)",
boxShadow: "inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
}}
>
{/* Selection column — only when the parent wires up selection. */}
{showCheckbox ? (
<label
className="flex items-center pl-3 pr-2 cursor-pointer shrink-0"
// Belt-and-suspenders: the label is a sibling of the button
// below, so click events don't bubble into it. stopPropagation
// here guards against future refactors that might nest the
// label inside the button.
onClick={(e) => e.stopPropagation()}
data-testid={`claim-card-select-${claim.claim_id}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => onToggleSelect?.(claim.claim_id)}
aria-label={`Select claim ${claim.claim_id} for export`}
className="h-3.5 w-3.5 rounded border-border accent-accent cursor-pointer"
/>
</label>
) : null}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"flex-1 min-w-0 text-left px-4 py-3 hover:bg-[hsl(36_22%_92%)] transition-colors",
!showCheckbox && "rounded-l-lg",
)}
aria-expanded={open}
>
<div className="flex items-center gap-3">
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform shrink-0",
open && "rotate-90"
)}
strokeWidth={1.75}
style={{ color: "hsl(var(--surface-ink-3))" }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<span
className="display mono text-[12.5px]"
style={{ color: "hsl(var(--surface-ink))" }}
>
{claim.claim_id}
</span>
<span
className="text-[12px] truncate"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{claim.subscriber.first_name} {claim.subscriber.last_name}
</span>
<span
className="text-[12px]"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
· {claim.payer.name}
</span>
</div>
<div
className="mono text-[10.5px] flex items-center gap-2 mt-1"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
<span>NPI {claim.billing_provider.npi}</span>
<span className="opacity-40">·</span>
<span>
{claim.service_lines.length} line
{claim.service_lines.length === 1 ? "" : "s"}
</span>
<span className="opacity-40">·</span>
<span>{claim.diagnoses.length} dx</span>
</div>
</div>
<div className="text-right shrink-0">
<div
className="display mono text-[14px]"
style={{ color: "hsl(var(--surface-ink))" }}
>
{fmt.usdDecimal(claim.claim.total_charge)}
</div>
<div className="mt-0.5">
<ValidationDot passed={passed} hasWarnings={hasWarnings} />
</div>
</div>
</div>
</button>
{open ? (
<div
className="basis-full border-t px-4 py-3 grid gap-4 animate-fade-in"
style={{
borderColor: "hsl(30 14% 14% / 0.08)",
backgroundColor: "hsl(36 22% 92%)",
}}
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatPill label="Member" value={claim.subscriber.member_id} />
<StatPill
label="Place of service"
value={claim.claim.place_of_service ?? "—"}
/>
<StatPill label="Frequency" value={claim.claim.frequency_code ?? "—"} />
<StatPill
label="Prior auth"
value={claim.claim.prior_auth ?? "—"}
/>
</div>
{claim.diagnoses.length > 0 ? (
<div>
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Diagnoses
</div>
<div className="flex flex-wrap gap-1.5">
{claim.diagnoses.map((d, i) => (
<Badge key={`${d.code}-${i}`} variant="muted">
{d.qualifier ? `${d.qualifier}·` : ""}
{d.code}
</Badge>
))}
</div>
</div>
) : null}
{claim.service_lines.length > 0 ? (
<div>
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Service lines
</div>
<div
className="rounded-md border overflow-x-auto"
style={{
borderColor: "hsl(30 14% 14% / 0.10)",
backgroundColor: "hsl(36 22% 98%)",
}}
>
<table className="w-full text-[12px]">
<thead style={{ backgroundColor: "hsl(36 22% 90%)" }}>
<tr className="text-left" style={{ color: "hsl(var(--surface-ink-2))" }}>
<th className="px-2.5 py-1.5 font-medium">#</th>
<th className="px-2.5 py-1.5 font-medium">Code</th>
<th className="px-2.5 py-1.5 font-medium">Mods</th>
<th className="px-2.5 py-1.5 font-medium">Date</th>
<th className="px-2.5 py-1.5 font-medium">Units</th>
<th className="px-2.5 py-1.5 font-medium text-right">Charge</th>
</tr>
</thead>
<tbody>
{claim.service_lines.map((line) => (
<ServiceLine837Row key={line.line_number} line={line} />
))}
</tbody>
</table>
</div>
</div>
) : null}
{(claim.validation.errors.length > 0 ||
claim.validation.warnings.length > 0) ? (
<div>
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Validation
</div>
<ul className="space-y-1 text-[12px]">
{claim.validation.errors.map((issue, i) => (
<li
key={`e-${i}`}
className="flex items-start gap-2 text-destructive"
>
<XCircle
className="h-3.5 w-3.5 mt-0.5 shrink-0"
strokeWidth={1.75}
/>
<span>
<span className="mono">{issue.rule}</span> {issue.message}
</span>
</li>
))}
{claim.validation.warnings.map((issue, i) => (
<li
key={`w-${i}`}
className="flex items-start gap-2 text-[hsl(var(--warning))]"
>
<AlertTriangle
className="h-3.5 w-3.5 mt-0.5 shrink-0"
strokeWidth={1.75}
/>
<span>
<span className="mono">{issue.rule}</span> {issue.message}
</span>
</li>
))}
</ul>
</div>
) : null}
{/* SP21 Phase 5 Task 5.7: drill to the persisted claim. Only
renders when this streamed claim_id has been saved into
a parsed batch (so ClaimDrawer can find it). Before
"Save batch" the claim is streaming-only and the link
would 404. */}
{canDrill ? (
<div className="pt-1">
<button
type="button"
onClick={() =>
navigate(
`/claims?claim=${encodeURIComponent(claim.claim_id)}`,
)
}
data-testid="upload-claim-drill"
aria-label={`See claim ${claim.claim_id} in detail`}
className="inline-flex items-center gap-1.5 text-[12px] mono font-semibold cursor-pointer rounded-sm px-1 -mx-1 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
style={{ color: "hsl(var(--surface-ink))" }}
>
See claim in detail
<ArrowRight className="h-3 w-3" strokeWidth={2} />
</button>
</div>
) : null}
</div>
) : null}
</div>
);
}
function ServiceLine837Row({ line }: { line: ServiceLine }) {
return (
<tr
className="border-t"
style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}
>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink))" }}
>
{line.line_number}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink))" }}
>
{line.procedure.qualifier}·{line.procedure.code}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{line.procedure.modifiers.length > 0
? line.procedure.modifiers.join(", ")
: "—"}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{line.service_date ?? "—"}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{line.units ? `${toNum(line.units)} ${line.unit_type ?? ""}`.trim() : "—"}
</td>
<td
className="px-2.5 py-1.5 mono text-right display"
style={{ color: "hsl(var(--surface-ink))" }}
>
{fmt.usdDecimal(line.charge)}
</td>
</tr>
);
}
+14 -12
View File
@@ -141,12 +141,14 @@ export function ClaimDrawer({
// Right-anchored side panel. We override the Dialog primitive's // Right-anchored side panel. We override the Dialog primitive's
// default centered positioning (`left-1/2 top-1/2 -translate-x-1/2 // 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-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. // 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 // `aria-describedby={undefined}` suppresses the Radix warning
// about a missing description — the drawer content is its own // about a missing description — the drawer content is its own
// description and there's nothing useful to point at. // 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" data-testid="claim-drawer"
aria-describedby={undefined} aria-describedby={undefined}
> >
@@ -167,7 +169,7 @@ export function ClaimDrawer({
> >
<ClaimDrawerHeader claim={data} onClose={onClose} /> <ClaimDrawerHeader claim={data} onClose={onClose} />
<div <div
className="flex gap-0 px-6 border-b border-[color:var(--surface-line)]/40" className="flex gap-0 px-6 border-b border-border/40"
data-testid="claim-drawer-tabs" data-testid="claim-drawer-tabs"
> >
<button <button
@@ -176,15 +178,15 @@ export function ClaimDrawer({
className={cn( className={cn(
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors", "relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
activeTab === "details" activeTab === "details"
? "text-[color:var(--surface-ink)]" ? "text-foreground"
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]" : "text-muted-foreground hover:text-foreground"
)} )}
data-testid="tab-button-details" data-testid="tab-button-details"
data-active={activeTab === "details" ? "true" : "false"} data-active={activeTab === "details" ? "true" : "false"}
> >
Details Details
{activeTab === "details" ? ( {activeTab === "details" ? (
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" /> <span className="absolute inset-x-0 -bottom-px h-[2px] bg-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
) : null} ) : null}
</button> </button>
<button <button
@@ -193,29 +195,29 @@ export function ClaimDrawer({
className={cn( className={cn(
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors", "relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
activeTab === "line-reconciliation" activeTab === "line-reconciliation"
? "text-[color:var(--surface-ink)]" ? "text-foreground"
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]" : "text-muted-foreground hover:text-foreground"
)} )}
data-testid="tab-button-line-reconciliation" data-testid="tab-button-line-reconciliation"
data-active={activeTab === "line-reconciliation" ? "true" : "false"} data-active={activeTab === "line-reconciliation" ? "true" : "false"}
> >
Line Reconciliation Line Reconciliation
{activeTab === "line-reconciliation" ? ( {activeTab === "line-reconciliation" ? (
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" /> <span className="absolute inset-x-0 -bottom-px h-[2px] bg-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
) : null} ) : null}
</button> </button>
</div> </div>
{activeTab === "line-reconciliation" ? ( {activeTab === "line-reconciliation" ? (
lr.loading ? ( lr.loading ? (
<p <p
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]" className="px-6 py-4 text-sm text-muted-foreground"
data-testid="line-reconciliation-loading" data-testid="line-reconciliation-loading"
> >
Loading line reconciliation Loading line reconciliation
</p> </p>
) : lr.error ? ( ) : lr.error ? (
<p <p
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]" className="px-6 py-4 text-sm text-muted-foreground"
data-testid="line-reconciliation-error" data-testid="line-reconciliation-error"
> >
Failed to load line reconciliation. Failed to load line reconciliation.
@@ -224,7 +226,7 @@ export function ClaimDrawer({
<LineReconciliationTab data={lr.data} /> <LineReconciliationTab data={lr.data} />
) : null ) : null
) : ( ) : (
<div className="flex flex-col divide-y divide-[color:var(--surface-line)]/12"> <div className="flex flex-col divide-y divide-border/40">
<ValidationPanel validation={data.validation} /> <ValidationPanel validation={data.validation} />
<ServiceLinesTable <ServiceLinesTable
serviceLines={data.serviceLines} serviceLines={data.serviceLines}
@@ -32,21 +32,21 @@ export function ClaimDrawerError({ kind, onRetry, onClose }: ClaimDrawerErrorPro
return ( return (
<div <div
className="flex flex-col items-start gap-4 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]" className="flex flex-col items-start gap-4 p-6 bg-card text-foreground"
role="alert" role="alert"
data-testid={`claim-drawer-error-${kind}`} data-testid={`claim-drawer-error-${kind}`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon <Icon
className="h-5 w-5 text-[color:var(--m-error)]" className="h-5 w-5 text-destructive"
strokeWidth={1.75} strokeWidth={1.75}
aria-hidden aria-hidden
/> />
<span className="eyebrow text-[color:var(--m-error)]"> <span className="eyebrow text-destructive">
{eyebrow} {eyebrow}
</span> </span>
</div> </div>
<p className="text-sm text-[color:var(--m-ink-secondary)] max-w-sm">{message}</p> <p className="text-sm text-muted-foreground max-w-sm">{message}</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{kind === "network" && onRetry ? ( {kind === "network" && onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry"> <Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
@@ -10,7 +10,7 @@ import { Skeleton } from "@/components/ui/skeleton";
export function ClaimDrawerSkeleton() { export function ClaimDrawerSkeleton() {
return ( return (
<div <div
className="flex flex-col gap-6 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]" className="flex flex-col gap-6 p-6 bg-card text-foreground"
data-testid="claim-drawer-skeleton" data-testid="claim-drawer-skeleton"
aria-busy="true" aria-busy="true"
> >
+5 -5
View File
@@ -35,7 +35,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
<section className="flex flex-col gap-3 px-6 py-4"> <section className="flex flex-col gap-3 px-6 py-4">
<h3 <h3
data-testid="section-label" data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]" className="eyebrow text-muted-foreground"
> >
Diagnoses ({diagnoses.length}) Diagnoses ({diagnoses.length})
</h3> </h3>
@@ -43,7 +43,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
{diagnoses.length === 0 ? ( {diagnoses.length === 0 ? (
<p <p
data-testid="diagnoses-empty" data-testid="diagnoses-empty"
className="text-sm text-[color:var(--m-ink-tertiary)]" className="text-sm text-muted-foreground"
> >
No diagnoses No diagnoses
</p> </p>
@@ -58,17 +58,17 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
className="flex items-baseline gap-3 text-sm" className="flex items-baseline gap-3 text-sm"
> >
<span <span
className="mono text-[color:var(--m-ink-primary)] font-medium" className="mono text-foreground font-medium"
> >
{d.qualifier ? `${d.qualifier} ${d.code}` : d.code} {d.qualifier ? `${d.qualifier} ${d.code}` : d.code}
</span> </span>
{desc ? ( {desc ? (
<> <>
<span <span
className="h-px w-3 bg-[color:var(--m-border-heavy)]/30" className="h-px w-3 bg-border/60"
aria-hidden aria-hidden
/> />
<span className="text-[color:var(--m-ink-secondary)] italic"> <span className="text-muted-foreground italic">
{desc} {desc}
</span> </span>
</> </>
@@ -31,26 +31,26 @@ export function LineReconciliationTab({
> >
<div className="flex gap-5 font-mono tabular-nums text-sm"> <div className="flex gap-5 font-mono tabular-nums text-sm">
<span data-testid="billed-total" className="flex flex-col gap-0.5"> <span data-testid="billed-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow"> <span className="text-muted-foreground eyebrow">
Billed Billed
</span> </span>
<span className="text-[color:var(--m-ink-primary)] font-semibold"> <span className="text-foreground font-semibold">
{fmt.usdPrecise(Number(summary.billedTotal))} {fmt.usdPrecise(Number(summary.billedTotal))}
</span> </span>
</span> </span>
<span data-testid="paid-total" className="flex flex-col gap-0.5"> <span data-testid="paid-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow"> <span className="text-muted-foreground eyebrow">
Paid Paid
</span> </span>
<span className="text-[color:var(--m-ink-primary)] font-semibold"> <span className="text-foreground font-semibold">
{fmt.usdPrecise(Number(summary.paidTotal))} {fmt.usdPrecise(Number(summary.paidTotal))}
</span> </span>
</span> </span>
<span data-testid="adjustments-total" className="flex flex-col gap-0.5"> <span data-testid="adjustments-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow"> <span className="text-muted-foreground eyebrow">
Adjustments Adjustments
</span> </span>
<span className="text-[color:var(--m-ink-primary)] font-semibold"> <span className="text-foreground font-semibold">
{fmt.usdPrecise(Number(summary.adjustmentTotal))} {fmt.usdPrecise(Number(summary.adjustmentTotal))}
</span> </span>
</span> </span>
@@ -70,7 +70,7 @@ export function LineReconciliationTab({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div data-testid="left-column"> <div data-testid="left-column">
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2"> <h3 className="eyebrow text-muted-foreground mb-2">
Billed (837 SV1) Billed (837 SV1)
</h3> </h3>
{leftRows.map((row) => ( {leftRows.map((row) => (
@@ -78,7 +78,7 @@ export function LineReconciliationTab({
))} ))}
</div> </div>
<div data-testid="right-column"> <div data-testid="right-column">
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2"> <h3 className="eyebrow text-muted-foreground mb-2">
Adjudicated (835 SVC) Adjudicated (835 SVC)
</h3> </h3>
{rightRows.map((row) => ( {rightRows.map((row) => (
@@ -114,7 +114,7 @@ function LineCard({
return ( return (
<div <div
className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-[color:var(--m-ink-tertiary)]/5" className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-muted/40"
style={{ style={{
borderLeft: `3px solid ${accentColor}`, borderLeft: `3px solid ${accentColor}`,
background: "rgba(255,255,255,0.02)", background: "rgba(255,255,255,0.02)",
@@ -123,9 +123,9 @@ function LineCard({
> >
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<div className="font-mono text-sm"> <div className="font-mono text-sm">
<span className="font-semibold text-[color:var(--m-ink-primary)]">{procCode}</span> <span className="font-semibold text-foreground">{procCode}</span>
{lineNumber !== null ? ( {lineNumber !== null ? (
<span className="text-[color:var(--m-ink-tertiary)] ml-2"> <span className="text-muted-foreground ml-2">
#{lineNumber} #{lineNumber}
</span> </span>
) : null} ) : null}
@@ -146,16 +146,16 @@ function LineCard({
</span> </span>
) : null} ) : null}
</div> </div>
<div className="font-mono tabular-nums text-xs text-[color:var(--m-ink-primary)] font-semibold"> <div className="font-mono tabular-nums text-xs text-foreground font-semibold">
{side === "left" && cl ? <span>{fmt.usdPrecise(Number(cl.charge))}</span> : null} {side === "left" && cl ? <span>{fmt.usdPrecise(Number(cl.charge))}</span> : null}
{side === "right" && svc ? <span>{fmt.usdPrecise(Number(svc.payment))}</span> : null} {side === "right" && svc ? <span>{fmt.usdPrecise(Number(svc.payment))}</span> : null}
</div> </div>
</div> </div>
{row.adjustments.length > 0 ? ( {row.adjustments.length > 0 ? (
<ul className="text-xs font-mono text-[color:var(--m-ink-tertiary)] mt-1 space-y-0.5"> <ul className="text-xs font-mono text-muted-foreground mt-1 space-y-0.5">
{row.adjustments.map((a, i) => ( {row.adjustments.map((a, i) => (
<li key={i}> <li key={i}>
<span className="text-[color:var(--m-ink-secondary)]">{a.groupCode}-{a.reasonCode}</span> <span className="text-foreground/80">{a.groupCode}-{a.reasonCode}</span>
<span className="ml-2 tabular-nums">{fmt.usdPrecise(Number(a.amount))}</span> <span className="ml-2 tabular-nums">{fmt.usdPrecise(Number(a.amount))}</span>
</li> </li>
))} ))}
@@ -56,20 +56,20 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
> >
<h3 <h3
data-testid="section-label" data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]" className="eyebrow text-muted-foreground"
> >
Matched Remittance Matched Remittance
</h3> </h3>
<div <div
data-testid="matched-remit-card-inner" data-testid="matched-remit-card-inner"
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 p-4 sm:flex-row sm:items-start sm:justify-between" className="flex flex-col gap-4 rounded-lg border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between"
> >
{/* Left: remit id + status badge + received date */} {/* Left: remit id + status badge + received date */}
<div className="flex min-w-0 flex-col gap-2"> <div className="flex min-w-0 flex-col gap-2">
<span <span
data-testid="matched-remit-id" data-testid="matched-remit-id"
className="mono text-sm text-[color:var(--m-ink-primary)]" className="mono text-sm text-foreground"
> >
{id} {id}
</span> </span>
@@ -83,7 +83,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
</Badge> </Badge>
<span <span
data-testid="matched-remit-received" data-testid="matched-remit-received"
className="text-xs text-[color:var(--m-ink-tertiary)]" className="text-xs text-muted-foreground"
> >
Received {fmt.date(receivedAt)} Received {fmt.date(receivedAt)}
</span> </span>
@@ -94,7 +94,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
<div className="flex flex-col items-start gap-2 sm:items-end"> <div className="flex flex-col items-start gap-2 sm:items-end">
<span <span
data-testid="matched-remit-total-paid" data-testid="matched-remit-total-paid"
className="display text-3xl text-[color:var(--m-ink-primary)] tabular-nums" className="display text-3xl text-foreground tabular-nums"
> >
{fmt.usdPrecise(totalPaid)} {fmt.usdPrecise(totalPaid)}
</span> </span>
@@ -103,7 +103,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
size="sm" size="sm"
onClick={handleViewRemittance} onClick={handleViewRemittance}
data-testid="view-remittance-link" data-testid="view-remittance-link"
className="gap-1 text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)]" className="gap-1 text-muted-foreground hover:text-foreground"
> >
View remittance View remittance
<ArrowRight <ArrowRight
+7 -7
View File
@@ -28,7 +28,7 @@ function AddressBlock({ address }: { address: AddressLike }) {
const a = address as ClaimDetailAddress; const a = address as ClaimDetailAddress;
return ( return (
<div <div
className="mt-1 border-t border-dashed border-[color:var(--m-border-heavy)]/30 pt-2 text-xs leading-relaxed text-[color:var(--m-ink-secondary)]" className="mt-1 border-t border-dashed border-border/60 pt-2 text-xs leading-relaxed text-muted-foreground"
data-testid="party-address" data-testid="party-address"
> >
<div>{a.line1}</div> <div>{a.line1}</div>
@@ -63,9 +63,9 @@ function PartyCard({
return ( return (
<div <div
data-testid={testId} data-testid={testId}
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 px-4 py-3 transition-colors hover:border-[color:var(--m-border-heavy)]/50" className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/30 px-4 py-3 transition-colors hover:border-border hover:bg-muted/40"
> >
<span className="eyebrow text-[color:var(--m-ink-tertiary)]"> <span className="eyebrow text-muted-foreground">
{label} {label}
</span> </span>
{onNameClick ? ( {onNameClick ? (
@@ -74,17 +74,17 @@ function PartyCard({
onClick={onNameClick} onClick={onNameClick}
data-testid={`${testId}-name-drill`} data-testid={`${testId}-name-drill`}
aria-label={`Drill into ${label.toLowerCase()} ${name}`} aria-label={`Drill into ${label.toLowerCase()} ${name}`}
className="display text-lg text-[color:var(--m-ink-primary)] text-left cursor-pointer drillable rounded-sm px-0 -mx-0" className="display text-lg text-foreground text-left cursor-pointer drillable rounded-sm px-0 -mx-0"
> >
{name} {name}
</button> </button>
) : ( ) : (
<div className="display text-lg text-[color:var(--m-ink-primary)]"> <div className="display text-lg text-foreground">
{name} {name}
</div> </div>
)} )}
<div <div
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums" className="mono text-[12.5px] text-muted-foreground tabular-nums"
> >
{identity} {identity}
</div> </div>
@@ -120,7 +120,7 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
> >
<h3 <h3
data-testid="section-label" data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]" className="eyebrow text-muted-foreground"
> >
Parties Parties
</h3> </h3>
@@ -33,17 +33,17 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
> >
<h3 <h3
data-testid="section-label" data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]" className="eyebrow text-muted-foreground"
> >
Raw Segments ({rawSegments.length}) Raw Segments ({rawSegments.length})
</h3> </h3>
<details className="group rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/40 overflow-hidden transition-colors hover:border-[color:var(--m-border-heavy)]/50"> <details className="group rounded-lg border border-border/60 bg-muted/30 overflow-hidden transition-colors hover:border-border">
<summary <summary
className="cursor-pointer select-none px-4 py-2.5 text-sm text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)] transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2" className="cursor-pointer select-none px-4 py-2.5 text-sm text-muted-foreground hover:text-foreground transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2"
data-testid="raw-segments-summary" data-testid="raw-segments-summary"
> >
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[color:var(--m-ink-tertiary)] group-open:bg-[color:var(--m-accent)] transition-colors" /> <span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground group-open:bg-accent transition-colors" />
{rawSegments.length === 0 {rawSegments.length === 0
? `Show raw segments` ? `Show raw segments`
: `Show ${rawSegments.length} raw segment${rawSegments.length === 1 ? "" : "s"}`} : `Show ${rawSegments.length} raw segment${rawSegments.length === 1 ? "" : "s"}`}
@@ -52,14 +52,14 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
{rawSegments.length === 0 ? ( {rawSegments.length === 0 ? (
<p <p
data-testid="raw-segments-empty" data-testid="raw-segments-empty"
className="px-4 pb-3 text-sm text-[color:var(--m-ink-tertiary)]" className="px-4 pb-3 text-sm text-muted-foreground"
> >
No segments No segments
</p> </p>
) : ( ) : (
<pre <pre
data-testid="raw-segments-pre" data-testid="raw-segments-pre"
className="overflow-x-auto whitespace-pre-wrap border-t border-[color:var(--m-border-heavy)]/20 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-[color:var(--m-ink-primary)] font-mono" className="overflow-x-auto whitespace-pre-wrap border-t border-border/40 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-foreground font-mono"
style={{ fontFamily: "var(--m-font-mono)" }} style={{ fontFamily: "var(--m-font-mono)" }}
> >
{rawSegments.map((segment, i) => ( {rawSegments.map((segment, i) => (
@@ -49,7 +49,7 @@ export function ServiceLinesTable({
<section className="flex flex-col gap-3 px-6 py-4"> <section className="flex flex-col gap-3 px-6 py-4">
<h3 <h3
data-testid="section-label" data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]" className="eyebrow text-muted-foreground"
> >
Service Lines ({serviceLines.length}) Service Lines ({serviceLines.length})
</h3> </h3>
@@ -57,7 +57,7 @@ export function ServiceLinesTable({
{serviceLines.length === 0 ? ( {serviceLines.length === 0 ? (
<p <p
data-testid="service-lines-empty" data-testid="service-lines-empty"
className="text-sm text-[color:var(--m-ink-tertiary)]" className="text-sm text-muted-foreground"
> >
No service lines No service lines
</p> </p>
@@ -84,34 +84,34 @@ export function ServiceLinesTable({
data-line-number={line.lineNumber} data-line-number={line.lineNumber}
className="row-hover" className="row-hover"
> >
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]"> <TableCell className="font-mono text-muted-foreground">
{line.lineNumber} {line.lineNumber}
</TableCell> </TableCell>
<TableCell className="font-mono text-[color:var(--m-ink-primary)]"> <TableCell className="font-mono text-foreground">
{formatProcedure(line)} {formatProcedure(line)}
</TableCell> </TableCell>
<TableCell <TableCell
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)] font-semibold" className="text-right font-mono text-base tabular-nums text-foreground font-semibold"
style={{ fontFamily: "var(--m-font-mono)" }} style={{ fontFamily: "var(--m-font-mono)" }}
> >
{fmt.usdPrecise(line.charge)} {fmt.usdPrecise(line.charge)}
</TableCell> </TableCell>
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]"> <TableCell className="font-mono text-muted-foreground">
{formatUnits(line)} {formatUnits(line)}
</TableCell> </TableCell>
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]"> <TableCell className="font-mono text-muted-foreground">
{line.serviceDate ? fmt.date(line.serviceDate) : ""} {line.serviceDate ? fmt.date(line.serviceDate) : ""}
</TableCell> </TableCell>
<TableCell <TableCell
data-testid="paid-cell" data-testid="paid-cell"
className="text-right font-mono tabular-nums text-[color:var(--m-ink-primary)]" className="text-right font-mono tabular-nums text-foreground"
style={{ fontFamily: "var(--m-font-mono)" }} style={{ fontFamily: "var(--m-font-mono)" }}
> >
{formatMoneyOrDash(lr?.paid ?? null)} {formatMoneyOrDash(lr?.paid ?? null)}
</TableCell> </TableCell>
<TableCell <TableCell
data-testid="adjustments-cell" data-testid="adjustments-cell"
className="text-right font-mono tabular-nums text-[color:var(--m-ink-secondary)]" className="text-right font-mono tabular-nums text-muted-foreground"
style={{ fontFamily: "var(--m-font-mono)" }} style={{ fontFamily: "var(--m-font-mono)" }}
> >
{formatMoneyOrDash(lr?.adjustmentsSum ?? null)} {formatMoneyOrDash(lr?.adjustmentsSum ?? null)}
@@ -64,7 +64,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
> >
<h3 <h3
data-testid="section-label" data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]" className="eyebrow text-muted-foreground"
> >
State History ({history.length}) State History ({history.length})
</h3> </h3>
@@ -72,14 +72,14 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
{history.length === 0 ? ( {history.length === 0 ? (
<p <p
data-testid="state-history-empty" data-testid="state-history-empty"
className="text-sm text-[color:var(--m-ink-tertiary)]" className="text-sm text-muted-foreground"
> >
No history events No history events
</p> </p>
) : ( ) : (
<ol <ol
data-testid="state-history-timeline" data-testid="state-history-timeline"
className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-[color:var(--m-border-heavy)]/40 pl-6" className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-border/40 pl-6"
> >
{history.map((event, i) => ( {history.map((event, i) => (
<li <li
@@ -91,18 +91,18 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
<span <span
data-testid="state-history-dot" data-testid="state-history-dot"
aria-hidden aria-hidden
className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-[color:var(--m-surface)] ${dotColorFor(event.kind)}`} className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-card ${dotColorFor(event.kind)}`}
/> />
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5"> <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span <span
data-testid="state-history-kind" data-testid="state-history-kind"
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-primary)]" className="text-[10px] font-semibold uppercase tracking-[0.14em] text-foreground"
> >
{kindLabel(event.kind)} {kindLabel(event.kind)}
</span> </span>
<span <span
data-testid="state-history-ts" data-testid="state-history-ts"
className="mono text-xs text-[color:var(--m-ink-secondary)] tabular-nums" className="mono text-xs text-muted-foreground tabular-nums"
> >
{fmt.date(event.ts)} · {fmt.time(event.ts)} {fmt.date(event.ts)} · {fmt.time(event.ts)}
</span> </span>
@@ -110,7 +110,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
{event.remittanceId ? ( {event.remittanceId ? (
<span <span
data-testid="history-remit-id" data-testid="history-remit-id"
className="mono text-xs text-[color:var(--m-ink-tertiary)]" className="mono text-xs text-muted-foreground"
> >
Remit {event.remittanceId} Remit {event.remittanceId}
</span> </span>
+13 -13
View File
@@ -63,12 +63,12 @@ function IssueGroup({
onClick={() => openPeek({ kind: "rule", rule })} onClick={() => openPeek({ kind: "rule", rule })}
data-testid={`${testId}-rule-drill`} data-testid={`${testId}-rule-drill`}
aria-label={`Drill into rule ${rule}`} 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} {rule}
</button> </button>
<span <span
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums" className="inline-flex items-center rounded-full bg-muted-foreground/15 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground tabular-nums"
data-testid={`${testId}-count`} data-testid={`${testId}-count`}
> >
{issues.length} {issues.length}
@@ -78,7 +78,7 @@ function IssueGroup({
{issues.map((issue, idx) => ( {issues.map((issue, idx) => (
<li <li
key={`${rule}-${idx}`} key={`${rule}-${idx}`}
className="flex items-start gap-2 text-[13px] leading-snug text-[color:var(--m-ink-secondary)]" className="flex items-start gap-2 text-[13px] leading-snug text-muted-foreground"
data-testid={`${testId}-message`} data-testid={`${testId}-message`}
> >
<Icon <Icon
@@ -121,14 +121,14 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
className="flex items-center gap-2 px-6 py-3" className="flex items-center gap-2 px-6 py-3"
> >
<CheckCircle2 <CheckCircle2
className="h-4 w-4 text-[color:var(--m-success)]" className="h-4 w-4 text-[hsl(var(--success))]"
strokeWidth={1.75} strokeWidth={1.75}
aria-hidden aria-hidden
/> />
<span className="text-[13px] font-medium text-[color:var(--m-ink-primary)]"> <span className="text-[13px] font-medium text-foreground">
All checks passed All checks passed
</span> </span>
<span className="ml-auto inline-flex items-center rounded-full bg-[color:var(--m-success)]/12 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-success)]"> <span className="ml-auto inline-flex items-center rounded-full bg-[hsl(var(--success)/0.12)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[hsl(var(--success))]">
Valid Valid
</span> </span>
</section> </section>
@@ -140,10 +140,10 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
return ( return (
<section <section
className="flex flex-col gap-4 px-6 py-4 bg-[color:var(--m-surface)]" className="flex flex-col gap-4 px-6 py-4"
data-testid="validation-panel" data-testid="validation-panel"
> >
<span className="eyebrow text-[color:var(--m-ink-tertiary)]"> <span className="eyebrow text-muted-foreground">
Validation Validation
</span> </span>
@@ -152,15 +152,15 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
data-testid="validation-errors" data-testid="validation-errors"
data-rule-group="errors" data-rule-group="errors"
role="alert" role="alert"
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.06)] px-3.5 py-3" className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.08)] px-3.5 py-3"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle <AlertCircle
className="h-3.5 w-3.5 text-[color:var(--m-error)]" className="h-3.5 w-3.5 text-destructive"
strokeWidth={1.75} strokeWidth={1.75}
aria-hidden aria-hidden
/> />
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-error)]"> <span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-destructive">
Errors ({validation.errors.length}) Errors ({validation.errors.length})
</span> </span>
</div> </div>
@@ -185,11 +185,11 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertTriangle <AlertTriangle
className="h-3.5 w-3.5 text-[color:var(--m-warning)]" className="h-3.5 w-3.5 text-[hsl(var(--warning))]"
strokeWidth={1.75} strokeWidth={1.75}
aria-hidden aria-hidden
/> />
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-warning)]"> <span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[hsl(var(--warning))]">
Warnings ({validation.warnings.length}) Warnings ({validation.warnings.length})
</span> </span>
</div> </div>
+167
View File
@@ -0,0 +1,167 @@
// ---------------------------------------------------------------------------
// DominantKpiCard — the headline KPI tile.
//
// One KPI gets the dominant slot on the dashboard. It carries:
// - a thicker accent rule on the left edge
// - an oversized Instrument Serif number
// - a wide sparkline (not a 28px afterthought)
// - delta + hint row in mono
// Same paper variant as the smaller KpiCard. Designed to live INSIDE
// the statement, next to a column of 4 smaller readouts.
// ---------------------------------------------------------------------------
import { ArrowDownRight, ArrowUpRight, type LucideIcon } from "lucide-react";
import * as React from "react";
import { Sparkline } from "./Sparkline";
import { AnimatedNumber } from "./AnimatedNumber";
import { cn } from "@/lib/utils";
interface DominantKpiCardProps {
label: string;
icon?: LucideIcon;
/** Pre-rendered value node. Required unless `rawValue`+`format`
* are both supplied, in which case the AnimatedNumber is used. */
value?: React.ReactNode;
/** Used when value is a plain number — drives the AnimatedNumber. */
rawValue?: number;
/** Formatter for the AnimatedNumber. */
format?: (n: number) => string;
delta?: { value: string; direction: "up" | "down"; positive: boolean };
hint?: string;
sparkline?: number[];
/** Sparkline stroke colour. Defaults to accent. */
sparklineColor?: string;
/** Accent rail colour (left edge). Defaults to accent. */
accent?: string;
className?: string;
style?: React.CSSProperties;
}
export function DominantKpiCard({
label,
icon: Icon,
value,
rawValue,
format,
delta,
hint,
sparkline,
sparklineColor = "hsl(var(--accent))",
accent = "hsl(var(--accent))",
className,
style,
}: DominantKpiCardProps) {
const computedStyle: React.CSSProperties = {
backgroundColor: "hsl(var(--surface))",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06), inset 3px 0 0 0 hsl(0 0% 100% / 0.4)",
...style,
};
return (
<div
style={computedStyle}
className={cn(
"relative rounded-xl p-6 flex flex-col gap-4 overflow-hidden",
"border border-[hsl(30_14%_14%/_0.10)] hover:bg-[hsl(36_22%_92%)] transition-colors",
className
)}
>
{/* Accent rail on the left — thicker than the small cards */}
<div
aria-hidden
className="absolute left-0 top-5 bottom-5 w-[3px] rounded-r-sm"
style={{ backgroundColor: accent, opacity: 0.85 }}
/>
{/* Header row: label + icon */}
<div className="flex items-center justify-between">
<div
className="eyebrow flex items-center gap-2"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
<span>{label}</span>
{hint ? (
<span
className="mono normal-case tracking-normal"
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10,
opacity: 0.7,
}}
>
· {hint}
</span>
) : null}
</div>
{Icon ? (
<div
className="h-7 w-7 rounded-md flex items-center justify-center"
style={{
backgroundColor: "hsl(36 22% 90%)",
boxShadow: "inset 0 0 0 1px hsl(30 14% 22% / 0.08)",
color: "hsl(var(--surface-ink-2))",
}}
>
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
</div>
) : null}
</div>
{/* Big number */}
<div
className="display tabular-nums tracking-[-0.04em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(48px, 6vw, 72px)",
lineHeight: 0.92,
fontWeight: 400,
}}
>
{rawValue !== undefined && format ? (
<AnimatedNumber value={rawValue} format={format} />
) : (
value
)}
</div>
{/* Delta + sparkline */}
<div className="flex items-end justify-between gap-4">
<div className="flex items-center gap-2 text-[11px] min-h-[16px]">
{delta ? (
<span
className={cn(
"inline-flex items-center gap-0.5 font-medium mono",
delta.positive
? "text-[hsl(var(--success))]"
: "text-destructive"
)}
>
{delta.direction === "up" ? (
<ArrowUpRight className="h-3 w-3" strokeWidth={2} />
) : (
<ArrowDownRight className="h-3 w-3" strokeWidth={2} />
)}
{delta.value}
</span>
) : null}
<span
className="mono uppercase tracking-[0.14em]"
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10,
}}
>
vs last 6 mo
</span>
</div>
{sparkline ? (
<div className="flex-1 max-w-[240px]">
<Sparkline values={sparkline} stroke={sparklineColor} />
</div>
) : null}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
// ---------------------------------------------------------------------------
// EditorialNote — italic Instrument Serif margin annotations.
//
// A small italicized serif note that "speaks" to the data next to it.
// Used in the dashboard to narrate the day's narrative: "Three NPIs
// are in flight" sits next to the KPI grid; an editorial note sits
// beside the chart explaining what it shows. The note itself is a
// single italic line of Instrument Serif with a tiny serif ampersand
// or hairline rule introducing it.
//
// This is the dashboard's "voice" — the moment the data stops being
// silent and reads as a story.
// ---------------------------------------------------------------------------
import * as React from "react";
import { cn } from "@/lib/utils";
export interface EditorialNoteProps {
children: React.ReactNode;
/** Optional lead-in (e.g. "↘", "§", "—" or a short label). */
lead?: string;
/** Optional secondary line shown below the note in mono caps. */
caption?: string;
/** Tone — dark canvas (default) or paper. */
tone?: "dark" | "paper";
className?: string;
}
export function EditorialNote({
children,
lead,
caption,
tone = "dark",
className,
}: EditorialNoteProps) {
const noteColor =
tone === "paper"
? "hsl(var(--surface-ink))"
: "hsl(var(--foreground))";
const leadColor =
tone === "paper"
? "hsl(var(--surface-ink-3))"
: "hsl(var(--muted-foreground))";
const captionColor =
tone === "paper"
? "hsl(var(--surface-ink-3))"
: "hsl(var(--muted-foreground))";
return (
<div className={cn("flex items-start gap-3", className)}>
{lead ? (
<span
aria-hidden
className="display italic shrink-0 leading-none mt-1"
style={{ color: leadColor, fontSize: 18 }}
>
{lead}
</span>
) : null}
<div className="min-w-0">
<p
className="display italic leading-snug"
style={{
color: noteColor,
fontSize: 15,
letterSpacing: "-0.005em",
}}
>
{children}
</p>
{caption ? (
<p
className="mono uppercase tracking-[0.18em] mt-1.5"
style={{ color: captionColor, fontSize: 9.5 }}
>
{caption}
</p>
) : null}
</div>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
import React, { act, useState } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { ExportBar } from "./ExportBar";
function renderBar(
props: Partial<React.ComponentProps<typeof ExportBar>> = {},
): {
container: HTMLDivElement;
unmount: () => void;
} {
const container = document.createElement("div");
document.body.appendChild(container);
const root: Root = createRoot(container);
const onToggleAll = props.onToggleAll ?? (() => {});
const onExport = props.onExport ?? (() => {});
act(() => {
root.render(
React.createElement(ExportBar, {
total: 10,
selectedCount: 10,
exporting: false,
onToggleAll,
onExport,
...props,
}),
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
describe("ExportBar", () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders the total and selected count", () => {
const { container, unmount } = renderBar({ total: 24, selectedCount: 18 });
expect(container.textContent).toContain("18");
expect(container.textContent).toContain("24");
unmount();
});
it("the select-all checkbox is checked when all items are selected", () => {
const { container, unmount } = renderBar({ total: 5, selectedCount: 5 });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
expect(cb.checked).toBe(true);
expect(cb.indeterminate).toBe(false);
unmount();
});
it("the select-all checkbox is unchecked when nothing is selected", () => {
const { container, unmount } = renderBar({ total: 5, selectedCount: 0 });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
expect(cb.checked).toBe(false);
expect(cb.indeterminate).toBe(false);
unmount();
});
it("the select-all checkbox is indeterminate when some items are selected", () => {
const { container, unmount } = renderBar({ total: 5, selectedCount: 2 });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
expect(cb.checked).toBe(false);
expect(cb.indeterminate).toBe(true);
unmount();
});
it("clicking the select-all checkbox calls onToggleAll", () => {
const onToggleAll = vi.fn();
const { container, unmount } = renderBar({ onToggleAll });
const cb = container.querySelector<HTMLInputElement>(
'input[type="checkbox"]',
)!;
act(() => {
cb.click();
});
expect(onToggleAll).toHaveBeenCalledTimes(1);
unmount();
});
it("the export button is disabled when selectedCount is 0", () => {
const { container, unmount } = renderBar({ selectedCount: 0 });
const btn = container.querySelector<HTMLButtonElement>("button")!;
expect(btn.disabled).toBe(true);
unmount();
});
it("the export button is enabled when selectedCount > 0", () => {
const { container, unmount } = renderBar({ selectedCount: 3 });
const btn = container.querySelector<HTMLButtonElement>("button")!;
expect(btn.disabled).toBe(false);
unmount();
});
it("the export button is disabled while exporting", () => {
const { container, unmount } = renderBar({ selectedCount: 3, exporting: true });
const btn = container.querySelector<HTMLButtonElement>("button")!;
expect(btn.disabled).toBe(true);
unmount();
});
it("clicking the export button calls onExport", () => {
const onExport = vi.fn();
const { container, unmount } = renderBar({ onExport, selectedCount: 3 });
const btn = container.querySelector<HTMLButtonElement>("button")!;
act(() => {
btn.click();
});
expect(onExport).toHaveBeenCalledTimes(1);
unmount();
});
it("renders the selected count in the export button label", () => {
const { container, unmount } = renderBar({ selectedCount: 7 });
const btn = container.querySelector<HTMLButtonElement>("button")!;
expect(btn.textContent).toContain("7");
unmount();
});
});
+127
View File
@@ -0,0 +1,127 @@
// ExportBar — sticky bar that sits at the top of the Upload page's
// streaming section and lets the user select-all / deselect-all the
// streamed claims and trigger the batch X12 export.
//
// Tri-state "Select all" checkbox:
// - all selected → checked
// - none selected → unchecked
// - mixed → indeterminate (the dash / line state)
//
// The Export button is disabled when the selection is empty (no point
// sending an empty bundle) or when an export is already in flight
// (don't double-fire the network call).
import { CheckSquare, Download, Loader2, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface ExportBarProps {
/** Total claims in the stream (denominator for "X of Y selected"). */
total: number;
/** Current selection size (numerator). */
selectedCount: number;
/** When true, the export button shows a spinner and is disabled. */
exporting: boolean;
/** Called when the user toggles the select-all checkbox. */
onToggleAll: () => void;
/** Called when the user clicks the Export button. */
onExport: () => void;
}
export function ExportBar({
total,
selectedCount,
exporting,
onToggleAll,
onExport,
}: ExportBarProps) {
const allSelected = total > 0 && selectedCount === total;
const noneSelected = selectedCount === 0;
// Indeterminate = some selected, but not all. Bound to the input via
// a ref because the `indeterminate` attribute is not a real HTML
// attribute — it's a JS property only.
const indeterminate = !allSelected && !noneSelected;
return (
<div
className={cn(
"flex items-center gap-4 px-3 py-2 rounded-md border bg-background/40",
"flex-wrap",
)}
style={{ borderColor: "hsl(var(--border) / 0.6)" }}
data-testid="export-bar"
>
<label
className="inline-flex items-center gap-2 cursor-pointer select-none text-[12px] mono uppercase tracking-[0.14em] font-semibold text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
data-testid="export-bar-toggle-all"
>
{allSelected ? (
<CheckSquare
className="h-3.5 w-3.5"
strokeWidth={1.75}
style={{ color: "hsl(var(--accent))" }}
/>
) : (
<Square
className="h-3.5 w-3.5"
strokeWidth={1.75}
style={{
color: indeterminate
? "hsl(var(--accent) / 0.7)"
: "hsl(var(--muted-foreground) / 0.6)",
}}
/>
)}
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = indeterminate;
}}
onChange={onToggleAll}
className="sr-only"
aria-label={
allSelected
? "Deselect all claims"
: noneSelected
? "Select all claims"
: `Select all claims (${selectedCount} of ${total} currently selected)`
}
/>
{allSelected ? "Deselect all" : "Select all"}
</label>
<span
className="text-[11.5px] mono text-muted-foreground/70"
aria-live="polite"
>
<span className="text-foreground">{selectedCount}</span> of{" "}
<span className="text-foreground">{total}</span> selected
</span>
<div className="ml-auto flex items-center gap-2">
<Button
size="sm"
variant="default"
onClick={onExport}
disabled={noneSelected || exporting}
data-testid="export-bar-export"
aria-label={`Export ${selectedCount} claims as X12 ZIP`}
>
{exporting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Exporting
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
Export ({selectedCount})
</>
)}
</Button>
</div>
</div>
);
}
+18
View File
@@ -24,6 +24,24 @@ export function Layout() {
return ( return (
<div className="relative min-h-screen z-10"> <div className="relative min-h-screen z-10">
<SkipLink /> <SkipLink />
{/* Ambient page halo a soft, fixed radial that creates a
subtle lighter zone behind the page content (centered
just below the top bar). Pure decoration; pointer-events
disabled so it never intercepts clicks. Two layered
radials: a neutral softbox centered above the page
header, and a cool accent in the upper-right that
mirrors the body::before composition. The halo sits
behind everything else (z-0 inside the z-10 root). */}
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 top-0 z-0 h-[70vh]"
style={{
background: `
radial-gradient(ellipse 55% 30% at 50% 6%, hsla(220, 32%, 30%, 0.16), transparent 70%),
radial-gradient(ellipse 35% 25% at 82% 10%, hsla(212, 70%, 60%, 0.10), transparent 65%)
`,
}}
/>
<div <div
className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none transition-opacity duration-200" className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none transition-opacity duration-200"
style={{ opacity: showScan ? 1 : 0 }} style={{ opacity: showScan ? 1 : 0 }}
+152
View File
@@ -0,0 +1,152 @@
// ---------------------------------------------------------------------------
// TickerTape — live vital-signs strip for the dashboard hero.
//
// A horizontal tape of small numeric readouts separated by hairline
// dividers, rendered on the dark canvas. Each cell shows a label
// (uppercase mono) and a value (mono). One cell can be marked
// `accent` to draw the eye, and the optional `live` cell shows a
// pulsing dot — the dashboard's only acknowledgement of time.
//
// This is a static ticker (not scrolling); the cells are just laid
// out left-to-right with dividers. We use it to give the dark hero
// data density without committing to a real chart.
// ---------------------------------------------------------------------------
import { cn } from "@/lib/utils";
export interface TickerCell {
/** Stable id used as React key. */
id: string;
/** Uppercase mono label (e.g. "Billed", "Pending", "Denial"). */
label: string;
/** The value, rendered verbatim in mono. Format it before passing. */
value: string;
/** Optional secondary value shown muted (e.g. delta). */
delta?: string;
/** Highlight this cell with the accent colour. */
accent?: boolean;
}
export interface TickerTapeProps {
cells: TickerCell[];
/** Show the "LIVE" pulse on the leading edge. */
live?: boolean;
/** Live label (default "Live"). */
liveLabel?: string;
className?: string;
}
export function TickerTape({
cells,
live = false,
liveLabel = "Live",
className,
}: TickerTapeProps) {
return (
<div
className={cn(
"relative flex items-stretch w-full overflow-hidden",
"rounded-md border border-border/50 bg-card/40 backdrop-blur",
className
)}
>
{/* Left rail — amber signal accent for the LIVE indicator */}
{live ? (
<div
className="flex items-center gap-2 px-3 border-r border-border/60 shrink-0"
style={{
background:
"linear-gradient(180deg, hsl(36 92% 56% / 0.06), transparent)",
}}
>
<span className="relative inline-flex h-1.5 w-1.5 shrink-0">
<span
className="absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping"
style={{ backgroundColor: "hsl(var(--signal))" }}
/>
<span
className="relative inline-flex h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: "hsl(var(--signal))" }}
/>
</span>
<span
className="mono uppercase tracking-[0.18em] font-semibold"
style={{
color: "hsl(var(--signal))",
fontSize: 10,
}}
>
{liveLabel}
</span>
</div>
) : null}
{/* Cells */}
<div className="flex items-stretch flex-1 min-w-0 overflow-x-auto">
{cells.map((c, i) => (
<div
key={c.id}
className={cn(
"flex-1 min-w-[110px] px-4 py-2.5 flex flex-col gap-0.5",
i > 0 ? "border-l border-border/40" : ""
)}
>
<span
className="mono uppercase tracking-[0.18em] font-medium"
style={{
color: c.accent
? "hsl(var(--accent))"
: "hsl(var(--muted-foreground))",
fontSize: 9.5,
}}
>
{c.label}
</span>
<div className="flex items-baseline gap-1.5">
<span
className="display mono tabular-nums"
style={{
color: c.accent
? "hsl(var(--foreground))"
: "hsl(var(--foreground))",
fontSize: 16,
letterSpacing: "-0.01em",
}}
>
{c.value}
</span>
{c.delta ? (
<span
className="mono tabular-nums"
style={{
color: "hsl(var(--muted-foreground))",
fontSize: 10.5,
}}
>
{c.delta}
</span>
) : null}
</div>
</div>
))}
</div>
{/* Right rail perforated tick pattern, decoratively marks the
end of the tape without using a heavy border. */}
<div
aria-hidden
className="shrink-0 w-3 self-stretch"
style={{
backgroundImage:
"radial-gradient(circle, hsl(36 14% 88% / 0.16) 1px, transparent 1.5px)",
backgroundSize: "6px 6px",
backgroundPosition: "0 center",
maskImage:
"linear-gradient(to right, transparent, black 30%, black 100%)",
WebkitMaskImage:
"linear-gradient(to right, transparent, black 30%, black 100%)",
}}
/>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
// ---------------------------------------------------------------------------
// AgingBars — AR aging stacked horizontal bar.
//
// One bar split into up to 4 segments by aging bucket (030 / 3160 /
// 6190 / 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. "030", "3160", "6190", "90+"). */
label: string;
/** Dollar amount outstanding in this bucket. */
amount: number;
/** Tailwind/HSL colour for the segment. */
color: string;
}
export interface AgingBarsProps {
buckets: AgingBucket[];
/** Total AR amount for share calc. Defaults to sum of buckets. */
total?: number;
/** Optional caption shown right-aligned above the bar. */
caption?: string;
}
export function AgingBars({ buckets, total, caption }: AgingBarsProps) {
const sum = total ?? buckets.reduce((s, b) => s + b.amount, 0);
if (sum <= 0) {
return (
<div
className="mono text-[11px] py-6 text-center"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Nothing outstanding.
</div>
);
}
return (
<div className="w-full">
{caption ? (
<div
className="mono text-[10.5px] uppercase tracking-[0.16em] mb-2 flex items-center justify-between"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
<span>Aging buckets</span>
<span>{caption}</span>
</div>
) : null}
{/* The stacked bar */}
<div
className="flex w-full h-3 overflow-hidden rounded-[3px]"
style={{ boxShadow: "inset 0 0 0 1px hsl(30 14% 14% / 0.08)" }}
>
{buckets.map((b, i) => {
const pct = (b.amount / sum) * 100;
if (pct <= 0) return null;
return (
<div
key={b.id}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: b.color,
animation: `bar-grow-h 800ms cubic-bezier(0.2,0.8,0.2,1) ${
i * 70
}ms both`,
transformOrigin: "left center",
}}
title={`${b.label}: ${fmt.usd(b.amount)}`}
aria-label={`${b.label} days: ${fmt.usd(b.amount)}`}
/>
);
})}
</div>
{/* Legend rows */}
<ul className="mt-4 space-y-2">
{buckets.map((b) => {
const share = sum > 0 ? (b.amount / sum) * 100 : 0;
return (
<li
key={b.id}
className="flex items-center justify-between gap-3 text-[12px]"
>
<div className="flex items-center gap-2 min-w-0">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-sm shrink-0"
style={{ backgroundColor: b.color }}
/>
<span
className="mono tabular-nums"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{b.label}d
</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<div
className="relative h-1 w-16 rounded-full overflow-hidden"
style={{ backgroundColor: "hsl(30 14% 14% / 0.06)" }}
aria-hidden
>
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${share}%`,
backgroundColor: b.color,
opacity: 0.7,
}}
/>
</div>
<span
className="mono tabular-nums"
style={{
color: "hsl(var(--surface-ink))",
minWidth: 70,
textAlign: "right",
}}
>
{fmt.usd(b.amount)}
</span>
<span
className="mono tabular-nums"
style={{
color: "hsl(var(--surface-ink-3))",
minWidth: 40,
textAlign: "right",
fontSize: 10.5,
}}
>
{share.toFixed(0)}%
</span>
</div>
</li>
);
})}
</ul>
</div>
);
}
+381
View File
@@ -0,0 +1,381 @@
// ---------------------------------------------------------------------------
// BarChart — grouped vertical bar chart on cream paper.
//
// Renders N series (e.g. billed, received, AR) over M categories (months).
// Each category gets a cluster of bars, one per series. The chart grows
// the bars from 0 on mount with a staggered delay so the statement
// doesn't appear all at once. Hover shows the exact value as a small
// ledger callout. Designed to live INSIDE the paper statement — no dark
// chrome, hairline grid only.
//
// The paper variant: cream background, dark ink, hairline rule in
// --surface-line-soft. Three series uses --accent (billed), --success
// (received), --signal (AR outstanding) so each carries its own
// semantic colour rather than three random hues.
// ---------------------------------------------------------------------------
import { useEffect, useRef, useState } from "react";
export interface BarSeries {
/** Stable id used for keying, hover hit-tests, and legend swatches. */
id: string;
/** Display name shown in the legend. */
label: string;
/** Hex/HSL colour used for bar fill + legend swatch. */
color: string;
/** Values aligned with `categories`. */
values: number[];
/** Optional formatter for tooltip + axis labels. Defaults to identity. */
format?: (n: number) => string;
}
export interface BarChartProps {
/** Category labels along the X axis (e.g. month abbreviations). */
categories: string[];
/** Ordered series. First series renders leftmost in each cluster. */
series: BarSeries[];
/** Height in px. Default 260. */
height?: number;
/** Optional y-axis tick formatter. If omitted, the chart self-scales
* to a "round" max and labels with a compact thousands suffix. */
formatY?: (n: number) => string;
/** When true, draws a hairline rule for each y tick. Default true. */
showGrid?: boolean;
/** Force a specific Y-axis maximum. When omitted, the chart auto-scales
* to a "round" max with a small headroom. */
yMax?: number;
/** When true, prints the per-cluster total above each month. Default true. */
showTotals?: boolean;
/** Index of the "current" category to highlight (e.g. latest month).
* Adds a subtle background band and an eyebrow above. */
highlightIndex?: number;
}
const PAD_LEFT = 44;
const PAD_RIGHT = 16;
const PAD_TOP = 16;
const PAD_BOTTOM = 28;
const niceMax = (raw: number): number => {
if (raw <= 0) return 1;
const exp = Math.floor(Math.log10(raw));
const base = Math.pow(10, exp);
const norm = raw / base;
let nice: number;
if (norm <= 1) nice = 1;
else if (norm <= 2) nice = 2;
else if (norm <= 2.5) nice = 2.5;
else if (norm <= 5) nice = 5;
else nice = 10;
return nice * base;
};
const compact = (n: number): string => {
const abs = Math.abs(n);
if (abs >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
if (abs >= 1_000) return `$${(n / 1_000).toFixed(0)}k`;
return `$${n.toFixed(0)}`;
};
interface Hover {
cat: number;
series: number;
x: number;
y: number;
}
export function BarChart({
categories,
series,
height = 260,
formatY,
showGrid = true,
yMax: yMaxProp,
showTotals = true,
highlightIndex,
}: BarChartProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const [width, setWidth] = useState(800);
const [hover, setHover] = useState<Hover | null>(null);
// Track container width so the SVG reflows with the layout grid.
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 800;
setWidth(Math.max(320, Math.floor(w)));
});
ro.observe(el);
setWidth(Math.max(320, Math.floor(el.clientWidth)));
return () => ro.disconnect();
}, []);
const innerW = width - PAD_LEFT - PAD_RIGHT;
const innerH = height - PAD_TOP - PAD_BOTTOM;
const allValues = series.flatMap((s) => s.values);
const rawMax = allValues.length ? Math.max(...allValues) : 1;
// If caller forces a yMax, use it; otherwise compute a "round" max
// with a small (5%) headroom so the tallest bar doesn't kiss the lid.
const yMax = yMaxProp ?? niceMax(rawMax * 1.05);
const yTicks = 4;
const clusterW = innerW / Math.max(1, categories.length);
const barGap = 4;
const barW = Math.max(
4,
(clusterW - barGap * (series.length - 1)) / Math.max(1, series.length)
);
const yToPx = (v: number) => PAD_TOP + innerH - (v / yMax) * innerH;
const catCenterX = (i: number) => PAD_LEFT + clusterW * i + clusterW / 2;
const fmtY = formatY ?? compact;
// Stagger the bars so they grow from baseline on first paint. We just
// emit a CSS animation-delay per bar via inline style.
const barIndex = (catI: number, serI: number) =>
catI * series.length + serI;
return (
<div ref={wrapRef} className="relative w-full" style={{ height }}>
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="block"
role="img"
aria-label="Monthly billed, received, and AR trend"
onMouseLeave={() => setHover(null)}
>
{/* Y-axis grid + labels */}
{showGrid &&
Array.from({ length: yTicks + 1 }, (_, i) => {
const v = (yMax / yTicks) * i;
const y = yToPx(v);
return (
<g key={i}>
<line
x1={PAD_LEFT}
x2={width - PAD_RIGHT}
y1={y}
y2={y}
stroke="hsl(30 14% 14% / 0.10)"
strokeWidth={1}
strokeDasharray={i === 0 ? "" : "2 3"}
/>
<text
x={PAD_LEFT - 10}
y={y + 4}
textAnchor="end"
className="mono"
fontSize={11}
fontWeight={500}
style={{ fill: "hsl(var(--surface-ink-2))" }}
>
{fmtY(v)}
</text>
</g>
);
})}
{/* Highlight band on the current month (subtle, like a sticky note) */}
{highlightIndex !== undefined &&
highlightIndex >= 0 &&
highlightIndex < categories.length ? (
<g>
<rect
x={catCenterX(highlightIndex) - clusterW / 2}
y={PAD_TOP}
width={clusterW}
height={innerH}
fill="hsl(36 92% 56% / 0.06)"
/>
<line
x1={catCenterX(highlightIndex)}
x2={catCenterX(highlightIndex)}
y1={PAD_TOP}
y2={PAD_TOP + innerH}
stroke="hsl(36 92% 56% / 0.45)"
strokeWidth={1}
strokeDasharray="2 3"
/>
<text
x={catCenterX(highlightIndex)}
y={PAD_TOP + 12}
textAnchor="middle"
className="mono"
fontSize={9}
fontWeight={600}
style={{ fill: "hsl(36 92% 36%)", letterSpacing: "0.18em" }}
>
NOW
</text>
</g>
) : null}
{/* Bars */}
{categories.map((cat, ci) =>
series.map((s, si) => {
const v = s.values[ci] ?? 0;
const x =
catCenterX(ci) -
(series.length * barW + (series.length - 1) * barGap) / 2 +
si * (barW + barGap);
const y = yToPx(v);
const h = Math.max(0, PAD_TOP + innerH - y);
const delay = barIndex(ci, si) * 35;
const isHover =
hover?.cat === ci && hover?.series === si ? true : false;
return (
<rect
key={`${cat}-${s.id}`}
x={x}
y={y}
width={barW}
height={h}
rx={1.5}
fill={s.color}
opacity={hover && !isHover ? 0.55 : 1}
style={{
transformOrigin: `${x + barW / 2}px ${PAD_TOP + innerH}px`,
animation: `bar-grow 600ms cubic-bezier(0.2,0.8,0.2,1) ${delay}ms both`,
}}
onMouseEnter={() =>
setHover({
cat: ci,
series: si,
x: x + barW / 2,
y,
})
}
/>
);
})
)}
{/* X-axis labels */}
{categories.map((cat, i) => (
<text
key={cat}
x={catCenterX(i)}
y={height - 14}
textAnchor="middle"
className="mono"
fontSize={11}
fontWeight={500}
style={{
fill:
highlightIndex === i
? "hsl(var(--surface-ink))"
: "hsl(var(--surface-ink-2))",
}}
>
{cat}
</text>
))}
{/* Per-cluster totals above each month — quick read of the spine. */}
{showTotals &&
categories.map((_, ci) => {
const total = series.reduce(
(s, sr) => s + (sr.values[ci] ?? 0),
0
);
const x = catCenterX(ci);
const topY = yToPx(
Math.max(...series.map((s) => s.values[ci] ?? 0))
);
const labelY = Math.max(14, topY - 8);
return (
<text
key={`total-${ci}`}
x={x}
y={labelY}
textAnchor="middle"
className="display mono"
fontSize={11}
fontWeight={600}
style={{
fill: "hsl(var(--surface-ink-2))",
opacity: 0.75,
}}
>
{fmtY(total)}
</text>
);
})}
{/* Hover callout */}
{hover ? (
(() => {
const s = series[hover.series]!;
const v = s.values[hover.cat] ?? 0;
const catLabel = categories[hover.cat] ?? "";
const text = `${s.label} · ${catLabel}`;
const valueText = s.format ? s.format(v) : fmtY(v);
// Position the chip to the right of the bar; flip left if it
// would overflow the canvas edge.
const flipLeft = hover.x > width - 160;
const chipX = flipLeft ? hover.x - 8 : hover.x + 8;
const anchor: "end" | "start" = flipLeft ? "end" : "start";
return (
<g style={{ pointerEvents: "none" }}>
<line
x1={hover.x}
x2={hover.x}
y1={PAD_TOP}
y2={PAD_TOP + innerH}
stroke={s.color}
strokeWidth={1}
strokeDasharray="2 3"
opacity={0.55}
/>
<text
x={chipX}
y={Math.max(14, hover.y - 10)}
textAnchor={anchor}
className="mono"
fontSize={10}
style={{ fill: "hsl(var(--surface-ink-3))" }}
>
{text}
</text>
<text
x={chipX}
y={Math.max(26, hover.y + 2)}
textAnchor={anchor}
className="display mono"
fontSize={16}
style={{ fill: "hsl(var(--surface-ink))" }}
>
{valueText}
</text>
</g>
);
})()
) : null}
</svg>
{/* Legend */}
<div className="flex items-center gap-4 mt-3 flex-wrap">
{series.map((s) => (
<div key={s.id} className="flex items-center gap-2">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-sm"
style={{ backgroundColor: s.color }}
/>
<span
className="mono text-[10.5px] uppercase tracking-[0.16em]"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{s.label}
</span>
</div>
))}
</div>
</div>
);
}
+194
View File
@@ -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 48 rows; collapses to a
// list if rows are empty. Paper-friendly: cream background, dark ink.
// ---------------------------------------------------------------------------
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export interface HBarRow {
/** Stable id used as React key. */
id: string;
/** Left label (e.g. provider name). */
label: string;
/** Sub-label under the row (e.g. NPI). Optional. */
sublabel?: string;
/** Bar value. */
value: number;
/** Optional pre-formatted trailing value. Defaults to fmt.num(value). */
formatted?: string;
/** Optional color override. Defaults to --accent. */
color?: string;
/** Optional leading slot (icon, initials avatar, etc). */
leading?: React.ReactNode;
}
export interface HBarChartProps {
rows: HBarRow[];
/** Bar fill colour when a row doesn't override. Defaults to accent. */
defaultColor?: string;
/** Pixel height of each row. Default 44. */
rowHeight?: number;
/** Compact mode: smaller text, tighter padding. */
compact?: boolean;
/** Optional click handler. */
onRowClick?: (id: string) => void;
}
export function HBarChart({
rows,
defaultColor = "hsl(var(--accent))",
rowHeight = 44,
compact = false,
onRowClick,
}: HBarChartProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const [labelW, setLabelW] = useState(180);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 800;
// Reserve ~38% of width for label + leading slot, capped between
// 140 and 260 so bars stay readable on wide and narrow layouts.
setLabelW(Math.max(140, Math.min(260, Math.floor(w * 0.38))));
});
ro.observe(el);
setLabelW(Math.max(140, Math.min(260, Math.floor(el.clientWidth * 0.38))));
return () => ro.disconnect();
}, []);
if (rows.length === 0) {
return (
<div
className="mono text-[11px] py-6 text-center"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
No data yet.
</div>
);
}
const max = Math.max(...rows.map((r) => r.value), 1);
return (
<div ref={wrapRef} className="w-full">
{rows.map((r, i) => {
const pct = (r.value / max) * 100;
const color = r.color ?? defaultColor;
return (
<div
key={r.id}
role={onRowClick ? "button" : undefined}
tabIndex={onRowClick ? 0 : undefined}
onClick={onRowClick ? () => onRowClick(r.id) : undefined}
onKeyDown={
onRowClick
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRowClick(r.id);
}
}
: undefined
}
className={cn(
"group flex items-center gap-3",
compact ? "py-1.5" : "py-2.5",
onRowClick ? "cursor-pointer" : ""
)}
style={{ minHeight: rowHeight }}
>
{/* Leading slot (e.g. initials avatar) */}
{r.leading ? (
<div className="shrink-0">{r.leading}</div>
) : null}
{/* Label column */}
<div
className="shrink-0 min-w-0"
style={{ width: labelW - (r.leading ? 36 : 0) }}
>
<div
className={cn(
"truncate font-medium",
compact ? "text-[12.5px]" : "text-[13.5px]"
)}
style={{ color: "hsl(var(--surface-ink))" }}
>
{r.label}
</div>
{r.sublabel ? (
<div
className="mono truncate"
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10.5,
marginTop: 1,
}}
>
{r.sublabel}
</div>
) : null}
</div>
{/* Bar */}
<div className="flex-1 min-w-0 relative">
{/* Track (hairline) */}
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
style={{ backgroundColor: "hsl(30 14% 14% / 0.08)" }}
aria-hidden
/>
<div
className="relative h-[10px] rounded-[2px]"
style={{
width: `${pct}%`,
backgroundColor: color,
opacity: 0.85,
animation: `bar-grow-h 700ms cubic-bezier(0.2,0.8,0.2,1) ${
i * 50
}ms both`,
transformOrigin: "left center",
boxShadow: `inset 0 1px 0 hsl(0 0% 100% / 0.15)`,
}}
aria-hidden
/>
{/* Rank tick on the bar */}
<span
className="mono absolute top-1/2 -translate-y-1/2 text-[9px] font-semibold tabular-nums"
style={{
left: 6,
color: "hsl(0 0% 100% / 0.92)",
mixBlendMode: "screen",
opacity: pct > 12 ? 1 : 0,
}}
>
{String(i + 1).padStart(2, "0")}
</span>
</div>
{/* Trailing value */}
<div
className={cn(
"shrink-0 text-right mono tabular-nums",
compact ? "text-[12.5px]" : "text-[14px]"
)}
style={{
color: "hsl(var(--surface-ink))",
minWidth: 56,
}}
>
{r.formatted ?? r.value.toLocaleString("en-US")}
</div>
</div>
);
})}
</div>
);
}
+119
View File
@@ -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 46 statuses.
// ---------------------------------------------------------------------------
import { fmt } from "@/lib/format";
export interface Segment {
id: string;
label: string;
count: number;
color: string;
}
export interface SegmentedBarProps {
segments: Segment[];
/** Optional caption shown above the bar (e.g. "Status distribution"). */
caption?: string;
}
export function SegmentedBar({ segments, caption }: SegmentedBarProps) {
const total = segments.reduce((s, x) => s + x.count, 0);
if (total <= 0) {
return (
<div
className="mono text-[11px] py-6 text-center"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
No claims yet.
</div>
);
}
return (
<div className="w-full">
{caption ? (
<div
className="mono text-[10.5px] uppercase tracking-[0.16em] mb-2"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{caption}
</div>
) : null}
{/* The bar */}
<div
className="flex w-full h-[10px] overflow-hidden rounded-[3px]"
style={{ boxShadow: "inset 0 0 0 1px hsl(30 14% 14% / 0.08)" }}
>
{segments.map((s, i) => {
const pct = (s.count / total) * 100;
if (pct <= 0) return null;
return (
<div
key={s.id}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: s.color,
animation: `bar-grow-h 700ms cubic-bezier(0.2,0.8,0.2,1) ${
i * 50
}ms both`,
transformOrigin: "left center",
}}
title={`${s.label}: ${s.count} (${((s.count / total) * 100).toFixed(0)}%)`}
aria-label={`${s.label}: ${s.count} of ${total}`}
/>
);
})}
</div>
{/* Legend */}
<ul className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5">
{segments.map((s) => {
const pct = (s.count / total) * 100;
if (s.count === 0) return null;
return (
<li
key={s.id}
className="flex items-center justify-between gap-2 text-[11.5px] min-w-0"
>
<div className="flex items-center gap-2 min-w-0">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-sm shrink-0"
style={{ backgroundColor: s.color }}
/>
<span
className="truncate"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{s.label}
</span>
</div>
<div className="flex items-center gap-2 shrink-0 mono tabular-nums">
<span style={{ color: "hsl(var(--surface-ink))" }}>
{fmt.num(s.count)}
</span>
<span
style={{
color: "hsl(var(--surface-ink-3))",
fontSize: 10.5,
minWidth: 30,
textAlign: "right",
}}
>
{pct.toFixed(0)}%
</span>
</div>
</li>
);
})}
</ul>
</div>
);
}
+7 -2
View File
@@ -102,10 +102,15 @@ export function Lane({
<section <section
className="flex-1 min-w-[280px] flex flex-col rounded-md overflow-hidden" className="flex-1 min-w-[280px] flex flex-col rounded-md overflow-hidden"
style={{ style={{
background: "var(--tt-bg-elev)", // "Lit from above" gradient — same treatment as the main
// .surface cards, in the inbox's cool/blue-grey hue family.
// Each lane becomes its own small lighter area against the
// surrounding ticker-tape background.
background:
"linear-gradient(180deg, hsl(220 18% 11.5%) 0%, hsl(220 18% 9.5%) 55%, hsl(220 18% 8.5%) 100%)",
border: "1px solid hsl(220 8% 18%)", border: "1px solid hsl(220 8% 18%)",
boxShadow: boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.03), 0 1px 2px 0 hsl(0 0% 0% / 0.4)", "inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
}} }}
data-lane={name.toLowerCase()} data-lane={name.toLowerCase()}
> >
+288
View File
@@ -0,0 +1,288 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
import React, { act } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { createRoot, type Root } from "react-dom/client";
// Mock the api + download + toast surfaces so the hook can be exercised
// in isolation. Each test sets up its own expectations on these.
vi.mock("@/lib/api", () => ({
api: {
exportBatch837: vi.fn(),
},
}));
vi.mock("@/lib/download", () => ({
downloadBlob: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
},
}));
import { useBatchExport, type BatchExportItem } from "./useBatchExport";
import { api } from "@/lib/api";
import { downloadBlob } from "@/lib/download";
import { toast } from "sonner";
const mockExportBatch837 = vi.mocked(api.exportBatch837);
const mockDownloadBlob = vi.mocked(downloadBlob);
const mockToastSuccess = vi.mocked(toast.success);
const mockToastWarning = vi.mocked(toast.warning);
const mockToastError = vi.mocked(toast.error);
function makeItem(claimId: string): BatchExportItem {
return { kind: "837p", data: { claim_id: claimId } };
}
function make835Item(id: string): BatchExportItem {
return { kind: "835", data: { payer_claim_control_number: id } };
}
/**
* Render the hook with a fixed initial list and capture its return
* value. The `rerenderWith` helper re-creates the harness with a new
* initial list that's how we simulate "new claims streamed in"
* without poking at internal useState setters from outside the
* component.
*/
function setup(initial: BatchExportItem[]) {
let latest: ReturnType<typeof useBatchExport> | null = null;
const container = document.createElement("div");
document.body.append(container);
function Harness({ items }: { items: BatchExportItem[] }) {
latest = useBatchExport(items);
return null;
}
const root: Root = createRoot(container);
function render(items: BatchExportItem[]) {
act(() => {
root.render(React.createElement(Harness, { items }));
});
}
render(initial);
return {
get latest(): ReturnType<typeof useBatchExport> {
if (!latest) throw new Error("hook result not yet captured");
return latest;
},
/** Re-render with a new items list (e.g. to simulate streaming). */
rerenderWith(items: BatchExportItem[]) {
render(items);
},
unmount() {
act(() => root.unmount());
container.remove();
},
};
}
describe("useBatchExport", () => {
beforeEach(() => {
mockExportBatch837.mockReset();
mockDownloadBlob.mockReset();
mockToastSuccess.mockReset();
mockToastWarning.mockReset();
mockToastError.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("auto-selects all 837P claims passed in initially", () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]);
expect(h.latest.selectedClaimIds.size).toBe(3);
expect(h.latest.total837InStream).toBe(3);
h.unmount();
});
it("ignores 835 items when counting / selecting", () => {
const h = setup([makeItem("CLM-1"), make835Item("PCN-1")]);
expect(h.latest.selectedClaimIds.size).toBe(1);
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true);
expect(h.latest.total837InStream).toBe(1);
h.unmount();
});
it("auto-selects a new 837P claim that streams in", () => {
const h = setup([makeItem("CLM-1")]);
expect(h.latest.selectedClaimIds.size).toBe(1);
h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]);
expect(h.latest.selectedClaimIds.size).toBe(2);
expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true);
h.unmount();
});
it("does not deselect existing claims when a new one streams in", () => {
const h = setup([makeItem("CLM-1")]);
act(() => {
h.latest.onToggleSelect("CLM-1"); // deselect
});
expect(h.latest.selectedClaimIds.size).toBe(0);
h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]);
// CLM-1 is still deselected (user's choice is preserved), CLM-2
// is auto-selected.
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false);
expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true);
h.unmount();
});
it("onToggleSelect adds and removes", () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
act(() => {
h.latest.onToggleSelect("CLM-1"); // toggle off
});
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false);
act(() => {
h.latest.onToggleSelect("CLM-1"); // toggle back on
});
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true);
h.unmount();
});
it("onToggleAll deselects when everything is selected", () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
expect(h.latest.selectedClaimIds.size).toBe(2);
act(() => {
h.latest.onToggleAll();
});
expect(h.latest.selectedClaimIds.size).toBe(0);
h.unmount();
});
it("onToggleAll selects everything when partial / none are selected", () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]);
act(() => {
h.latest.onToggleSelect("CLM-1"); // partial: only CLM-2 and CLM-3
});
act(() => {
h.latest.onToggleSelect("CLM-2");
});
act(() => {
h.latest.onToggleAll();
});
expect(h.latest.selectedClaimIds.size).toBe(3);
h.unmount();
});
it("onExport does nothing when no batch id is recorded", async () => {
const h = setup([makeItem("CLM-1")]);
await h.latest.onExport();
expect(mockExportBatch837).not.toHaveBeenCalled();
expect(mockToastError).toHaveBeenCalled();
h.unmount();
});
it("onExport does nothing when selection is empty (after recordBatchId)", async () => {
const h = setup([makeItem("CLM-1")]);
h.latest.recordBatchId("server-uuid-123");
act(() => {
h.latest.onToggleSelect("CLM-1"); // deselect
});
await h.latest.onExport();
expect(mockExportBatch837).not.toHaveBeenCalled();
h.unmount();
});
it("onExport calls api.exportBatch837 with the recorded batch id and selected ids, then downloadBlob + success toast", async () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
h.latest.recordBatchId("server-uuid-456");
const exportedBlob = new Blob([new Uint8Array([1, 2, 3])], {
type: "application/zip",
});
mockExportBatch837.mockResolvedValueOnce({
blob: exportedBlob,
filename: "batch-server-uuid-456-2-claims.zip",
serializeErrors: [],
});
await h.latest.onExport();
expect(mockExportBatch837).toHaveBeenCalledWith(
"server-uuid-456",
expect.arrayContaining(["CLM-1", "CLM-2"]),
);
expect(mockDownloadBlob).toHaveBeenCalledTimes(1);
// Regression: downloadBlob's signature is (filename, blob). An earlier
// build had the args swapped, which made URL.createObjectURL receive
// the filename string and throw "Overload resolution failed" — pin
// the order here so a swap can't sneak back in.
expect(mockDownloadBlob).toHaveBeenCalledWith(
"batch-server-uuid-456-2-claims.zip",
exportedBlob,
);
expect(mockToastSuccess).toHaveBeenCalledWith(
"Exported 2 claims as X12",
expect.objectContaining({ description: "batch-server-uuid-456-2-claims.zip" }),
);
expect(mockToastWarning).not.toHaveBeenCalled();
h.unmount();
});
it("onExport shows a warning toast with serialize-error details on partial failure", async () => {
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
h.latest.recordBatchId("server-uuid-789");
mockExportBatch837.mockResolvedValueOnce({
blob: new Blob([new Uint8Array([1])]),
filename: "batch-server-uuid-789-1-claims.zip",
serializeErrors: [{ claim_id: "CLM-2", reason: "no raw_json" }],
});
await h.latest.onExport();
expect(mockDownloadBlob).toHaveBeenCalledTimes(1);
expect(mockToastSuccess).not.toHaveBeenCalled();
expect(mockToastWarning).toHaveBeenCalledWith(
"Exported 1 · 1 couldn't be regenerated",
expect.objectContaining({
description: expect.stringContaining("CLM-2: no raw_json"),
}),
);
h.unmount();
});
it("onExport shows an error toast and resets exporting=false on network failure", async () => {
const h = setup([makeItem("CLM-1")]);
h.latest.recordBatchId("server-uuid-abc");
mockExportBatch837.mockRejectedValueOnce(new Error("network down"));
await h.latest.onExport();
expect(mockToastError).toHaveBeenCalledWith("network down");
expect(h.latest.exporting).toBe(false);
h.unmount();
});
it("exporting is true while the export is in flight, false after", async () => {
const h = setup([makeItem("CLM-1")]);
h.latest.recordBatchId("server-uuid-xyz");
let resolveExport: (v: unknown) => void = () => {};
mockExportBatch837.mockReturnValueOnce(
new Promise((res) => {
resolveExport = res;
}) as ReturnType<typeof api.exportBatch837>,
);
const exportPromise = h.latest.onExport();
// Yield once so the exporting=true state has flushed.
await act(async () => {
await Promise.resolve();
});
expect(h.latest.exporting).toBe(true);
resolveExport({
blob: new Blob(),
filename: "x.zip",
serializeErrors: [],
});
// The state update in onExport's `finally` block happens on a
// microtask after the promise resolves; wrapping the await in
// act() flushes it.
await act(async () => {
await exportPromise;
});
expect(h.latest.exporting).toBe(false);
h.unmount();
});
});
+174
View File
@@ -0,0 +1,174 @@
// useBatchExport — owns the per-claim selection state and the
// "click Export → POST → download ZIP" flow for the Upload page's
// streaming view.
//
// Extracted from Upload.tsx in SP9 (June 2026) so the logic is unit-
// testable in isolation from the page (and so the page itself stays
// focused on layout). All state is local to the hook — selection is
// not persisted across navigations, and the captured `batchId` is the
// most-recently-completed parse's server-side id.
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { downloadBlob } from "@/lib/download";
/** Minimal shape of a streamed claim this hook needs to read the id. */
export interface BatchExportItem {
kind: "837p" | "835";
data: { claim_id?: string; payer_claim_control_number?: string };
}
export interface UseBatchExportResult {
/** Currently-selected 837P claim ids. */
selectedClaimIds: Set<string>;
/** True while an export POST is in flight. */
exporting: boolean;
/** Total 837P claims currently in the stream. */
total837InStream: number;
/** Toggle a single claim's selection. */
onToggleSelect: (claimId: string) => void;
/** Toggle between "all selected" and "none selected" across the stream. */
onToggleAll: () => void;
/** Fire the export. Returns the promise so tests can await it. */
onExport: () => Promise<void>;
/**
* Called by the parent after a successful parse to record the
* server-side batch id. Captured into a ref so a re-parse that
* completes mid-export can't swap in a different id under the
* export promise.
*/
recordBatchId: (batchId: string | null) => void;
}
export function useBatchExport(items: BatchExportItem[]): UseBatchExportResult {
const [selectedClaimIds, setSelectedClaimIds] = useState<Set<string>>(
() => new Set(),
);
const [exporting, setExporting] = useState(false);
const lastBatchIdRef = useRef<string | null>(null);
// Tracks every claim id that has ever been in the stream. Used by the
// auto-select effect to distinguish "new claim, default-select it"
// from "user previously deselected this claim — leave it alone".
// Without this, a re-render with the same items list would re-add
// every claim that the user explicitly deselected.
const seenClaimIdsRef = useRef<Set<string>>(new Set());
// Auto-select newly streamed 837P claims.
//
// Pre-compute the new claim ids OUTSIDE the setState callback so
// the ref is mutated exactly once per items.length change and the
// setState callback only runs when there's actually something new
// to add. The previous inline pattern (mutating the ref inside the
// setState callback) failed under React StrictMode's effect
// double-invoke: the second invoke saw an empty prev (the first
// invoke's queued update was dropped) AND a ref that already
// contained the ids, so the second invoke added nothing — and the
// selection ended up empty even though the stream populated.
useEffect(() => {
const newIds: string[] = [];
for (const item of items) {
if (item.kind === "837p" && item.data.claim_id) {
const id = item.data.claim_id;
// Only add if we've never seen this id before (so a
// re-render with the same items doesn't re-add anything the
// user just deselected) AND it's not already selected (no
// need to add it again).
if (!seenClaimIdsRef.current.has(id)) {
seenClaimIdsRef.current.add(id);
newIds.push(id);
}
}
}
if (newIds.length === 0) return;
setSelectedClaimIds((prev) => {
const next = new Set(prev);
for (const id of newIds) next.add(id);
return next;
});
// Key on items.length so a re-render with the same set of items
// (just new array identity) doesn't re-run the effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items.length]);
const total837InStream = useMemo(
() => items.filter((i) => i.kind === "837p").length,
[items],
);
function onToggleSelect(claimId: string) {
setSelectedClaimIds((prev) => {
const next = new Set(prev);
if (next.has(claimId)) {
next.delete(claimId);
} else {
next.add(claimId);
}
return next;
});
}
function onToggleAll() {
const streamed837Ids = items
.filter((i): i is BatchExportItem & { data: { claim_id: string } } =>
i.kind === "837p" && !!i.data.claim_id,
)
.map((i) => i.data.claim_id);
if (
streamed837Ids.length > 0 &&
streamed837Ids.every((id) => selectedClaimIds.has(id))
) {
setSelectedClaimIds(new Set());
} else {
setSelectedClaimIds(new Set(streamed837Ids));
}
}
function recordBatchId(batchId: string | null) {
lastBatchIdRef.current = batchId;
}
async function onExport() {
const batchId = lastBatchIdRef.current;
if (!batchId) {
toast.error("No batch to export — parse a file first.");
return;
}
const ids = Array.from(selectedClaimIds);
if (ids.length === 0) return;
setExporting(true);
try {
const { blob, filename, serializeErrors } =
await api.exportBatch837(batchId, ids);
downloadBlob(filename, blob);
if (serializeErrors.length === 0) {
toast.success(`Exported ${ids.length} claims as X12`, {
description: filename,
});
} else {
toast.warning(
`Exported ${ids.length - serializeErrors.length} · ${serializeErrors.length} couldn't be regenerated`,
{
description: serializeErrors
.map((e) => `${e.claim_id}: ${e.reason}`)
.join("\n"),
},
);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setExporting(false);
}
}
return {
selectedClaimIds,
exporting,
total837InStream,
onToggleSelect,
onToggleAll,
onExport,
recordBatchId,
};
}
+48 -17
View File
@@ -127,36 +127,49 @@
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility; 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. /* A precise top-right accent + a complementary soft top-center
One light, not two an instrument, not a wash. */ 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 { body::before {
content: ""; content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
background: radial-gradient( background:
55rem 35rem at 100% -15%, radial-gradient(ellipse 70% 38% at 50% 0%, hsla(220, 32%, 28%, 0.16), transparent 65%),
hsla(212, 100%, 60%, 0.07), radial-gradient(55rem 35rem at 100% -15%, hsla(212, 100%, 60%, 0.09), transparent 62%);
transparent 62%
);
} }
/* Hairline grid the substrate of a precision instrument. /* Hairline grid the substrate of a precision instrument.
Sits behind everything, gives the dark surfaces a sense of 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 { body::after {
content: ""; content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
z-index: 0; z-index: 0;
opacity: 0.5; opacity: 0.6;
background-image: background-image:
linear-gradient(to right, 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% 16% / 0.18) 1px, transparent 1px); linear-gradient(to bottom, hsl(222 10% 18% / 0.20) 1px, transparent 1px);
background-size: 64px 64px; background-size: 64px 64px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%); 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%); -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 /* 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 { .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)); border: 1px solid hsl(var(--border));
box-shadow: 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); 0 1px 2px 0 hsl(0 0% 0% / 0.4);
} }
/* Subtle elevated surface used for cards that should lift a /* 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 { .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)); border: 1px solid hsl(var(--border));
box-shadow: 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); 0 1px 3px 0 hsl(0 0% 0% / 0.5);
} }
+135
View File
@@ -217,4 +217,139 @@ describe("api GET helpers", () => {
); );
await expect(api.serializeClaim837("ghost")).rejects.toBeInstanceOf(ApiError); await expect(api.serializeClaim837("ghost")).rejects.toBeInstanceOf(ApiError);
}); });
it("exportBatch837 returns the blob and filename from a 200 ZIP response", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // ZIP magic
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition":
'attachment; filename="batch-abc123-3-claims.zip"',
},
})
);
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
expect(out.filename).toBe("batch-abc123-3-claims.zip");
expect(out.blob).toBeInstanceOf(Blob);
// POST to the batch-scoped endpoint with claim_ids in the body.
const called = mockFetch.mock.calls[0][0] as string;
expect(called).toContain("/api/batches/abc123/export-837");
const init = mockFetch.mock.calls[0][1] as RequestInit;
expect(init.method).toBe("POST");
const body = JSON.parse(init.body as string);
expect(body).toEqual({ claim_ids: ["CLM-1", "CLM-2", "CLM-3"] });
});
it("exportBatch837 parses the X-Cyclone-Serialize-Errors header into serializeErrors", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
const errs = [
{ claim_id: "CLM-2", reason: "no raw_json" },
{ claim_id: "CLM-3", reason: "raw_json invalid: ..." },
];
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition":
'attachment; filename="batch-abc123-1-claims.zip"',
"x-cyclone-serialize-errors": JSON.stringify(errs),
},
})
);
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
expect(out.serializeErrors).toEqual(errs);
});
it("exportBatch837 returns an empty serializeErrors array when the header is missing", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition": 'attachment; filename="batch-x-1-claims.zip"',
},
})
);
const out = await api.exportBatch837("x", ["CLM-1"]);
expect(out.serializeErrors).toEqual([]);
});
it("exportBatch837 falls back to a default filename when Content-Disposition is missing", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: { "content-type": "application/zip" },
})
);
const out = await api.exportBatch837("xyz", ["CLM-1"]);
expect(out.filename).toBe("batch-xyz-claims.zip");
});
it("exportBatch837 throws ApiError on 404", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({ error: "Not found", detail: "unknown batch: ghost" }),
{ status: 404, headers: { "content-type": "application/json" } }
)
);
await expect(
api.exportBatch837("ghost", ["CLM-1"])
).rejects.toBeInstanceOf(ApiError);
});
it("exportBatch837 throws on empty claim_ids without making a request", async () => {
await expect(api.exportBatch837("any", [])).rejects.toThrow();
expect(mockFetch).not.toHaveBeenCalled();
});
it("getBatchDiff surfaces a clear ApiError when a backend 400 wraps detail in a nested object", async () => {
// The backend can wrap structured errors as
// `{"detail": {"error": "Missing param", "detail": "..."}}`. The
// client must walk into the nested `detail` so the user sees the
// human-readable message rather than a blob of raw JSON.
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({
detail: {
error: "Missing param",
detail: "Both ?a=<batch_id> and ?b=<batch_id> are required.",
},
}),
{ status: 400, headers: { "content-type": "application/json" } }
)
);
let caught: unknown;
try {
await api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "930f25e07eab4ac0908c9770689385e1");
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(ApiError);
expect((caught as ApiError).status).toBe(400);
// The unwrapped message must surface — not the raw JSON.
expect((caught as ApiError).message).toBe(
"Both ?a=<batch_id> and ?b=<batch_id> are required."
);
});
it("getBatchDiff throws ApiError(400) before hitting the network if either id is empty", async () => {
// Defense-in-depth: the hook's `enabled` guard should prevent this
// from ever firing, but if it ever does, the client must refuse to
// make a malformed request and surface a clear local error.
await expect(api.getBatchDiff("", "930f25e07eab4ac0908c9770689385e1")).rejects.toMatchObject({
status: 400,
message: expect.stringContaining("?a=<batch_id>"),
});
await expect(api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "")).rejects.toMatchObject({
status: 400,
message: expect.stringContaining("?b=<batch_id>"),
});
// Network must not be touched.
expect(mockFetch).not.toHaveBeenCalled();
});
}); });
+96 -5
View File
@@ -175,12 +175,24 @@ async function readErrorBody(res: Response): Promise<string> {
try { try {
const t = await res.text(); const t = await res.text();
if (!t) return ""; if (!t) return "";
// FastAPI errors are `{ "error": "...", "detail": "..." }`. Surface the // FastAPI errors can be flat `{ "error": "...", "detail": "..." }`
// detail if present, else the raw text. // 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 { try {
const obj = JSON.parse(t) as { detail?: unknown; error?: unknown }; let current: unknown = JSON.parse(t);
if (typeof obj.detail === "string") return obj.detail; for (let depth = 0; depth < 5; depth++) {
if (typeof obj.error === "string") return obj.error; 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 { } catch {
// not JSON; fall through // not JSON; fall through
} }
@@ -558,6 +570,16 @@ async function getRemittance<T = unknown>(id: string): Promise<T> {
*/ */
async function getBatchDiff(a: string, b: string): Promise<BatchDiff> { async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
if (!isConfigured) throw notConfiguredError(); if (!isConfigured) throw notConfiguredError();
// Defense-in-depth: even though the `useBatchDiff` hook gates on both
// ids being non-empty, refuse to fire the request if either is
// missing. Surfaces a clear local error instead of letting the
// backend return its generic "Missing param" 400.
if (typeof a !== "string" || a.length === 0) {
throw new ApiError(400, "Missing param: ?a=<batch_id> is required.");
}
if (typeof b !== "string" || b.length === 0) {
throw new ApiError(400, "Missing param: ?b=<batch_id> is required.");
}
const res = await fetch( const res = await fetch(
joinUrl( joinUrl(
`/api/batch-diff?${qs({ a, b })}`, `/api/batch-diff?${qs({ a, b })}`,
@@ -786,6 +808,74 @@ async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
return { ...mapAck(row), rawJson: row.raw_json }; return { ...mapAck(row), rawJson: row.raw_json };
} }
/**
* Download a ZIP of regenerated X12 837 files for a parsed batch.
*
* Drives `POST /api/batches/{batchId}/export-837` with the requested
* claim_ids in the body. The backend returns a binary ZIP whose
* entries follow the HCPF X12 File Naming Standards template
* ``{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (one file per
* successfully serialized claim, with a per-claim millisecond offset
* so every entry has a unique 17-digit timestamp). The ZIP itself is
* named ``batch-{batchId}-{N}-claims.zip`` via Content-Disposition,
* where N is the success count.
*
* Per-claim serialization failures are surfaced via the
* `X-Cyclone-Serialize-Errors` response header (JSON-encoded array of
* `{claim_id, reason}`). The ZIP still contains the successful claims;
* the failures are returned alongside so the UI can show a partial-
* success toast like "Exported 18 · 2 couldn't be regenerated".
*
* Throws `ApiError` on non-2xx 404 (batch missing) is the most
* likely case. The client-side guard rejects empty `claimIds` without
* making a request.
*/
export interface BatchExportResult {
blob: Blob;
filename: string;
serializeErrors: Array<{ claim_id: string; reason: string }>;
}
async function exportBatch837(
batchId: string,
claimIds: string[],
): Promise<BatchExportResult> {
if (!isConfigured) throw notConfiguredError();
if (claimIds.length === 0) {
throw new Error("claimIds is empty");
}
const res = await fetch(
joinUrl(`/api/batches/${encodeURIComponent(batchId)}/export-837`),
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/zip",
},
body: JSON.stringify({ claim_ids: claimIds }),
},
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
const blob = await res.blob();
const cd = res.headers.get("content-disposition") ?? "";
const match = /filename="?([^";]+)"?/i.exec(cd);
const filename = match?.[1] ?? `batch-${batchId}-claims.zip`;
const errHeader = res.headers.get("x-cyclone-serialize-errors");
let serializeErrors: Array<{ claim_id: string; reason: string }> = [];
if (errHeader) {
try {
serializeErrors = JSON.parse(errHeader);
} catch {
// Malformed header — treat as empty rather than failing the download.
serializeErrors = [];
}
}
return { blob, filename, serializeErrors };
}
export const api = { export const api = {
isConfigured, isConfigured,
baseUrl: BASE_URL, baseUrl: BASE_URL,
@@ -799,6 +889,7 @@ export const api = {
listClaims, listClaims,
getClaimDetail, getClaimDetail,
serializeClaim837, serializeClaim837,
exportBatch837,
listRemittances, listRemittances,
getRemittance, getRemittance,
listProviders, listProviders,
+392 -703
View File
File diff suppressed because it is too large Load Diff
+350 -573
View File
File diff suppressed because it is too large Load Diff
+159 -491
View File
@@ -4,7 +4,9 @@ import { Files, GitBranch, Layers, Receipt } from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { EmptyState } from "@/components/ui/empty-state"; import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state"; import { ErrorState } from "@/components/ui/error-state";
import { 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 { BatchesList, BatchesListSkeleton } from "@/components/BatchesList";
import { import {
BatchDetailContent, BatchDetailContent,
@@ -16,6 +18,7 @@ import { useBatches } from "@/hooks/useBatches";
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard"; import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
import { api, ApiError, type BatchSummary } from "@/lib/api"; import { api, ApiError, type BatchSummary } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { fmt } from "@/lib/format";
import type { ParseResult837, ParseResult835 } from "@/types"; import type { ParseResult837, ParseResult835 } from "@/types";
function readBatchId(): string | null { function readBatchId(): string | null {
@@ -120,12 +123,16 @@ function useBatchDetail(id: string | null): {
const HELP_NOOP = () => {}; 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 * PageHeader hero (eyebrow + display title + status pill + ghost
* batch register (KPI strip + tabular list). Click a row to open the * watermark) KpiCard strip (4 tiles) single Card wrapping the
* detail drawer (rendered above the page, dimmed via the body * tabular register quiet hairline footer.
* opacity). *
* 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() { export function Batches() {
const { data, isLoading, isError, error, refetch } = useBatches(100); const { data, isLoading, isError, error, refetch } = useBatches(100);
@@ -166,7 +173,7 @@ export function Batches() {
const errKind = batchErrorKind(detailError); const errKind = batchErrorKind(detailError);
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Aggregate metrics for the § 01 KPI strip // Aggregate metrics for the KPI strip + hero copy.
// ----------------------------------------------------------------- // -----------------------------------------------------------------
const totals = items.reduce( const totals = items.reduce(
(acc, b) => ({ (acc, b) => ({
@@ -178,14 +185,20 @@ export function Batches() {
{ count: 0, p837: 0, p835: 0, claims: 0 }, { 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 72140px, 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 heroDelay = 0;
const foldDelay = 220; const kpiDelay = 140;
const titleDelay = 320; const tableDelay = 240;
const kpiDelay = 460; const footerDelay = 320;
const tableDelay = 600;
return ( return (
<> <>
@@ -219,541 +232,196 @@ export function Batches() {
<div <div
data-testid="batches-page-body" data-testid="batches-page-body"
className={cn( className={cn(
"space-y-0 animate-fade-in transition-opacity", "space-y-6 lg:space-y-10 animate-fade-in transition-opacity",
dimBackground && "pointer-events-none opacity-60", dimBackground && "pointer-events-none opacity-60",
)} )}
> >
{/* ================================================================= {/* =================================================================
HERO DARK EDITORIAL HEADER HERO dark editorial header
================================================================= */} ================================================================= */}
<section <section
className="relative pt-6 pb-8 lg:pt-9 lg:pb-10" className="relative animate-fade-in overflow-hidden"
style={{ animationDelay: `${heroDelay}ms` }} style={{ animationDelay: `${heroDelay}ms` }}
> >
{/* Ghost "PARSED" watermark — a print-shop stamp behind the title. */}
<div <div
aria-hidden="true" aria-hidden="true"
className="pointer-events-none select-none absolute inset-x-0 top-[58%] -translate-y-1/2 whitespace-nowrap display text-center" className="pointer-events-none select-none absolute -right-4 top-1/2 -translate-y-1/2 whitespace-nowrap display text-foreground"
style={{ style={{
fontSize: "clamp(160px, 20vw, 300px)", fontSize: "clamp(72px, 12vw, 140px)",
letterSpacing: "-0.05em", letterSpacing: "-0.04em",
opacity: 0.05, opacity: 0.045,
lineHeight: 1, lineHeight: 1,
color: "hsl(var(--surface-ink))",
}} }}
> >
PARSED {watermark}
</div> </div>
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-8 items-end mb-7"> <div className="relative z-10 flex items-end justify-between gap-4 sm:gap-6 flex-wrap">
<div className="min-w-0 max-w-3xl"> <div className="min-w-0">
<div className="flex items-center gap-3 mb-5"> <PageHeader
<div className="h-px w-14 bg-foreground/25" /> eyebrow={`Batches · Register · ${totals.count} on file`}
<span className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground"> title={
Batches · Register <>
</span> Parsed{" "}
<span <span className="italic text-muted-foreground/85">
className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground/60 hidden sm:inline" batches.
aria-hidden </span>
> </>
· {totals.count} on file }
</span> subtitle={
</div> <>
<h1 className="display text-[48px] sm:text-[64px] lg:text-[80px] leading-[0.92] text-foreground tracking-[-0.04em]"> Every 837P and 835 file the backend has ingested. Click a
Parsed{" "} row for envelope, summary, and a peek at the contained
<span className="italic text-muted-foreground/85">batches.</span> claims.
</h1> </>
}
/>
</div> </div>
<div className="flex flex-col items-start lg:items-end gap-3"> <div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3 py-1.5 text-[11px] mono uppercase tracking-[0.12em] text-muted-foreground backdrop-blur">
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3.5 py-2 text-[11.5px] mono uppercase tracking-[0.14em] text-muted-foreground backdrop-blur"> <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[hsl(var(--accent))]">
<Files <span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--accent))] opacity-60 animate-ping" />
className="h-3.5 w-3.5" </span>
strokeWidth={1.75} <Files className="h-3.5 w-3.5" strokeWidth={1.75} />
style={{ color: "hsl(var(--accent))" }} 837P · 835 · envelope &amp; summary
/>
837P · 835 · envelope & summary
</div>
<kbd
className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70"
>
Tap a row to open the drawer
</kbd>
</div> </div>
</div> </div>
<div className="relative z-10 max-w-2xl">
<p className="text-[14px] text-muted-foreground leading-relaxed">
Every 837P and 835 file the backend has ingested. Click a row
for envelope, summary, and a peek at the contained claims.
</p>
</div>
</section> </section>
{/* ================================================================= {/* =================================================================
FOLD TORN PAGE KPI STRIP four standard KpiCards, accent tokens instead of
paper-tinted custom tiles.
================================================================= */} ================================================================= */}
<div <section
aria-hidden aria-label="Batch register metrics"
className="relative h-14" className="grid gap-3.5 grid-cols-2 md:grid-cols-4 animate-fade-in-up"
style={{ animationDelay: `${foldDelay}ms` }} style={{ animationDelay: `${kpiDelay}ms` }}
> >
<div <KpiCard
className="absolute inset-x-0 top-0 h-1/2" label="On file"
style={{ value={fmt.num(totals.count)}
background: icon={Layers}
"linear-gradient(to bottom, hsl(0 0% 0% / 0.55), transparent)", accent="default"
}} hint={totals.count === 1 ? "batch" : "batches"}
/> />
<div <KpiCard
className="absolute inset-x-0 bottom-0 h-1/2" label="837P"
style={{ value={fmt.num(totals.p837)}
background: icon={GitBranch}
"linear-gradient(to top, hsl(30 14% 14% / 0.18), transparent)", accent="accent"
}} hint="professional claims"
/> />
<div <KpiCard
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px" label="835"
style={{ value={fmt.num(totals.p835)}
background: icon={Receipt}
"linear-gradient(to right, transparent, hsl(30 14% 14% / 0.5) 12%, hsl(30 14% 14% / 0.7) 50%, hsl(30 14% 14% / 0.5) 88%, transparent)", accent="warning"
}} hint="remittance advice"
/> />
<div <KpiCard
className="absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-2" label="Claims"
style={{ pointerEvents: "none" }} value={fmt.num(totals.claims)}
> accent="success"
{Array.from({ length: 48 }, (_, i) => ( hint="total in register"
<span />
key={i} </section>
className="block h-[3px] w-[3px] rounded-full"
style={{ backgroundColor: "hsl(30 14% 14% / 0.22)" }}
/>
))}
</div>
<div className="relative z-10 mx-auto h-full flex items-center justify-center gap-3 bg-background px-4 w-fit">
<span
className="display italic"
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
>
</span>
<span
className="mono uppercase tracking-[0.24em]"
style={{
color: "hsl(var(--muted-foreground))",
fontSize: 11,
fontWeight: 500,
}}
>
The register
</span>
<span
className="display italic"
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
>
</span>
</div>
</div>
{/* ================================================================= {/* =================================================================
PAPER PLANE REGISTER single Card wrapping the tabular list. Same
chrome language as the rest of the app: eyebrow + display
title + sidecar hint, dark-tone table.
================================================================= */} ================================================================= */}
<div <section
className="relative max-w-[1280px] mx-auto" aria-label="Batch register"
style={{ className="animate-fade-in-up"
animationDelay: `${titleDelay}ms`, style={{ animationDelay: `${tableDelay}ms` }}
backgroundColor: "hsl(var(--surface))",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 30px 80px -24px hsl(0 0% 0% / 0.45)",
}}
> >
{/* Paper grain */} <Card>
<div <CardContent className="p-6 lg:p-7 space-y-5">
aria-hidden <div className="flex items-end justify-between gap-6 flex-wrap">
className="pointer-events-none absolute inset-0 opacity-[0.04]" <div className="min-w-0">
style={{ <div className="eyebrow flex items-center gap-2 mb-2">
backgroundImage: <span className="inline-block h-px w-6 bg-foreground/20" />
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.08 0 0 0 0 0.05 0 0 0 0.9 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\")",
backgroundSize: "160px 160px",
mixBlendMode: "multiply",
}}
/>
{/* Title block */}
<div
className="relative px-8 lg:px-14 pt-12 pb-9 border-b animate-fade-in-up"
style={{
animationDelay: `${titleDelay}ms`,
borderColor: "hsl(30 14% 14% / 0.12)",
}}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-0 bottom-0 w-px"
style={{ backgroundColor: "hsl(30 14% 14% / 0.14)" }}
/>
<div className="flex items-end justify-between gap-8 flex-wrap">
<div>
<div
className="mono text-[12px] uppercase tracking-[0.24em] mb-4 font-medium"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Register · Parsed batches
</div>
<h2
className="display leading-[0.92] tracking-[-0.04em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(48px, 7vw, 96px)",
fontWeight: 400,
}}
>
The register.
</h2>
<div
className="mt-4 display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: "clamp(15px, 1.3vw, 18px)",
lineHeight: 1.4,
maxWidth: "32ch",
}}
>
One row per ingested file the kind, the input filename,
how many claims it carried, and the day it was parsed.
</div>
</div>
<div className="text-right shrink-0">
<div
className="mono text-[11px] uppercase tracking-[0.24em] mb-1.5 font-medium"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
On file
</div>
<div
className="display tabular-nums"
style={{
color: "hsl(var(--surface-ink))",
fontSize: 26,
lineHeight: 1.1,
}}
>
{totals.count}
</div>
<div
className="mono text-[11px] mt-1"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
batch{totals.count === 1 ? "" : "es"} · {totals.claims}{" "}
claim{totals.claims === 1 ? "" : "s"}
</div>
</div>
</div>
</div>
{/* KPI strip — § 01 Vital signs */}
<section
aria-label="Batch register metrics"
className="relative px-8 lg:px-14 py-7 animate-fade-in-up"
style={{ animationDelay: `${kpiDelay}ms` }}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-7 flex flex-col items-center gap-1"
>
<span
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
§ 01
</span>
<div
className="w-px h-10"
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
/>
<span
className="display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: 11,
writingMode: "vertical-rl",
transform: "rotate(180deg)",
letterSpacing: "0.16em",
}}
>
Vital signs
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<BatchesKpiTile
label="On file"
value={totals.count}
icon={<Layers className="h-3 w-3" strokeWidth={1.75} />}
tone="ink"
/>
<BatchesKpiTile
label="837P"
value={totals.p837}
icon={<GitBranch className="h-3 w-3" strokeWidth={1.75} />}
tone="blue"
/>
<BatchesKpiTile
label="835"
value={totals.p835}
icon={<Receipt className="h-3 w-3" strokeWidth={1.75} />}
tone="amber"
/>
<BatchesKpiTile
label="Claims"
value={totals.claims}
tone="success"
/>
</div>
</section>
{/* Table — § 02 The register */}
<section
aria-label="Batch register"
className="relative px-8 lg:px-14 pb-8 lg:pb-10 animate-fade-in-up"
style={{ animationDelay: `${tableDelay}ms` }}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-9 flex flex-col items-center gap-1"
>
<span
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
§ 02
</span>
<div
className="w-px h-12"
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
/>
<span
className="display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: 11,
writingMode: "vertical-rl",
transform: "rotate(180deg)",
letterSpacing: "0.16em",
}}
>
The register
</span>
</div>
<div
className="pt-6 border-t"
style={{
borderTopStyle: "double",
borderTopWidth: 3,
borderColor: "hsl(30 14% 14% / 0.12)",
}}
>
<div className="flex items-end justify-between gap-6 flex-wrap mb-5">
<div>
<div
className="mono text-[11.5px] uppercase tracking-[0.24em] font-semibold mb-2"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
The register The register
</div> </div>
<h3 <h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
className="display leading-[0.98] tracking-[-0.03em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(24px, 2.6vw, 32px)",
fontWeight: 400,
}}
>
All <span className="italic">ingests</span>, newest first. All <span className="italic">ingests</span>, newest first.
</h3> </h2>
</div> </div>
<div <p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
className="text-[12.5px] display italic"
style={{ color: "hsl(var(--surface-ink-2))", maxWidth: 380 }}
>
Tap a row to open the envelope drawer. Use{" "} Tap a row to open the envelope drawer. Use{" "}
<kbd <kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80 mono text-[10.5px] uppercase tracking-[0.14em]">
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
style={{
borderColor: "hsl(30 14% 14% / 0.20)",
color: "hsl(var(--surface-ink))",
}}
>
</kbd>{" "} </kbd>{" "}
/{" "} /{" "}
<kbd <kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80 mono text-[10.5px] uppercase tracking-[0.14em]">
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
style={{
borderColor: "hsl(30 14% 14% / 0.20)",
color: "hsl(var(--surface-ink))",
}}
>
</kbd>{" "} </kbd>{" "}
inside the drawer to step. inside the drawer to step.
</div> </p>
</div> </div>
{isError ? ( <div className="pt-5 border-t border-border/40">
<div {isError ? (
className="rounded-md border p-5"
style={{
borderColor: "hsl(30 14% 14% / 0.16)",
backgroundColor: "hsl(36 22% 96%)",
}}
>
<ErrorState <ErrorState
message="Couldn't load batches from the backend." message="Couldn't load batches from the backend."
detail={error instanceof Error ? error.message : String(error)} detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()} onRetry={() => refetch()}
/> />
</div> ) : isLoading ? (
) : isLoading ? (
<div
className="rounded-md border p-4 space-y-2"
style={{
borderColor: "hsl(30 14% 14% / 0.10)",
backgroundColor: "hsl(36 22% 96%)",
}}
>
<BatchesListSkeleton /> <BatchesListSkeleton />
</div> ) : items.length === 0 ? (
) : items.length === 0 ? ( <div className="rounded-md border border-dashed border-border/60 bg-card/40">
<div <EmptyState
className="rounded-md border p-10 text-center" eyebrow="Batches · awaiting first ingest"
style={{ message="Upload an 837P or 835 file on the Upload page to populate this list."
borderColor: "hsl(30 14% 14% / 0.16)", />
borderStyle: "dashed", </div>
backgroundColor: "hsl(36 22% 96%)", ) : (
}} <div className="rounded-md border border-border/60 overflow-hidden bg-card/40">
> <BatchesList
<EmptyState items={items}
eyebrow="Batches · awaiting first ingest" openId={batchId}
message="Upload an 837P or 835 file on the Upload page to populate this list." onOpen={open}
/> />
</div> </div>
) : ( )}
<div </div>
className="rounded-md border overflow-hidden" </CardContent>
style={{ </Card>
borderColor: "hsl(30 14% 14% / 0.10)", </section>
backgroundColor: "hsl(36 22% 98%)",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
}}
>
<BatchesList
items={items}
openId={batchId}
onOpen={open}
tone="paper"
/>
</div>
)}
</div>
</section>
{/* Footer */} {/* =================================================================
<div FOOTER single hairline-separated status row. Replaces the
className="relative px-8 lg:px-14 py-5 border-t flex items-center justify-between mono text-[10.5px] uppercase tracking-[0.18em]" warm-paper "End of register" treatment with a quiet
style={{ instrument-style status line in the same rhythm as the
borderColor: "hsl(30 14% 14% / 0.12)", other pages.
color: "hsl(var(--surface-ink-3))", ================================================================= */}
}} <section
> aria-label="Register status"
<span>End of register</span> className="animate-fade-in-up flex items-center justify-between gap-4 flex-wrap pt-5 border-t border-border/40"
<span>Cyclone · Batches</span> style={{ animationDelay: `${footerDelay}ms` }}
>
<div className="flex items-center gap-2 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--accent))]" />
Cyclone · Batches
</div>
<div className="flex items-center gap-3 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span> <span>
{totals.count} {totals.count === 1 ? "row" : "rows"} {totals.count} {totals.count === 1 ? "row" : "rows"}
</span> </span>
<span className="text-muted-foreground/30" aria-hidden>
·
</span>
<span>last parsed {lastParsed ? fmt.dateShort(lastParsed) : "—"}</span>
</div> </div>
</div> <div className="flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span>open drawer</span>
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80">
</kbd>
</div>
</section>
</div> </div>
</> </>
); );
} }
// ---------------------------------------------------------------------------
// BatchesKpiTile — paper-toned metric for the batch register.
// Mirrors AckKpiTile / KpiTile in the other hybrid pages.
// ---------------------------------------------------------------------------
function BatchesKpiTile({
label,
value,
tone,
icon,
}: {
label: string;
value: number;
tone: "blue" | "amber" | "ink" | "success";
icon?: React.ReactNode;
}) {
const accentMap = {
blue: "hsl(212 100% 45%)",
amber: "hsl(36 92% 50%)",
ink: "hsl(var(--surface-ink))",
success: "hsl(152 64% 38%)",
} as const;
const tintMap = {
blue: "hsl(212 85% 92%)",
amber: "hsl(36 82% 92%)",
ink: "hsl(36 22% 90%)",
success: "hsl(152 50% 88%)",
} as const;
const accent = accentMap[tone];
const tint = tintMap[tone];
return (
<div
className="relative rounded-xl p-5 overflow-hidden border"
style={{
backgroundColor: "hsl(var(--surface))",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06), inset 3px 0 0 0 hsl(0 0% 100% / 0.4)",
borderColor: "hsl(30 14% 14% / 0.10)",
}}
>
<div
aria-hidden
className="absolute left-0 top-5 bottom-5 w-[3px] rounded-r-sm"
style={{ backgroundColor: accent, opacity: 0.85 }}
/>
<div className="flex items-center justify-between mb-2.5">
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{label}
</div>
<div
className="h-5 w-5 rounded-md flex items-center justify-center"
style={{ backgroundColor: tint, color: accent }}
>
{icon ?? (
<span
className="block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: accent }}
/>
)}
</div>
</div>
<div
className="display tabular-nums tracking-[-0.04em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(28px, 3vw, 40px)",
lineHeight: 1,
fontWeight: 400,
}}
>
{value}
</div>
</div>
);
}
+136 -106
View File
@@ -224,9 +224,26 @@ export default function Inbox() {
return ( return (
<div <div
className="min-h-screen" className="min-h-screen relative"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }} style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
> >
{/* Ambient halo a soft, fixed radial that creates a subtle
lighter zone behind the InboxHeader. The inbox has its own
dark ticker-tape background so it doesn't receive the body
wash; this overlay restores the "lit from above" feel that
the main pages get from the body::before softbox, in the
same warm-amber accent family as the rest of the surface.
Pointer-events disabled. */}
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 top-0 z-0 h-[55vh]"
style={{
background: `
radial-gradient(ellipse 60% 38% at 50% 0%, hsla(36, 60%, 30%, 0.10), transparent 65%),
radial-gradient(ellipse 35% 28% at 88% 8%, hsla(36, 70%, 50%, 0.07), transparent 60%)
`,
}}
/>
{/* SP21 Phase 4 Task 4.4: RemitDrawer mounts here so a row click {/* SP21 Phase 4 Task 4.4: RemitDrawer mounts here so a row click
in the candidates / unmatched lanes drills into the parent in the candidates / unmatched lanes drills into the parent
remit. The drawer portals into document.body (Radix Dialog), remit. The drawer portals into document.body (Radix Dialog),
@@ -245,50 +262,11 @@ export default function Inbox() {
/> />
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} /> <InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
{/* Fold a thin amber rule + italic serif annotation that {/* Lane grid transitions directly into the summary strip below.
transitions the editorial header into the lane grid below. */} The previous "fold" divider with italic serif arrows and
<div "Five lanes · one queue" caption was the page's last warm-
aria-hidden paper print-shop artifact removed so the ticker-tape
className="relative h-10 flex items-center" aesthetic carries all the way through. */}
style={{ background: "var(--tt-bg)" }}
>
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
style={{
background:
"linear-gradient(to right, transparent, var(--tt-amber) 12%, var(--tt-amber) 88%, transparent)",
opacity: 0.35,
}}
/>
<div
className="relative z-10 mx-auto px-3 flex items-center gap-2"
style={{ background: "var(--tt-bg)" }}
>
<span
className="display italic"
style={{ color: "var(--tt-amber)", fontSize: 14 }}
>
</span>
<span
className="mono uppercase"
style={{
color: "var(--tt-ink-dim)",
fontSize: 10.5,
letterSpacing: "0.24em",
fontWeight: 500,
}}
>
Five lanes · one queue
</span>
<span
className="display italic"
style={{ color: "var(--tt-amber)", fontSize: 14 }}
>
</span>
</div>
</div>
<main className="px-6 lg:px-10 pb-24 pt-4 flex gap-4 items-stretch flex-wrap"> <main className="px-6 lg:px-10 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
<Lane <Lane
@@ -374,64 +352,101 @@ export default function Inbox() {
</main> </main>
{/* ---------------------------------------------------------------- {/* ----------------------------------------------------------------
PAPER-TONED SUMMARY STRIP TICKER-TAPE QUEUE SUMMARY
A cream "ledger" panel below the lanes that shows the aggregate A dark terminal panel below the lanes that aggregates the
eye-flow across the queue. Mirrors the "Magazine Spread" pattern eye-flow across the queue. Sits on the same --tt-bg-elev
used on the Dashboard/Claims/etc pages so the Inbox feels part surface as the Lane cards so the page reads as one continuous
of the same document set, not a stand-alone terminal. instrument instead of mixing a paper-toned "ledger" panel
into the terminal aesthetic.
---------------------------------------------------------------- */} ---------------------------------------------------------------- */}
<section <section
aria-label="Queue summary" aria-label="Queue summary"
className="relative mx-6 lg:mx-10 mt-6 mb-4" className="relative mx-6 lg:mx-10 mt-5 mb-6"
> >
<div <div
className="relative rounded-xl overflow-hidden border" className="relative rounded-md overflow-hidden"
style={{ style={{
backgroundColor: "hsl(var(--surface))", // "Lit from above" gradient — same treatment as the lane
borderColor: "hsl(30 14% 14% / 0.10)", // cards and the main .surface panels, so the queue
// summary reads as a slightly lifted, subtly lighter
// surface against the ticker-tape backdrop.
background:
"linear-gradient(180deg, hsl(220 18% 11.5%) 0%, hsl(220 18% 9.5%) 55%, hsl(220 18% 8.5%) 100%)",
border: "1px solid hsl(220 8% 18%)",
boxShadow: boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 12px 40px -16px hsl(0 0% 0% / 0.45)", "inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
}} }}
> >
{/* Title row */} {/* Header row machine-tag eyebrow + editorial headline +
aggregate Need eyes count, separated from the lane metrics
by a hairline. */}
<div <div
className="px-5 lg:px-7 py-4 border-b flex items-center justify-between gap-4 flex-wrap" className="px-5 lg:px-6 py-3.5 flex items-center justify-between gap-4 flex-wrap"
style={{ borderColor: "hsl(30 14% 14% / 0.10)" }} style={{ borderBottom: "1px solid hsl(220 8% 16%)" }}
> >
<div> <div className="min-w-0">
<div <div className="flex items-center gap-2.5 mb-1.5">
className="mono text-[10.5px] uppercase tracking-[0.20em] font-semibold mb-1.5" <span
style={{ color: "hsl(var(--surface-ink-3))" }} aria-hidden
> className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
Queue ledger style={{ background: "var(--tt-amber)" }}
/>
<span
className="mono uppercase"
style={{
color: "var(--tt-amber)",
fontSize: 10.5,
letterSpacing: "0.22em",
fontWeight: 600,
}}
>
Eye-flow
</span>
<span
className="mono uppercase"
style={{
color: "var(--tt-muted)",
fontSize: 10,
letterSpacing: "0.18em",
}}
aria-hidden
>
· five lanes, one queue
</span>
</div> </div>
<h2 <h2
className="display leading-[0.95] tracking-[-0.03em]" className="display italic"
style={{ style={{
color: "hsl(var(--surface-ink))", color: "var(--tt-ink)",
fontSize: "clamp(22px, 2.2vw, 28px)", fontSize: "clamp(20px, 1.9vw, 24px)",
lineHeight: 1.1,
fontWeight: 400, fontWeight: 400,
letterSpacing: "-0.02em",
}} }}
> >
The eye-flow, <span className="italic">at a glance.</span> at a glance.
</h2> </h2>
</div> </div>
<div <div className="text-right" aria-label="Aggregate metrics">
className="text-right"
aria-label="Aggregate metrics"
>
<div <div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold" className="mono uppercase"
style={{ color: "hsl(var(--surface-ink-3))" }} style={{
color: "var(--tt-ink-dim)",
fontSize: 10,
letterSpacing: "0.20em",
fontWeight: 500,
}}
> >
Need eyes Need eyes
</div> </div>
<div <div
className="display tabular-nums" className="display tabular-nums"
style={{ style={{
color: "hsl(var(--surface-ink))", color: "var(--tt-amber)",
fontSize: 24, fontSize: 28,
lineHeight: 1.1, lineHeight: 1,
fontWeight: 400,
marginTop: 4,
}} }}
> >
{needEyes} {needEyes}
@@ -439,8 +454,13 @@ export default function Inbox() {
</div> </div>
</div> </div>
{/* Lane metrics row — paper tiles that mirror each lane's accent */} {/* Lane metrics row each tile carries its own accent dot
<div className="grid grid-cols-2 lg:grid-cols-5 divide-x divide-y lg:divide-y-0" style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}> and count, so the operator can read the queue breakdown
at a glance without scanning back up to the Lane grid. */}
<div
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
style={{ borderTop: "1px solid hsl(220 8% 16%)" }}
>
<SummaryTile <SummaryTile
label="Rejected" label="Rejected"
value={lanes.rejected.length} value={lanes.rejected.length}
@@ -629,33 +649,31 @@ export default function Inbox() {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SummaryTile — paper-toned metric for the queue ledger strip. Mirrors // SummaryTile — ticker-tape metric for the eye-flow strip. Each tile
// the lane accent (oxblood / amber / ink-blue / muted) so the eye-flow // carries the lane's accent dot + label + count in the same color
// reads at a glance. // family the Lane component uses (oxblood / amber / ink-blue / muted),
// so the operator can read the queue breakdown at a glance without
// scanning back up to the lane grid.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const SUMMARY_ACCENTS: Record< const SUMMARY_ACCENTS: Record<
"oxblood" | "amber" | "ink" | "muted", "oxblood" | "amber" | "ink" | "muted",
{ dot: string; tint: string; text: string } { dot: string; value: string }
> = { > = {
oxblood: { oxblood: {
dot: "hsl(358 70% 42%)", dot: "var(--tt-oxblood)",
tint: "hsl(358 70% 92%)", value: "var(--tt-ink)",
text: "hsl(358 70% 30%)",
}, },
amber: { amber: {
dot: "hsl(36 92% 50%)", dot: "var(--tt-amber)",
tint: "hsl(36 82% 88%)", value: "var(--tt-amber)",
text: "hsl(36 92% 30%)",
}, },
ink: { ink: {
dot: "hsl(212 80% 45%)", dot: "var(--tt-ink-blue)",
tint: "hsl(212 85% 92%)", value: "var(--tt-ink)",
text: "hsl(212 80% 30%)",
}, },
muted: { muted: {
dot: "hsl(30 8% 50%)", dot: "var(--tt-muted)",
tint: "hsl(36 22% 90%)", value: "var(--tt-ink-dim)",
text: "hsl(30 8% 30%)",
}, },
}; };
@@ -672,41 +690,53 @@ function SummaryTile({
return ( return (
<div <div
className={cn( className={cn(
"relative px-4 py-4 first:pl-5 lg:first:pl-7 last:pr-5 lg:last:pr-7" "relative px-4 py-4 first:pl-5 lg:first:pl-6 last:pr-5 lg:last:pr-6"
)} )}
style={{ borderRight: "1px solid hsl(220 8% 14%)" }}
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span <span
aria-hidden aria-hidden
className="block h-1.5 w-1.5 rounded-full" className="block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: a.dot }} style={{
backgroundColor: a.dot,
boxShadow: `0 0 6px ${a.dot}`,
}}
/> />
<span <span
className="mono text-[10px] uppercase tracking-[0.20em] font-semibold" className="mono uppercase"
style={{ color: "hsl(var(--surface-ink-3))" }} style={{
color: "var(--tt-ink-dim)",
fontSize: 10.5,
letterSpacing: "0.18em",
fontWeight: 500,
}}
> >
{label} {label}
</span> </span>
</div> </div>
<div <div
className="display tabular-nums tracking-[-0.03em]" className="display tabular-nums"
style={{ style={{
color: "hsl(var(--surface-ink))", color: a.value,
fontSize: "clamp(24px, 2vw, 30px)", fontSize: "clamp(22px, 1.9vw, 28px)",
lineHeight: 1, lineHeight: 1,
fontWeight: 400, fontWeight: 400,
letterSpacing: "-0.02em",
}} }}
> >
{value} {value}
</div> </div>
<div <div
className="mono text-[10px] uppercase tracking-[0.16em] mt-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm" className="mono uppercase mt-1.5"
style={{ style={{
color: a.text, color: "var(--tt-muted)",
backgroundColor: a.tint, fontSize: 9.5,
letterSpacing: "0.16em",
fontWeight: 500,
}} }}
> >
{value === 0 ? "Clear" : value === 1 ? "row" : "rows"} {value === 0 ? "clear" : value === 1 ? "row" : "rows"}
</div> </div>
</div> </div>
); );
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -8,7 +8,8 @@ import React, { act, useEffect } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { MemoryRouter, useLocation } from "react-router-dom"; import { MemoryRouter, useLocation } from "react-router-dom";
import { createRoot, type Root } from "react-dom/client"; 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 { useAppStore } from "@/store";
import type { ClaimOutput, ClaimPayment, ParsedBatch } from "@/types"; import type { ClaimOutput, ClaimPayment, ParsedBatch } from "@/types";
+537 -1020
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -299,6 +299,13 @@ export interface ParseResult837 {
envelope: Envelope | null; envelope: Envelope | null;
claims: ClaimOutput[]; claims: ClaimOutput[];
summary: BatchSummary; 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 --------------------------------------------------------------- // --- 835 ERA ---------------------------------------------------------------
@@ -379,6 +386,8 @@ export interface ParseResult835 {
claims: ClaimPayment[]; claims: ClaimPayment[];
summary: BatchSummary; summary: BatchSummary;
validation?: ValidationReport | null; validation?: ValidationReport | null;
/** See ParseResult837.batch_id. */
batch_id?: string | null;
} }
// --- NDJSON streaming ------------------------------------------------------ // --- NDJSON streaming ------------------------------------------------------