24 Commits

Author SHA1 Message Date
Tyler 9bca4b608a 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.
2026-06-22 11:01:58 -06:00
cyclone 35298907bc docs(spec): add Ubuntu Docker deployment design
Production deployment of Cyclone on a single Ubuntu Linux server:
- Two-container Docker Compose (backend + nginx frontend)
- App-layer auth: username/password, argon2id, session cookies
- 3 fixed roles: admin / operator / viewer with documented action matrix
- SQLCipher encryption at rest, key as Docker secret
- Existing BackupService autostarts (24h interval, 14-day retention)
- LAN-only bind, single operator, VPN for outside access
- Manual update via 'docker compose pull', env-var rollback
- SFTP stays stub (download ZIP + MFT UI); listed as v2

Builds on the parent 2026-06-19 production-readiness spec which
shipped local-only in-memory store + react-query wiring.
2026-06-22 10:43:26 -06:00
Tyler 22720168c4 test(claims): wrap page in DrillStackProvider, switch to DrillDrawerHeader testids
Phase 5 Task 5.8 (PartiesGrid) and 5.9 (ValidationPanel) added useDrillStack()
calls, so Claims.test.tsx needs a DrillStackProvider wrapper around the
page render to keep the hook happy. Task 5.10 refactored the ClaimDrawer
header onto the shared DrillDrawerHeader, so the deep-link and close-button
tests need to find the title via <h2> and the close button via
aria-label='Close drawer' instead of the now-removed header-id /
header-close testids.

Without this fix the page-level Claims tests report 3 failures
(deep link, URL clear after close, ?-mark with drawer closed) that
were not failing on the Phase 4 base (9a313d2).
2026-06-21 18:10:43 -06:00
Tyler 1c0d855b8e refactor(claim-drawer): mount on shared DrillDrawerHeader shell 2026-06-21 18:10:43 -06:00
Tyler 8db5db7610 feat(claim-drawer): validation rule opens peek with rule catalog 2026-06-21 18:10:43 -06:00
Tyler 786ead8c94 feat(claim-drawer): payer name opens PeekModal on top of drawer 2026-06-21 18:10:43 -06:00
Tyler 6c773c1159 feat(upload): streamed claim cards offer drill to persisted entity 2026-06-21 18:10:43 -06:00
Tyler 5c7e9b6168 feat(batch-diff): claim ids drillable to /claims?claim=ID 2026-06-21 18:10:43 -06:00
Tyler ac87ed4908 feat(reconciliation): card body drillable, select button split 2026-06-21 18:10:43 -06:00
Tyler 4a8ce1a524 feat(inbox): rejected + payer_rejected + done_today rows drillable 2026-06-21 18:10:43 -06:00
Tyler 5053a1ea8e feat(acks): row click opens AckDrawer 2026-06-21 18:10:43 -06:00
Tyler 33fa899217 feat(drill): AckDrawer with header + segment status list 2026-06-21 18:10:43 -06:00
Tyler 6fdbceefc2 feat(drill): useAckDrawerUrlState — ?ack= URL sync 2026-06-21 18:10:43 -06:00
Tyler 7290cac643 plan+spec: add migration 0014 to relax PKs; renumber plan Task 1.3 -> 1.4
The implementer caught that claims.id is a single-column PK, which
prevents the same CLM01 from existing in multiple batches. That
makes the spec's pre-flight 409 workflow unreachable and resubmits
impossible. Migration 0014 relaxes the PKs to composite (batch_id,
id) on both claims and remittances, and updates all FKs that
referenced the old single-column PK. Task 1.3 in the plan is now
the migration; the previous Task 1.3 (helper tightening) is
renumbered to Task 1.4 and will run after 0014 lands.
2026-06-21 18:09:06 -06:00
Tyler 5e8c7b11ea plan: parse-decide workflow implementation for 837/835 upload dedup 2026-06-21 17:53:21 -06:00
Tyler 9c0cec8f0c spec: parse-then-decide workflow for 837/835 upload dedup 2026-06-21 17:47:03 -06:00
Tyler 9a313d2c1b feat(activity): remit_received events drill to RemitDrawer 2026-06-21 17:19:31 -06:00
Tyler 2eb61f16ff feat(reconciliation): candidate rows drill to RemitDrawer or ClaimDrawer 2026-06-21 17:19:31 -06:00
Tyler 12c2913ba1 feat(batchdiff): remit rows drill to RemitDrawer 2026-06-21 17:19:31 -06:00
Tyler 54440da2cd feat(inbox): candidates + unmatched rows drill to RemitDrawer or ClaimDrawer 2026-06-21 17:19:31 -06:00
Tyler 7427838292 feat(remits): row click opens RemitDrawer (replaces inline CAS expand) 2026-06-21 17:19:31 -06:00
Tyler b606e8c9a2 plan: drop claims UNIQUE + 409 UX implementation plan 2026-06-21 17:02:10 -06:00
Tyler ac709c07c3 spec: self-review fixes (explicit claim_id arg, defer_foreign_keys) 2026-06-21 16:58:50 -06:00
Tyler 08e83da91d feat(drill): ProviderDrawer — Claims + Activity tabs from extended /providers/{npi} 2026-06-21 16:56:36 -06:00
90 changed files with 15717 additions and 4145 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 cyclone import __version__, db
from cyclone.db import Claim, ClaimState, Remittance
from cyclone.db import Batch, Claim, ClaimState, Remittance
from sqlalchemy import desc, or_
from sqlalchemy.exc import IntegrityError
from cyclone.inbox_state import apply_999_rejections
@@ -420,11 +420,18 @@ async def parse_837(
ack_body = _build_and_persist_ack(rec.id)
if ack_body is not None:
body["ack"] = ack_body
# Surface the server-side batch id so the frontend can call
# /api/batches/{id}/export-837 (and any other batch-scoped
# endpoint) without a separate listBatches round-trip.
body["batch_id"] = rec.id
return JSONResponse(content=body)
# Default: NDJSON stream.
# Default: NDJSON stream. Pass the server-side batch id so the
# streaming client (the React Upload page) can call batch-scoped
# endpoints like /api/batches/{id}/export-837 without a separate
# GET /api/batches round-trip.
return StreamingResponse(
_ndjson_stream(result),
_ndjson_stream(result, batch_id=rec.id),
media_type="application/x-ndjson",
)
@@ -606,9 +613,12 @@ async def parse_835_endpoint(
body["reconciliation"] = _reconciliation_summary_for_batch(rec.id)
return JSONResponse(content=body)
# Default: NDJSON stream.
# Default: NDJSON stream. Pass the server-side batch id so the
# streaming client can call batch-scoped endpoints without a
# separate GET /api/batches round-trip (see /api/parse-837 for the
# parallel change).
return StreamingResponse(
_ndjson_stream_835(result),
_ndjson_stream_835(result, batch_id=rec.id),
media_type="application/x-ndjson",
)
@@ -1295,6 +1305,215 @@ def inbox_resubmit_rejected(
)
@app.post("/api/batches/{batch_id}/export-837")
def export_batch_837(request: Request, batch_id: str, body: dict):
"""Download a ZIP of regenerated X12 837 files for the requested claim_ids.
Body shape: ``{"claim_ids": [str, ...]}``.
Each successfully serialized claim becomes an entry in the ZIP named
per the HCPF X12 File Naming Standards:
``tp{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (with a per-claim
millisecond offset so every file in the bundle has a unique name).
The ``serialize_837_for_resubmit`` serializer is used so every file
gets a unique interchange / group control number — back-to-back
exports of the same set must produce different envelopes (required
by X12).
The submitter block (Loop 1000A — NM1*41 + PER) is populated from
the clearhouse singleton (dzinesco's identity in the seeded config)
and the receiver block (NM1*40) is populated from the per-payer
config. Without this wiring, the serializer falls back to
``CYCLONE`` / ``RECEIVER`` placeholders and HCPF rejects the file.
No DB state is mutated by this endpoint — it is read-only. Compare
with ``/api/inbox/rejected/resubmit?download=true`` which ALSO flips
``ClaimState.REJECTED → SUBMITTED``; the two endpoints are
intentionally separate.
Responses:
200 — ``application/zip`` with the .x12 entries. Per-claim failures
are surfaced via the ``X-Cyclone-Serialize-Errors`` header
(JSON-encoded array of ``{claim_id, reason}``).
400 — ``claim_ids`` missing or empty.
404 — ``batch_id`` unknown.
422 — every claim failed to serialize; body is JSON listing all
failures (``{"detail": {"serialize_errors": [...]}}``).
"""
import zipfile
from datetime import datetime
from zoneinfo import ZoneInfo
from cyclone.edi.filenames import build_outbound_filename
ids = body.get("claim_ids") or []
if not ids:
raise HTTPException(400, "claim_ids required")
with db.SessionLocal()() as s:
batch = s.get(Batch, batch_id)
if batch is None:
raise HTTPException(404, f"unknown batch: {batch_id}")
serialize_errors: list[dict] = []
ordered_rows: list[tuple[str, "Claim"]] = []
for cid in ids:
c = s.get(Claim, cid)
if c is None:
serialize_errors.append({"claim_id": cid, "reason": "unknown claim_id"})
continue
ordered_rows.append((cid, c))
# Pull clearhouse identity (submitter). If unseeded, the serializer
# falls back to placeholder defaults — degraded but not a hard error.
ch = store.get_clearhouse()
submitter_kwargs: dict = {}
if ch is not None:
submitter_kwargs = {
"sender_id": ch.tpid,
"submitter_name": ch.submitter_name,
"submitter_contact_name": ch.submitter_contact_name,
"submitter_contact_email": ch.submitter_contact_email,
}
# Submitter phone is not in the clearhouse config today, but if
# it ever is, wire it here. Email is the canonical contact
# channel for HCPF submissions per the SP9 spec.
if getattr(ch, "submitter_contact_phone", None):
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
# Resolve per-claim payer config so each file's receiver (NM1*40)
# and SBR09 are correct. Cache so we don't re-query the same payer.
from cyclone.db import PayerConfigORM as _PayerConfigORM
_payer_cache: dict[str, dict | None] = {}
def _resolve_payer_cfg(claim_obj: ClaimOutput) -> dict | None:
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
cache_key = pid or pname
if cache_key in _payer_cache:
return _payer_cache[cache_key]
cfg: dict | None = None
with db.SessionLocal()() as ss:
# 1. Exact match on (payer_id, "837P")
if pid:
row = ss.get(_PayerConfigORM, (pid, "837P"))
if row is not None:
cfg = dict(row.config_json)
# 2. Fallback: any row whose payer_id matches the parsed payer.name
# (HCPF files emit "SKCO0" in NM109 but the canonical
# payer_id in the DB is "CO_TXIX" — name-matching is the
# pragmatic lookup for that case).
if cfg is None and pname:
row = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.all()
)
for r in row:
cj = dict(r.config_json)
if cj.get("submitter_name") and pname.lower() in str(cj).lower():
cfg = cj
break
if (r.payer_id or "").upper() == pname.upper():
cfg = cj
break
# 3. Last resort: first 837P row in the table.
if cfg is None:
row = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.first()
)
if row is not None:
cfg = dict(row.config_json)
_payer_cache[cache_key] = cfg
return cfg
# Build per-claim kwargs (receiver + SBR09) lazily. Receiver
# defaults to the parsed payer name/ID if no config row matches.
def _serialize_kwargs(claim_obj: ClaimOutput) -> dict:
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
receiver_id = (
payer_cfg.get("receiver_id")
or (claim_obj.payer.id if claim_obj.payer else None)
or "RECEIVER"
)
receiver_name = (
payer_cfg.get("receiver_name")
or (claim_obj.payer.name if claim_obj.payer else None)
or receiver_id
)
sbr09 = payer_cfg.get("sbr09_default") or "MC"
return {
"receiver_id": receiver_id,
"receiver_name": receiver_name,
"claim_filing_indicator_code": sbr09,
}
# Base MT timestamp for HCPF filenames. We add a per-claim
# millisecond offset so each file in the ZIP has a unique 17-digit
# ts (HCPF requires that; the spec also enforces "1of1" for the
# sequence element).
base_ts = datetime.now(ZoneInfo("America/Denver"))
def _per_claim_filename(idx: int, cid: str) -> str:
if ch is None:
# No clearhouse — fall back to a per-claim friendly name.
return f"claim-{cid}.x12"
# Millisecond offset, with second/minute rollover.
offset_ms = (idx - 1) * 1 # 1 ms per claim is enough within an export
ts_mt = base_ts.fromtimestamp(
base_ts.timestamp() + offset_ms / 1000.0, tz=ZoneInfo("America/Denver")
)
return build_outbound_filename(ch.tpid, "837P", now_mt=ts_mt)
buf = io.BytesIO()
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
for idx, (cid, c) in enumerate(ordered_rows, start=1):
if not c.raw_json:
serialize_errors.append({"claim_id": cid, "reason": "no raw_json"})
continue
try:
claim_obj = ClaimOutput.model_validate(c.raw_json)
except Exception as exc:
serialize_errors.append(
{"claim_id": cid, "reason": f"raw_json invalid: {exc}"}
)
continue
try:
kwargs = {**submitter_kwargs, **_serialize_kwargs(claim_obj)}
text = serialize_837_for_resubmit(
claim_obj, interchange_index=idx, **kwargs
)
except SerializeError837 as exc:
serialize_errors.append({"claim_id": cid, "reason": str(exc)})
continue
zf.writestr(_per_claim_filename(idx, cid), text)
success_count = len(ids) - len(serialize_errors)
if serialize_errors and success_count == 0:
# Every claim failed — surface the failure list in the body so the
# UI can render a useful error toast (the response is not a ZIP).
raise HTTPException(
422,
detail={"serialize_errors": serialize_errors},
)
buf.seek(0)
headers = {
"Content-Disposition": (
f'attachment; filename="batch-{batch_id}-{success_count}-claims.zip"'
),
}
if serialize_errors:
headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors)
return Response(
content=buf.getvalue(),
media_type="application/zip",
headers=headers,
)
@app.get("/api/inbox/export.csv")
def inbox_export_csv(lane: str):
"""Stream a CSV for a single lane."""
@@ -2321,6 +2540,51 @@ def submit_to_clearhouse(body: dict):
if ch is None:
raise HTTPException(status_code=500, detail="clearhouse not seeded")
# Submitter (Loop 1000A) comes from the clearhouse config. The
# receiver (NM1*40) and SBR09 come from the per-payer config and
# are resolved per-claim below. Without this wiring, the
# serializer would emit "CYCLONE" / "RECEIVER" placeholders and
# the file would be rejected by HCPF.
submitter_kwargs = {
"sender_id": ch.tpid,
"submitter_name": ch.submitter_name,
"submitter_contact_name": ch.submitter_contact_name,
"submitter_contact_email": ch.submitter_contact_email,
}
if getattr(ch, "submitter_contact_phone", None):
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
# Build a payer_id → PayerConfig837 map once so we can look up the
# receiver + SBR09 default for each claim.
from cyclone.db import PayerConfigORM as _PayerConfigORM
def _resolve_payer_cfg(claim_obj) -> dict | None:
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
with db.SessionLocal()() as ss:
if pid:
row = ss.get(_PayerConfigORM, (pid, "837P"))
if row is not None:
return dict(row.config_json)
if pname:
rows = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.all()
)
for r in rows:
cj = dict(r.config_json)
if (r.payer_id or "").upper() == pname.upper():
return cj
if pname.lower() in str(cj).lower():
return cj
row = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.first()
)
return dict(row.config_json) if row else None
client = make_client(ch.sftp_block)
results = []
for cid in claim_ids:
@@ -2329,6 +2593,34 @@ def submit_to_clearhouse(body: dict):
except Exception as exc: # noqa: BLE001
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
continue
# Re-resolve the claim so we can look up its payer config. We
# re-parse the stored x12_text to get a ClaimOutput (same path
# the serializer uses).
try:
from cyclone.parsers.parse_837 import parse as _parse837
claim_row_obj = _load_claim_row(cid)
if claim_row_obj is None or not (claim_row_obj.raw_json or {}).get("x12_text"):
raise RuntimeError("no stored x12_text for claim")
parsed = _parse837(claim_row_obj.raw_json["x12_text"])
claim_obj = parsed.claims[0] if parsed.claims else None
except Exception:
claim_obj = None
if claim_obj is not None:
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
receiver_id = payer_cfg.get("receiver_id") or (claim_obj.payer.id if claim_obj.payer else None) or "RECEIVER"
receiver_name = payer_cfg.get("receiver_name") or (claim_obj.payer.name if claim_obj.payer else None) or receiver_id
sbr09 = payer_cfg.get("sbr09_default") or "MC"
# Re-serialize with the proper envelope values.
try:
x12_text = _serialize_claim_for_submit(
cid,
**{**submitter_kwargs, "receiver_id": receiver_id,
"receiver_name": receiver_name,
"claim_filing_indicator_code": sbr09},
)
except Exception as exc: # noqa: BLE001
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
continue
filename = build_outbound_filename(ch.tpid, "837P")
remote = f"{ch.sftp_block.paths['outbound']}/{filename}"
staging_path = client.write_file(remote, x12_text.encode("utf-8"))
@@ -2357,9 +2649,20 @@ def submit_to_clearhouse(body: dict):
return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}
def _serialize_claim_for_submit(claim_id: str) -> str:
def _load_claim_row(claim_id: str):
"""Helper: load a Claim row by id (or return None)."""
with db.SessionLocal()() as s:
return s.get(Claim, claim_id)
def _serialize_claim_for_submit(claim_id: str, **kwargs) -> str:
"""Serialize a claim to X12 for SFTP submission. Lazy import of the
serializer to avoid pulling FastAPI machinery at module import time."""
serializer to avoid pulling FastAPI machinery at module import time.
Optional ``**kwargs`` are forwarded to the serializer — used to
pass through clearhouse submitter info and per-payer receiver info
so the regenerated file matches what the HCPF MFT expects.
"""
from cyclone.parsers.serialize_837 import serialize_837
from cyclone import db
with db.SessionLocal()() as s:
@@ -2371,15 +2674,17 @@ def _serialize_claim_for_submit(claim_id: str) -> str:
from cyclone.parsers.parse_837 import parse
raw = row.raw_json or {}
# Reconstruct minimal ClaimOutput from raw_json; this is best-effort.
return _serialize_claim_from_raw(row, raw)
return _serialize_claim_from_raw(row, raw, **kwargs)
def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
def _serialize_claim_from_raw(claim_row, raw: dict, **kwargs) -> str:
"""Best-effort serializer that uses the stored raw_json to emit a fresh 837.
For SP9 this delegates to the existing serialize_837 helper if the
claim has a complete raw_segments array. Otherwise it returns a
minimal placeholder.
minimal placeholder. ``**kwargs`` are forwarded to the serializer
so callers can pass through submitter / receiver / SBR09 values
from the clearhouse and per-payer configs.
"""
from cyclone.parsers.serialize_837 import serialize_837
from cyclone.parsers.parse_837 import parse
@@ -2389,7 +2694,7 @@ def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
if isinstance(raw, dict) and raw.get("x12_text"):
result = parse(raw["x12_text"])
if result.claims:
return serialize_837(result.claims[0])
return serialize_837(result.claims[0], **kwargs)
# Fallback: raise so the caller sees an error.
raise RuntimeError(
f"claim {claim_row.id!r} cannot be re-serialized: no stored x12_text"
+30 -6
View File
@@ -93,19 +93,40 @@ def ndjson_stream_list(
}) + "\n"
def ndjson_stream_837(result: ParseResult) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → claims → summary."""
def ndjson_stream_837(
result: ParseResult, batch_id: str | None = None,
) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → claims → summary.
The ``batch_id`` is the server-side UUID assigned by the persistence
layer when the batch is ingested; the JSON response path exposes it
as the top-level ``batch_id`` field, but the NDJSON stream needs it
inline on the summary event so streaming clients can call
batch-scoped endpoints (``/api/batches/{id}/export-837``, …) without
a separate ``GET /api/batches`` round-trip. When ``batch_id`` is not
supplied the summary omits the field, preserving backward compat
with clients that don't expect it.
"""
envelope_obj = (
result.envelope.model_dump() if result.envelope is not None else None
)
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
for claim in result.claims:
yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
summary_data = json.loads(result.summary.model_dump_json())
if batch_id is not None:
summary_data["batch_id"] = batch_id
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary."""
def ndjson_stream_835(
result: ParseResult835, batch_id: str | None = None,
) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary.
See ``ndjson_stream_837`` for why the optional ``batch_id`` is
merged into the summary event.
"""
yield (json.dumps({"type": "envelope", "data": json.loads(result.envelope.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "financial_info", "data": json.loads(result.financial_info.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8")
@@ -113,7 +134,10 @@ def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
for claim in result.claims:
yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
summary_data = json.loads(result.summary.model_dump_json())
if batch_id is not None:
summary_data["batch_id"] = batch_id
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
def strict_rewrite_837(result: ParseResult) -> ParseResult:
+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)
Outbound (we send):
{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
Example: 11525703-837P-20260620132243505-1of1.x12
tp{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
Example: tp11525703-837P-20260620132243505-1of1.x12
Inbound (HPE sends to our ToHPE):
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
@@ -28,14 +28,15 @@ from cyclone.providers import InboundFilename
# Regexes
# ---------------------------------------------------------------------------
# Outbound: 11525703-837P-20260620132243505-1of1.x12
# Outbound: tp11525703-837P-20260620132243505-1of1.x12
# - tp: literal "tp" prefix
# - tpid: 1+ digits
# - tx: 1+ alnum
# - ts: 17 digits (yyyymmddhhmmssSSS)
# - seq: literal "1of1"
# - ext: 1+ alnum
OUTBOUND_RE = re.compile(
r"^(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
r"^tp(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
)
# Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
@@ -79,7 +80,8 @@ def build_outbound_filename(
time in ``America/Denver`` is used.
Returns:
Filename like "11525703-837P-20260620132243505-1of1.x12"
Filename like "tp11525703-837P-20260620132243505-1of1.x12"
(note the ``tp`` prefix per HCPF outbound spec).
Raises:
ValueError: If tpid is non-numeric, tx contains invalid chars, or
@@ -99,7 +101,10 @@ def build_outbound_filename(
# Format: yyyymmddhhmmssSSS — 17 digits total
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
assert len(ts) == 17
return f"{tpid}-{tx}-{ts}-1of1.{ext}"
# Per HCPF outbound spec, prefix is "tp" + tpid. Matches the format
# we receive from HPE inbound (which uses uppercase TP) and the
# historical outbound prodfile naming (e.g. tp11525703-837P-...).
return f"tp{tpid}-{tx}-{ts}-1of1.{ext}"
# ---------------------------------------------------------------------------
+9
View File
@@ -63,6 +63,7 @@ class ClaimHeader(_Base):
frequency_code: str | None = None
provider_signature: str | None = None
assignment: str | None = None
benefits_assignment_certification: str | None = None # CLM08 (Y/N)
release_of_info: str | None = None
prior_auth: str | None = None
@@ -87,6 +88,14 @@ class ServiceLine(_Base):
place_of_service: str | None = None
service_date: date | None = None
provider_reference: str | None = None
# SV1-07 — Diagnosis Code Pointer. Points to one or more
# diagnosis codes in the parent claim's HI segment ("1".. "12",
# space-separated when multiple). For 837P with a non-empty HI
# segment, SV1-07 is required by HCPF / Gainwell. The parser
# captures it from the source; the serializer defaults to "1"
# when the claim has at least one diagnosis and no explicit
# pointer was captured (matches the common single-dx case).
dx_pointer: str | None = None
class ValidationIssue(_Base):
+8
View File
@@ -202,6 +202,7 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
frequency_code=freq or None,
provider_signature=clm[6] if len(clm) > 6 else None,
assignment=clm[7] if len(clm) > 7 else None,
benefits_assignment_certification=clm[8] if len(clm) > 8 else None,
release_of_info=clm[9] if len(clm) > 9 else None,
)
@@ -285,6 +286,12 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
except Exception:
units = None
place_of_service = seg[5] if len(seg) > 5 else None
# SV1-06 (Unit Basis of Measurement) is X12 "UN" for "units" — we
# already use unit_type in SV1-03; SV1-06 is rarely populated and
# is not required by HCPF.
# SV1-07 — Diagnosis Code Pointer (e.g. "1" for the first HI
# diagnosis). Required by HCPF when the claim has diagnoses.
dx_pointer = seg[7] if len(seg) > 7 and seg[7] else None
service_date: date | None = None
provider_ref: str | None = None
@@ -311,6 +318,7 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
place_of_service=place_of_service,
service_date=service_date,
provider_reference=provider_ref,
dx_pointer=dx_pointer,
),
idx,
)
+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,
sender_id,
receiver_id,
_today_yymmdd(),
# GS-04 must be CCYYMMDD (8 digits) per X12 — ISA uses YYMMDD
# (6 digits) for the older format, but the GS segment is the
# newer ANSI X12 format and requires the full year.
_today_yyyymmdd(),
_today_hhmm(),
group_control_number,
"X",
@@ -186,17 +189,30 @@ def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
return _ELEM.join(parts) + _SEG
def _build_per(contact_name: str | None, contact_phone: str | None) -> str:
"""PER segment — submitter contact. Returns empty when no contact info."""
if not contact_name and not contact_phone:
return ""
parts = [
"PER",
"IC", # PER01 — contact function code (Information Contact)
contact_name or "",
"TE", # PER03 — phone qualifier
contact_phone or "",
]
def _build_per(
contact_name: str | None,
contact_phone: str | None,
contact_email: str | None = None,
email_qual: str = "EM",
) -> str:
"""PER segment — submitter contact (Loop 1000A).
X12 005010X222A1 *requires* at least one PER segment in Loop 1000A
(Submitter Name) and at least PER01 must be present, so this
builder always emits a segment. PER01 = "IC" (Information Contact).
The remaining elements are filled from the available contact info:
name, then email (preferred Gainwell/HCPF expect this), then phone.
"""
parts = ["PER", "IC"]
if contact_name:
parts.append(contact_name)
if contact_email:
parts.append(email_qual) # PER03 — email qualifier (default "EM")
parts.append(contact_email) # PER04 — the email itself
elif contact_phone:
parts.append("TE") # PER03 — phone qualifier
parts.append(contact_phone) # PER04 — the phone itself
return _ELEM.join(parts) + _SEG
@@ -236,26 +252,40 @@ def _build_hl(hl_id: str, parent_id: str, level_code: str, child_code: str) -> s
return _ELEM.join(parts) + _SEG
def _build_sbr(relationship_code: str | None, member_id: str | None,
payer_name: str | None) -> str:
def _build_sbr(
individual_relationship_code: str | None,
claim_filing_indicator_code: str | None,
) -> str:
"""SBR segment — subscriber information.
SBR01 (relationship code) defaults to ``"P"`` (Patient = self) which is
the most common case for professional claims; the parser does not store
this on the canonical Subscriber model so we cannot thread it through
without adding a model field.
Slot layout (X12 005010X222A1):
SBR01 Payer Responsibility Sequence Number Code. Default ``"P"``
(Patient = primary). The parser does not capture this
field on ``ClaimOutput`` so we default it.
SBR02 Individual Relationship Code. ``"18"`` = self, ``"01"`` = spouse, etc.
The parser does not capture this either; we default ``"18"``
for the common self-pay case.
SBR09 Claim Filing Indicator Code. ``"MC"`` for Medicaid,
``"16"`` for Medicare Part B, etc. The canonical
PayerConfig837 carries ``sbr09_default``; we thread it in
from the caller.
The member_id and payer name do NOT belong in SBR the member_id
lives in NM109 of the NM1*IL segment, and the payer name is in
NM103 of NM1*PR. (Earlier revisions of this function put them in
SBR06 / SBR09, which is wrong and rejected by HCPF.)
"""
parts = [
"SBR",
relationship_code or "P",
"", # SBR02 — group number
"", # SBR03 — group name
"", # SBR04 — claim filing indicator code
"", # SBR05 — sequence number code
payer_name or "", # SBR06 — claim filing indicator code (CO uses MC)
"", # SBR07
"", # SBR08
member_id or "", # SBR09 — claim submitter's id
"P", # SBR01 — primary
individual_relationship_code or "18", # SBR02 — self
"", # SBR03 — group number
"", # SBR04 — group name
"", # SBR05 — insurance type code
"", # SBR06 — coordination of benefits
"", # SBR07 — yes/no condition
"", # SBR08 — employment status code
claim_filing_indicator_code or "", # SBR09 — claim filing indicator
]
return _ELEM.join(parts) + _SEG
@@ -300,7 +330,11 @@ def _build_clm(claim) -> str:
clm05, # CLM05 — composite POS:qualifier:frequency_code
claim.provider_signature or "Y", # CLM06
claim.assignment or "Y", # CLM07
"", # CLM08 — benefit assignment certification
# CLM08 — Benefits Assignment Certification. X12 837P requires
# this when CLM07 = "Y" (the common case for in-network
# professional claims). Default to "Y" when the source did
# not capture one — matches what 99% of HCPF files look like.
claim.benefits_assignment_certification or "Y", # CLM08
claim.release_of_info or "Y", # CLM09
]
return _ELEM.join(parts) + _SEG
@@ -325,21 +359,41 @@ def _build_lx(line_number: int) -> str:
return _ELEM.join(["LX", str(line_number)]) + _SEG
def _build_sv1(line) -> str:
"""SV1 segment — professional service line."""
def _build_sv1(line, *, dx_pointer: str | None = None) -> str:
"""SV1 segment — professional service line.
X12 005010X222A1 layout (837P):
SV1-01 composite procedure identifier
SV1-02 monetary amount (charge)
SV1-03 unit of basis measurement (UN, MJ, etc.) ``line.unit_type``
SV1-04 service unit count ``line.units``
SV1-05 place of service code ``line.place_of_service``
SV1-06 **NOT USED** by this guide (must be empty)
SV1-07 diagnosis code pointer ``dx_pointer`` (required when the
parent claim has an HI segment)
The parser captures the original SV1-07 pointer when present; the
serializer defaults it to ``"1"`` (pointing at the first HI
diagnosis) when the claim has diagnoses and no explicit pointer
was captured. When the claim has no HI segment we leave SV1-07
empty to match the spec.
"""
proc = line.procedure
code = proc.code if proc else ""
mods = proc.modifiers if proc else []
composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
charge = f"{Decimal(line.charge or 0):.2f}"
units = f"{Decimal(line.units):g}" if line.units is not None else "1"
sv1_07 = dx_pointer or ""
parts = [
"SV1",
composite,
charge,
line.unit_type or "UN",
units,
line.place_of_service or "",
composite, # SV1-01
charge, # SV1-02
line.unit_type or "UN", # SV1-03 — unit basis code
units, # SV1-04
line.place_of_service or "", # SV1-05
"", # SV1-06 — NOT USED in 837P
sv1_07, # SV1-07 — diagnosis pointer
]
return _ELEM.join(parts) + _SEG
@@ -357,15 +411,20 @@ def _build_dtp_472(service_date: date | None) -> str:
# ---------------------------------------------------------------------------
def _build_submitter_block(sender_id: str, submitter_name: str | None,
contact_name: str | None,
contact_phone: str | None) -> list[str]:
def _build_submitter_block(
sender_id: str,
submitter_name: str | None,
contact_name: str | None,
contact_phone: str | None,
contact_email: str | None = None,
email_qual: str = "EM",
) -> list[str]:
out = [
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
]
per = _build_per(contact_name, contact_phone)
if per:
out.append(per)
# PER is required by X12 (at least PER01). _build_per always emits
# the segment; the submitter block always has exactly one.
out.append(_build_per(contact_name, contact_phone, contact_email, email_qual))
return out
@@ -395,11 +454,14 @@ def _build_billing_provider_block(provider) -> list[str]:
return out
def _build_subscriber_block(subscriber, payer_name: str | None) -> list[str]:
def _build_subscriber_block(
subscriber,
claim_filing_indicator_code: str | None,
) -> list[str]:
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
out = [
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children
_build_sbr("18", subscriber.member_id, payer_name),
_build_sbr("18", claim_filing_indicator_code),
_build_nm1(
"IL", "IL",
f"{subscriber.last_name} {subscriber.first_name}".strip(),
@@ -427,12 +489,24 @@ def _build_payer_block(payer) -> list[str]:
]
def _build_service_lines_block(service_lines) -> list[str]:
"""Per line: LX / SV1 / DTP*472 / REF*6R."""
def _build_service_lines_block(service_lines, *, has_diagnoses: bool = False) -> list[str]:
"""Per line: LX / SV1 / DTP*472 / REF*6R.
``has_diagnoses`` is True when the parent claim emits an HI segment;
in that case SV1-07 is required by X12 and we default each line's
pointer to ``"1"`` (the first HI diagnosis) unless the source
captured a different pointer on the line itself.
"""
out: list[str] = []
for idx, line in enumerate(service_lines or [], start=1):
out.append(_build_lx(idx))
out.append(_build_sv1(line))
# Prefer the line's captured pointer (parser pulled SV1-07
# when present). Fall back to "1" only when the claim has
# diagnoses and the source had no explicit pointer — the
# common single-diagnosis case.
line_pointer = getattr(line, "dx_pointer", None)
effective_pointer = line_pointer or ("1" if has_diagnoses else "")
out.append(_build_sv1(line, dx_pointer=effective_pointer))
dtp = _build_dtp_472(line.service_date)
if dtp:
out.append(dtp)
@@ -450,7 +524,10 @@ def serialize_837(
submitter_name: str | None = None,
submitter_contact_name: str | None = None,
submitter_contact_phone: str | None = None,
submitter_contact_email: str | None = None,
submitter_contact_email_qual: str = "EM",
receiver_name: str | None = None,
claim_filing_indicator_code: str | None = None,
interchange_control_number: str = "000000001",
group_control_number: str = "1",
) -> str:
@@ -462,6 +539,16 @@ def serialize_837(
(``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
the configured values.
The submitter block (Loop 1000A) always emits a PER segment per the
X12 spec the canonical clearhouse config provides the contact
name and email so callers should pass them through.
The claim filing indicator (SBR09) is read from the per-payer
config (``PayerConfig837.sbr09_default``); callers should pass it
in. If not passed, SBR09 is left empty (which causes the
:func:`cyclone.parsers.validator._r202_sbr09_allowed` rule to skip
its check degraded but not a hard error).
Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are
emitted from the canonical ``ClaimOutput`` fields, so post-parse
edits propagate to the output.
@@ -481,11 +568,13 @@ def serialize_837(
),
]
segments.extend(_build_submitter_block(
sender_id, submitter_name, submitter_contact_name, submitter_contact_phone,
sender_id, submitter_name,
submitter_contact_name, submitter_contact_phone, submitter_contact_email,
submitter_contact_email_qual,
))
segments.extend(_build_receiver_block(receiver_id, receiver_name))
segments.extend(_build_billing_provider_block(claim.billing_provider))
segments.extend(_build_subscriber_block(claim.subscriber, claim.payer.name))
segments.extend(_build_subscriber_block(claim.subscriber, claim_filing_indicator_code))
segments.extend(_build_payer_block(claim.payer))
# Claim-level editable segments.
@@ -497,7 +586,10 @@ def serialize_837(
segments.append(_build_hi(claim.diagnoses))
# Service lines (LX / SV1 / DTP*472 / REF*6R).
segments.extend(_build_service_lines_block(claim.service_lines))
segments.extend(_build_service_lines_block(
claim.service_lines,
has_diagnoses=bool(claim.diagnoses),
))
# SE segment count includes ST (line 3, 1-based) through SE itself
# — i.e. the entire ST..SE block inclusive.
@@ -514,15 +606,23 @@ def serialize_837_for_resubmit(
claim: ClaimOutput,
*,
interchange_index: int,
**kwargs,
) -> str:
"""Like :func:`serialize_837` but assigns deterministic-but-unique
interchange + group control numbers for a bundle position.
Interchange number = ``f"{interchange_index:09d}"``.
Group number = ``str(interchange_index)``.
All other keyword arguments (sender_id, receiver_id, submitter_*
and receiver_* contact info, claim_filing_indicator_code) are
forwarded to :func:`serialize_837` unchanged so callers like the
export and SFTP-submit endpoints can pass through clearhouse +
payer config without copying the signature.
"""
return serialize_837(
claim,
interchange_control_number=f"{interchange_index:09d}",
group_control_number=str(interchange_index),
**kwargs,
)
+1 -1
View File
@@ -2277,7 +2277,7 @@ class CycloneStore:
submitter_contact_email="tyler@dzinesco.com",
filename_block={
"tz": "America/Denver",
"outbound_template": "{tpid}-{tx}-{ts_mt}-1of1.{ext}",
"outbound_template": "tp{tpid}-{tx}-{ts_mt}-1of1.{ext}",
"inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
},
sftp_block={
+8
View File
@@ -101,6 +101,14 @@ def test_parse_837_endpoint_streams_ndjson(client: TestClient):
# Summary numbers match the JSON path.
assert parsed[3]["data"]["total_claims"] == 2
assert parsed[3]["data"]["passed"] == 2
# The streaming summary carries the server-side batch id so the
# frontend can call /api/batches/{id}/export-837 without a separate
# GET /api/batches round-trip (regression: it used to be missing
# on the stream path, only present on the JSON path, which made the
# Upload page's Export button say "no batch to export").
assert parsed[3]["type"] == "summary"
assert isinstance(parsed[3]["data"]["batch_id"], str)
assert len(parsed[3]["data"]["batch_id"]) == 32 # uuid4().hex
def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
+7
View File
@@ -82,6 +82,13 @@ def test_parse_835_endpoint_streams_ndjson(client: TestClient):
# Summary numbers match the JSON path.
assert parsed[7]["data"]["total_claims"] == 2
assert parsed[7]["data"]["passed"] == 2
# The streaming summary carries the server-side batch id so the
# frontend can call /api/batches/{id}/export-837 without a separate
# GET /api/batches round-trip (matches the parallel fix on
# /api/parse-837).
assert parsed[7]["type"] == "summary"
assert isinstance(parsed[7]["data"]["batch_id"], str)
assert len(parsed[7]["data"]["batch_id"]) == 32 # uuid4().hex
def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
+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():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", now_mt=now)
assert name == "11525703-837P-20260620132243505-1of1.x12"
# HCPF outbound format: tp prefix on the tpid
assert name == "tp11525703-837P-20260620132243505-1of1.x12"
def test_build_outbound_default_extension():
@@ -39,15 +40,17 @@ def test_build_outbound_default_extension():
def test_build_outbound_custom_extension():
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
name = build_outbound_filename("11525703", "837P", ext="txt", now_mt=now)
assert name == "11525703-837P-20260620132243505-1of1.txt"
assert name == "tp11525703-837P-20260620132243505-1of1.txt"
def test_build_outbound_uses_mt_when_no_arg():
# Snapshot test — the timestamp will be very recent; check format only
name = build_outbound_filename("11525703", "837P")
assert OUTBOUND_RE.match(name), name
# tp11525703-837P-YYYYMMDDhhmmssSSS-1of1.x12 — 4 dash-separated parts
parts = name.split("-")
assert len(parts) == 4
assert parts[0] == "tp11525703"
assert len(parts[2]) == 17 # yyyymmddhhmmssSSS
@@ -128,8 +131,9 @@ def test_parse_inbound_rejects_non_x12_ext():
def test_roundtrip_outbound_to_inbound():
# Outbound tpid is bare (no TP); inbound tpid is bare inside TP{...}
# The two regexes use different shapes — round-trip via tpid only.
# Outbound uses tp{...}, inbound uses TP{...} (case differs but both
# prefixes are required). The two regexes use different shapes —
# round-trip via tpid only.
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
out = build_outbound_filename("11525703", "837P", now_mt=now)
assert OUTBOUND_RE.match(out)
@@ -142,13 +146,19 @@ def test_roundtrip_outbound_to_inbound():
def test_is_outbound_filename():
assert is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
# HCPF outbound always has the lowercase "tp" prefix
assert is_outbound_filename("tp11525703-837P-20260620132243505-1of1.x12")
# Bare tpid (no tp prefix) is no longer a valid outbound filename
assert not is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
# Uppercase TP prefix is the inbound shape, not outbound
assert not is_outbound_filename("TP11525703-837P-20260620132243505-1of1.x12")
assert not is_outbound_filename("not-a-filename")
def test_is_inbound_filename():
assert is_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.x12")
# Lowercase tp prefix is the outbound shape, not inbound
assert not is_inbound_filename("tp11525703-837P_M019048402-20260520231513488-1of1_999.x12")
assert not is_inbound_filename("11525703-837P-20260620132243505-1of1.x12")
+215 -8
View File
@@ -65,6 +65,21 @@ def test_build_gs_emits_gs_segment_with_hc_functional_id():
assert parts[6] == "1"
def test_build_gs_uses_gs04_yyyymmdd_8_digits():
"""GS-04 must be CCYYMMDD (8 digits) per X12; ISA uses YYMMDD (6).
A 6-digit value like '260622' is rejected by EDI validators with
'Element GS-04 must use CCYYMMDD date format'.
"""
gs = _build_gs("SENDER", "RECEIVER", "1")
parts = gs.rstrip("~").split("*")
# parts[4] = GS-04 date
assert len(parts[4]) == 8, f"expected 8-digit CCYYMMDD, got {parts[4]!r}"
# Must parse as a CCYYMMDD date
from datetime import datetime
datetime.strptime(parts[4], "%Y%m%d")
def test_build_st_emits_837_segment():
st = _build_st("0001")
assert st.startswith("ST*837*0001*005010X222A1~")
@@ -100,12 +115,15 @@ def test_build_nm1_person_entity_splits_first_last():
assert parts[4] == "Jane"
def test_build_per_returns_empty_when_no_contact():
assert _build_per(None, None) == ""
assert _build_per("", "") == ""
def test_build_per_emits_per01_even_with_no_contact():
"""PER is required by X12 Loop 1000A — at least PER01 must be present."""
per = _build_per(None, None)
assert per == "PER*IC~"
per = _build_per("", "")
assert per == "PER*IC~"
def test_build_per_emits_segment_with_contact():
def test_build_per_emits_segment_with_phone_contact():
per = _build_per("Jane Doe", "5551234567")
parts = per.rstrip("~").split("*")
assert parts[0] == "PER"
@@ -115,6 +133,25 @@ def test_build_per_emits_segment_with_contact():
assert parts[4] == "5551234567"
def test_build_per_emits_segment_with_email_contact():
"""When email is given, it wins over phone (HCPF expects email)."""
per = _build_per("Tyler Martinez", None, contact_email="tyler@dzinesco.com")
parts = per.rstrip("~").split("*")
assert parts[0] == "PER"
assert parts[1] == "IC"
assert parts[2] == "Tyler Martinez"
assert parts[3] == "EM"
assert parts[4] == "tyler@dzinesco.com"
def test_build_per_email_takes_precedence_over_phone():
"""If both phone and email are given, email is emitted (PER04)."""
per = _build_per("Tyler", "555-1234", contact_email="t@example.com")
parts = per.rstrip("~").split("*")
assert parts[3] == "EM"
assert parts[4] == "t@example.com"
def test_build_n3_returns_empty_when_no_address():
assert _build_n3(None, None) == ""
assert _build_n3("", "") == ""
@@ -133,12 +170,33 @@ def test_build_hl_emits_segment():
assert hl == "HL*1**20*1~"
def test_build_sbr_emits_segment():
sbr = _build_sbr("18", "M123", "PAYER")
def test_build_sbr_emits_segment_with_correct_slots():
"""SBR01=Payer Responsibility Seq Code (default 'P'),
SBR02=Individual Relationship Code (e.g. '18' for self),
SBR09=Claim Filing Indicator Code (e.g. 'MC' for Medicaid)."""
sbr = _build_sbr("18", "MC")
parts = sbr.rstrip("~").split("*")
assert parts[0] == "SBR"
assert parts[1] == "18"
assert parts[9] == "M123"
# SBR01 — primary
assert parts[1] == "P"
# SBR02 — individual relationship (self = 18)
assert parts[2] == "18"
# SBR09 — claim filing indicator
assert parts[9] == "MC"
# Member ID and payer name do NOT belong in SBR — they live in
# NM109 and NM1*PR.NM103 respectively.
assert "M123" not in sbr
assert "PAYER" not in sbr
def test_build_sbr_defaults_relationship_to_self_and_filing_to_empty():
"""When called with all-None, SBR01/02 fall back to safe defaults
and SBR09 is left empty (the validator's R202 rule will then skip)."""
sbr = _build_sbr(None, None)
parts = sbr.rstrip("~").split("*")
assert parts[1] == "P"
assert parts[2] == "18"
assert parts[9] == ""
def test_build_ref_returns_empty_when_no_value():
@@ -200,6 +258,34 @@ def test_build_clm_emits_clm01_to_clm05():
assert parts[5] == "11:1"
def test_build_clm_emits_clm08_defaulting_to_y():
"""CLM-08 (Benefits Assignment Certification) is required by X12
837P when CLM-07 = 'Y'. Default to 'Y' when the source didn't
capture one (matches what 99% of HCPF files look like).
"""
claim = _stub_claim_header()
clm = _build_clm(claim)
parts = clm.rstrip("~").split("*")
# parts[8] = CLM-08
assert parts[8] == "Y", f"CLM-08 should default to 'Y', got {parts[8]!r}"
def test_build_clm_propagates_captured_clm08():
from cyclone.parsers.models import ClaimHeader
claim = ClaimHeader(
claim_id="CLM-1",
total_charge=Decimal("100.00"),
place_of_service="11",
frequency_code="1",
assignment="Y",
benefits_assignment_certification="N",
release_of_info="Y",
)
clm = _build_clm(claim)
parts = clm.rstrip("~").split("*")
assert parts[8] == "N"
def test_build_ref_g1_returns_empty_when_no_prior_auth():
assert _build_ref_g1(None) == ""
assert _build_ref_g1("") == ""
@@ -242,6 +328,57 @@ def test_build_sv1_emits_procedure_modifiers_charge_units():
assert parts[4] == "1"
def test_build_sv1_emits_sv1_06_and_sv1_07_when_dx_pointer_given():
"""SV1-07 (Diagnosis Code Pointer) is required by X12/HCPF when the
parent claim has an HI segment (i.e. has at least one diagnosis).
Per 005010X222A1, SV1-06 is "Not Used" by the guide and MUST be
empty. Unit basis (UN/MJ/...) goes only in SV1-03.
"""
line = _stub_service_line()
sv1 = _build_sv1(line, dx_pointer="1")
parts = sv1.rstrip("~").split("*")
# parts layout: SV1, comp(SV1-01), charge(02), unit_basis(03),
# units(04), pos(05), ""(06 NOT USED), sv1_07(07)
assert len(parts) == 8, f"expected 8 elements, got {parts}"
# SV1-03 = unit basis
assert parts[3] == "UN"
# SV1-06 = "" (Not Used by 837P guide)
assert parts[6] == "", f"SV1-06 must be empty (Not Used by 837P), got {parts[6]!r}"
# SV1-07 = pointer
assert parts[7] == "1"
def test_build_sv1_omits_sv1_07_when_no_dx_pointer():
"""When the claim has no HI segment, SV1-07 should be empty."""
line = _stub_service_line()
sv1 = _build_sv1(line) # no dx_pointer kwarg
parts = sv1.rstrip("~").split("*")
assert len(parts) == 8
assert parts[6] == "" # SV1-06 still empty
assert parts[7] == "" # SV1-07 empty
def test_build_sv1_matches_goodclaim_layout():
"""Layout must match the known-good reference at docs/goodclaim.x12:
SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~
i.e. 8 fields total: comp, charge, UN, units, '', '', '1'.
"""
from cyclone.parsers.models import Procedure, ServiceLine
line = ServiceLine(
line_number=1,
procedure=Procedure(qualifier="HC", code="T1019", modifiers=["U1", "KX"]),
charge=Decimal("125.40"),
units=Decimal("19.00"),
unit_type="UN",
place_of_service=None,
)
sv1 = _build_sv1(line, dx_pointer="1")
assert sv1 == "SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~"
def test_build_dtp_472_emits_service_date():
assert _build_dtp_472(date(2026, 6, 15)) == "DTP*472*D8*20260615~"
@@ -369,6 +506,76 @@ def test_serialize_837_uses_custom_sender_receiver_ids():
parse(text, _CFG)
def test_serialize_837_emits_per_segment_in_submitter_block():
"""X12 Loop 1000A (Submitter Name) requires a PER segment after
NM1*41. The serializer must emit one even with no contact info
(PER01='IC' is the only required element)."""
claim = _load_claim()
text = serialize_837(claim)
# The first NM1*41 should be followed immediately by a PER segment.
seg_ids = [seg.split("*")[0] for seg in text.split("~") if seg]
nm1_41_idx = seg_ids.index("NM1") # first NM1 is the submitter
assert nm1_41_idx >= 0
# The very next segment must be PER (PER01='IC' is required by spec).
assert seg_ids[nm1_41_idx + 1] == "PER"
per_line = next(seg for seg in text.split("~") if seg.startswith("PER*IC"))
assert per_line.startswith("PER*IC")
def test_serialize_837_per_segment_includes_email_from_kwargs():
"""Passing submitter_contact_email should emit PER*IC*<name>*EM*<email>."""
claim = _load_claim()
text = serialize_837(
claim,
sender_id="DZINESCO",
submitter_name="Dzinesco",
submitter_contact_name="Tyler Martinez",
submitter_contact_email="tyler@dzinesco.com",
)
assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text
# And the ISA sender id should be the clearhouse TPID, not "CYCLONE".
assert "ZZ*DZINESCO" in text
assert "ZZ*CYCLONE" not in text
def test_serialize_837_sbr09_uses_claim_filing_indicator_code_kwarg():
"""SBR09 must be the claim filing indicator (e.g. 'MC' for Medicaid),
not the member id. The serializer takes it from the kwarg."""
claim = _load_claim()
text = serialize_837(claim, claim_filing_indicator_code="MC")
sbr_line = next(seg for seg in text.split("~") if seg.startswith("SBR*"))
parts = sbr_line.rstrip("~").split("*")
# SBR01 = P (primary), SBR02 = 18 (self), SBR09 = MC
assert parts[1] == "P"
assert parts[2] == "18"
assert parts[9] == "MC"
# And the member id should NOT be in SBR.
assert claim.subscriber.member_id not in sbr_line
def test_serialize_837_for_resubmit_forwards_kwargs_to_serialize_837():
"""serialize_837_for_resubmit is a thin wrapper — it must forward
clearhouse + payer kwargs so the export endpoint can use it."""
claim = _load_claim()
text = serialize_837_for_resubmit(
claim,
interchange_index=7,
sender_id="DZINESCO",
submitter_name="Dzinesco",
submitter_contact_name="Tyler Martinez",
submitter_contact_email="tyler@dzinesco.com",
receiver_id="COMEDASSISTPROG",
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
claim_filing_indicator_code="MC",
)
assert "ZZ*DZINESCO" in text
assert "ZZ*COMEDASSISTPROG" in text
assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text
# Control numbers reflect the resubmit index.
isa = next(seg for seg in text.split("~") if seg.startswith("ISA*"))
assert "000000007" in isa
def test_serialize_error_is_an_exception():
assert issubclass(SerializeError, Exception)
+2 -2
View File
@@ -31,13 +31,13 @@ def sftp_block(tmp_path):
def test_stub_writes_preserving_remote_path(sftp_block, tmp_path):
client = SftpClient(sftp_block)
remote = "/CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
remote = "/CO XIX/PROD/coxix_prod_11525703/FromHPE/tp11525703-837P-20260620132243505-1of1.x12"
target = client.write_file(remote, b"ISA*00*...~IEA*1*1~")
assert target.exists()
assert target.read_bytes() == b"ISA*00*...~IEA*1*1~"
# Confirm the full nested MFT path is preserved under staging
rel = target.relative_to(sftp_block.staging_dir)
assert str(rel) == "CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
assert str(rel) == "CO XIX/PROD/coxix_prod_11525703/FromHPE/tp11525703-837P-20260620132243505-1of1.x12"
def test_stub_creates_parent_dirs(sftp_block):
+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~
@@ -0,0 +1,926 @@
# Drop `UNIQUE(batch_id, patient_control_number)` + 409 UX Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow multi-claim 837P files (where many `CLM*` segments share a `member_id`) to ingest without 409, and surface a structured error panel on the upload page when a true duplicate claim still trips a 409.
**Architecture:** Backend migration drops the inline `UNIQUE(batch_id, patient_control_number)` on `claims` via SQLite table recreation. Two new store helpers (`find_existing_batch_for_claim`, `find_existing_batch_for_remit`) enable both 837 and 835 409 handlers to surface the id of the prior batch. Frontend `ApiError` carries `existingBatchId`; `Upload.tsx` renders an inline error panel above the streaming results with a link to the existing batch and a "Pick a different file" escape hatch.
**Tech Stack:** Python 3.11+, SQLAlchemy 2.x, FastAPI, SQLite (sqlcipher3 optional), React 18 + TanStack Query + Radix UI + sonner, Vitest + React Testing Library, pytest.
**Spec:** `docs/superpowers/specs/2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md` — read fully before starting.
**Worktree setup (one-time):**
```bash
cd /Users/openclaw/dev/cyclone
git worktree add .worktrees/claims-unique-fix -b claims-unique-fix main
cd .worktrees/claims-unique-fix
# Install backend deps if needed (venv assumed active)
pip install -e backend
# Install frontend deps if needed
npm install
```
All commits happen in this worktree. Merge to main via fast-forward when each phase ends.
---
## Phase 1 — Backend migration + helpers + API
### Task 1.1: Migration 0013 drops inline UNIQUE via table recreation
**Files:**
- Create: `backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql`
- Modify: `backend/tests/test_db_migrate.py` (append test)
The runner (`backend/src/cyclone/db_migrate.py`) wraps each `.sql` in an implicit transaction via `engine.begin()`, so the migration MUST NOT use `BEGIN`/`COMMIT` or `PRAGMA foreign_keys=OFF` (no-op inside a transaction). Use `PRAGMA defer_foreign_keys = ON` instead — checks fire at commit against the renamed table.
- [ ] **Step 1: Write the failing test**
Add to `backend/tests/test_db_migrate.py`:
```python
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Migration 0013 must drop the inline UNIQUE(batch_id, patient_control_number)
on claims by recreating the table. Idempotent and preserves data."""
monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
# Copy the real migrations so the test starts from v1.
real_dir = Path(db_migrate.__file__).parent / "migrations"
for src in sorted(real_dir.glob("00*.sql")):
(tmp_path / src.name).write_text(src.read_text())
engine = _fresh_engine(tmp_path)
db_migrate.run(engine)
# Before 0013: insert two claims with same (batch_id, patient_control_number) raises.
with engine.begin() as c:
c.exec_driver_sql("INSERT INTO batches(id, kind, input_filename, parsed_at) VALUES ('b1', '837p', 'x.txt', '2026-01-01')")
c.exec_driver_sql("INSERT INTO claims(id, batch_id, patient_control_number) VALUES ('c1', 'b1', 'M')")
with pytest.raises(sqlalchemy.exc.IntegrityError):
c.exec_driver_sql("INSERT INTO claims(id, batch_id, patient_control_number) VALUES ('c2', 'b1', 'M')")
# Now drop the migration in by name only:
(tmp_path / "0013_drop_claims_unique_constraint.sql").write_text(
"-- version: 13\n"
"PRAGMA defer_foreign_keys = ON;\n"
"CREATE TABLE claims_new ("
"id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,"
"patient_control_number TEXT NOT NULL, service_date_from DATE, service_date_to DATE,"
"charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0, provider_npi TEXT, payer_id TEXT,"
"state TEXT NOT NULL DEFAULT 'submitted', state_before_reversal TEXT,"
"matched_remittance_id TEXT REFERENCES remittances(id), raw_json TEXT,"
"rejection_reason TEXT, rejected_at TIMESTAMP, resubmit_count INTEGER NOT NULL DEFAULT 0,"
"state_changed_at TIMESTAMP, payer_rejected_at TEXT, payer_rejected_reason TEXT,"
"payer_rejected_status_code TEXT, payer_rejected_by_277ca_id TEXT,"
"payer_rejected_acknowledged_at TEXT, payer_rejected_acknowledged_actor TEXT);\n"
"INSERT INTO claims_new SELECT * FROM claims;"
"DROP TABLE claims;"
"ALTER TABLE claims_new RENAME TO claims;"
)
db_migrate.run(engine)
# After 0013: same insert succeeds.
with engine.begin() as c:
c.exec_driver_sql("INSERT INTO claims(id, batch_id, patient_control_number) VALUES ('c2', 'b1', 'M')")
rows = c.exec_driver_sql("SELECT COUNT(*) FROM claims").scalar()
assert rows == 2
assert _user_version(engine) == 13
def test_migration_0013_is_idempotent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Re-running db_migrate.run on a v13 DB is a no-op."""
monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
real_dir = Path(db_migrate.__file__).parent / "migrations"
for src in sorted(real_dir.glob("00*.sql")):
(tmp_path / src.name).write_text(src.read_text())
engine = _fresh_engine(tmp_path)
db_migrate.run(engine) # applies all
version_after_first = _user_version(engine)
db_migrate.run(engine) # second call: no-op
assert _user_version(engine) == version_after_first
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && python -m pytest tests/test_db_migrate.py::test_drop_claims_unique_constraint_migration tests/test_db_migrate.py::test_migration_0013_is_idempotent -v`
Expected: `test_drop_claims_unique_constraint_migration` FAILS because 0013 doesn't exist yet; `test_migration_0013_is_idempotent` PASSES (idempotency already works for any v).
- [ ] **Step 3: Write migration 0013**
Create `backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql`:
```sql
-- version: 13
-- Drop the inline UNIQUE(batch_id, patient_control_number) on claims.
-- Migration 0003 attempted DROP INDEX IF EXISTS uq_claims_batch_pcn but
-- the constraint is inline in CREATE TABLE, so the drop was a no-op.
-- The only way to remove an inline UNIQUE in SQLite is table recreation.
--
-- X12 837P allows any number of CLM segments per 2000B subscriber loop;
-- claim identity is provided by the primary key (claims.id = CLM01).
-- The remittances table had a parallel constraint already removed in 0003
-- (because that one WAS a named index), so this migration only touches
-- claims.
--
-- The migration runner (db_migrate.py) wraps each .sql in an implicit
-- transaction via engine.begin(), so we MUST NOT use BEGIN/COMMIT.
-- PRAGMA defer_foreign_keys defers FK checks to commit, which is the
-- only way to drop a referenced table inside a transaction in SQLite.
PRAGMA defer_foreign_keys = ON;
CREATE TABLE claims_new (
id TEXT PRIMARY KEY,
batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
patient_control_number TEXT NOT NULL,
service_date_from DATE,
service_date_to DATE,
charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
provider_npi TEXT,
payer_id TEXT,
state TEXT NOT NULL DEFAULT 'submitted',
state_before_reversal TEXT,
matched_remittance_id TEXT REFERENCES remittances(id),
raw_json TEXT,
rejection_reason TEXT,
rejected_at TIMESTAMP,
resubmit_count INTEGER NOT NULL DEFAULT 0,
state_changed_at TIMESTAMP,
payer_rejected_at TEXT,
payer_rejected_reason TEXT,
payer_rejected_status_code TEXT,
payer_rejected_by_277ca_id TEXT,
payer_rejected_acknowledged_at TEXT,
payer_rejected_acknowledged_actor TEXT
-- NO UNIQUE (batch_id, patient_control_number) — removed.
);
INSERT INTO claims_new SELECT * FROM claims;
DROP TABLE claims;
ALTER TABLE claims_new RENAME TO claims;
-- Recreate secondary indexes (same names, same columns as initial schema
-- plus later migrations).
CREATE INDEX ix_claims_state ON claims(state);
CREATE INDEX ix_claims_patient_control_number ON claims(patient_control_number);
CREATE INDEX ix_claims_service_date_from ON claims(service_date_from);
CREATE INDEX ix_claims_state_changed_at ON claims(state, state_changed_at);
CREATE INDEX idx_claims_payer_rejected_at ON claims(payer_rejected_at);
CREATE INDEX idx_claims_payer_rejected_unack
ON claims(payer_rejected_at)
WHERE payer_rejected_acknowledged_at IS NULL;
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd backend && python -m pytest tests/test_db_migrate.py::test_drop_claims_unique_constraint_migration tests/test_db_migrate.py::test_migration_0013_is_idempotent -v`
Expected: PASS for both. The first test asserts the inline UNIQUE is gone (insert succeeds) and `user_version==13`. The second asserts idempotency.
- [ ] **Step 5: Commit**
```bash
git add backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql backend/tests/test_db_migrate.py
git commit -m "feat(db): drop inline UNIQUE(batch_id, patient_control_number) via migration 0013"
```
---
### Task 1.2: Store helpers `find_existing_batch_for_claim` and `find_existing_batch_for_remit`
**Files:**
- Modify: `backend/src/cyclone/store.py:1-50` (imports) and add new functions near the top
- Modify: `backend/tests/test_store.py` (append tests)
The helpers are pure reads. They return the batch_id of the first batch containing the given claim_id / remit_id, or None.
- [ ] **Step 1: Write the failing test**
Add to `backend/tests/test_store.py`:
```python
def test_find_existing_batch_for_claim_returns_none_for_unknown(global_store):
"""Unknown claim_id -> None."""
assert global_store.find_existing_batch_for_claim("nope") is None # type: ignore[attr-defined]
def test_find_existing_batch_for_claim_returns_batch_id(global_store):
"""Known claim_id -> batch_id of the holding batch."""
from cyclone.store import BatchRecord, _claim_837_row # noqa: F401
from cyclone.db import Batch, Claim, db as _db_mod
from datetime import datetime, timezone
from cyclone.parser.parsers_837p import ClaimOutput # noqa: F401
# Simpler: insert a batch + claim directly via the DB.
with _db_mod.SessionLocal()() as s:
s.add(Batch(id="B1", kind="837p", input_filename="x.txt",
parsed_at=datetime(2026,1,1,tzinfo=timezone.utc)))
s.add(Claim(id="CLM-A", batch_id="B1", patient_control_number="M1"))
s.commit()
assert global_store.find_existing_batch_for_claim("CLM-A") == "B1" # type: ignore[attr-defined]
def test_find_existing_batch_for_remit_returns_none_for_unknown(global_store):
"""Unknown remit id -> None."""
assert global_store.find_existing_batch_for_remit("nope") is None # type: ignore[attr-defined]
def test_find_existing_batch_for_remit_returns_batch_id(global_store):
"""Known remit id -> batch_id of the holding batch."""
from cyclone.db import Batch, Remittance, db as _db_mod
from datetime import datetime, timezone
with _db_mod.SessionLocal()() as s:
s.add(Batch(id="B2", kind="835", input_filename="y.txt",
parsed_at=datetime(2026,1,1,tzinfo=timezone.utc)))
s.add(Remittance(id="CLP-A", batch_id="B2",
payer_claim_control_number="CLP-A",
status_code="1", received_at=datetime(2026,1,1,tzinfo=timezone.utc)))
s.commit()
assert global_store.find_existing_batch_for_remit("CLP-A") == "B2" # type: ignore[attr-defined]
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && python -m pytest tests/test_store.py::test_find_existing_batch_for_claim_returns_none_for_unknown -v`
Expected: FAIL with `AttributeError: 'CycloneStore' object has no attribute 'find_existing_batch_for_claim'`.
- [ ] **Step 3: Implement helpers**
Add to `backend/src/cyclone/store.py` near the top, after imports:
```python
def find_existing_batch_for_claim(claim_id: str) -> str | None:
"""Return the batch_id of the first batch containing this claim id, or None.
Pure read; opens a short-lived session. Used by the 837 409 handler to
surface which prior batch already holds the same CLM01.
"""
from sqlalchemy import select
from cyclone import db
from cyclone.db import Claim
with db.SessionLocal()() as s:
row = s.execute(
select(Claim.batch_id).where(Claim.id == claim_id).limit(1)
).first()
return row[0] if row else None
def find_existing_batch_for_remit(remit_id: str) -> str | None:
"""Return the batch_id of the first batch containing this remit id, or None.
Pure read; opens a short-lived session. Used by the 835 409 handler.
`remit_id` is the PK on `remittances.id` (= payer_claim_control_number = CLP01).
"""
from sqlalchemy import select
from cyclone import db
from cyclone.db import Remittance
with db.SessionLocal()() as s:
row = s.execute(
select(Remittance.batch_id).where(Remittance.id == remit_id).limit(1)
).first()
return row[0] if row else None
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd backend && python -m pytest tests/test_store.py -k "find_existing_batch" -v`
Expected: PASS for all 4 tests.
- [ ] **Step 5: Commit**
```bash
git add backend/src/cyclone/store.py backend/tests/test_store.py
git commit -m "feat(store): add find_existing_batch_for_claim and find_existing_batch_for_remit"
```
---
### Task 1.3: 409 handlers in api.py surface `existing_batch_id`
**Files:**
- Modify: `backend/src/cyclone/api.py:394-415` (837 409 handler)
- Modify: `backend/src/cyclone/api.py:588-602` (835 409 handler)
- Modify: `backend/tests/test_api_parse_persists.py` (append tests)
After migration 0013, IntegrityError on the 837 path can only fire when two claims in the same file share the same CLM01 (rare). The helper may still return a batch_id if the colliding CLM01 was previously ingested and not deleted (the dedup `s.get(Claim, claim_id)` only queries DB state, not pending session state, so cross-batch duplicates still get inserted and trip the PK).
- [ ] **Step 1: Write the failing tests**
Add to `backend/tests/test_api_parse_persists.py`:
```python
def test_409_response_includes_existing_batch_id_for_837(client: TestClient):
"""When a CLM01 already exists in a prior batch, the 409 body has existing_batch_id."""
from cyclone.db import Batch, Claim
from datetime import datetime, timezone
# Seed a prior batch with claim CLM-X.
with global_store._lock:
pass
from cyclone import db as _db
with _db.SessionLocal()() as s:
s.add(Batch(id="PRIOR", kind="837p", input_filename="prior.txt",
parsed_at=datetime(2026,1,1,tzinfo=timezone.utc),
raw_result_json={}))
s.add(Claim(id="CLM-X", batch_id="PRIOR", patient_control_number="M"))
s.commit()
# Build a file with two CLM* segments both using CLM-X (forces PK collision).
text = (
"ISA*00* *00* *ZZ*SUBMITTERID *ZZ*RECEIVERID "
"*240101*1200*^*00501*000000001*0*P*:~\n"
"GS*HC*SUBMITTERID*RECEIVERID*20240101*1200*1*X*005010X222A1~\n"
"ST*837*0001*005010X222A1~\n"
"BHT*0019*00*1*20240101*1200*CH~\n"
"NM1*41*2*SUBMITTER*****46*SUBMITTERID~\n"
"PER*IC*CONTACT*TE*5555555555~\n"
"NM1*40*2*RECEIVER*****46*RECEIVERID~\n"
"HL*1**20*1~\n"
"NM1*85*2*BILLING*****XX*1881068062~\n"
"N3*123 MAIN*\nN4*DENVER*CO*80202~\n"
"REF*EI*123456789~\n"
"HL*2*1*22*0~\n"
"SBR*P*18*******CI~\n"
"NM1*IL*1*DOE*JOHN****MI*M~\n"
"N3*456 ELM*\nN4*DENVER*CO*80202~\n"
"DMG*D8*19700101*M~\n"
"NM1*PR*2*MEDICAID*****PI*MCD~\n"
"CLM*CLM-X*100***11:B:1*Y*A*Y*Y~\n"
"LX*1~\nSV1*HC:99213*100*UN*1***1~\n"
"DTP*472*D8*20240101~\n"
"CLM*CLM-X*100***11:B:1*Y*A*Y*Y~\n"
"LX*2~\nSV1*HC:99213*100*UN*1***1~\n"
"DTP*472*D8*20240101~\n"
"SE*30*0001~\n"
"GE*1*1~\n"
"IEA*1*000000001~\n"
)
resp = client.post(
"/api/parse-837",
files={"file": ("dup.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 409, resp.text
body = resp.json()
assert body.get("existing_batch_id") == "PRIOR"
def test_409_response_includes_existing_batch_id_for_835(client: TestClient):
"""When a CLP01 already exists in a prior batch, the 835 409 body has existing_batch_id."""
from cyclone.db import Batch, Remittance
from datetime import datetime, timezone
from cyclone import db as _db
with _db.SessionLocal()() as s:
s.add(Batch(id="PRIOR835", kind="835", input_filename="prior.txt",
parsed_at=datetime(2026,1,1,tzinfo=timezone.utc),
raw_result_json={}))
s.add(Remittance(id="CLP-X", batch_id="PRIOR835",
payer_claim_control_number="CLP-X",
status_code="1",
received_at=datetime(2026,1,1,tzinfo=timezone.utc)))
s.commit()
# Build a minimal 835 with two CLP segments using CLP-X. This is contrived;
# we just need to trigger IntegrityError. The exact parser robustness to this
# fixture is not the focus — the test asserts the 409 body shape.
# ... or instead: directly invoke the handler via a unit-style test that
# pre-seeds and then makes a minimal 835 that includes CLP-X twice.
# For brevity, drop this test if constructing a fixture is too brittle;
# the 837 test above already exercises the same handler pattern.
pytest.skip("835 fixture with duplicate CLP01 is fragile; 837 case covers the pattern")
```
Note: the 835 test is intentionally skipped — constructing a minimal valid 835 with duplicate CLP01 is brittle. The 837 case exercises the handler pattern (call `find_existing_batch_for_remit` from a similarly-shaped except block in the 835 handler). If you need 835 coverage, manually inspect by running the API on a real fixture after the implementation lands.
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && python -m pytest tests/test_api_parse_persists.py::test_409_response_includes_existing_batch_id_for_837 -v`
Expected: FAIL because the 409 handler does not yet add `existing_batch_id`.
- [ ] **Step 3: Modify 837 409 handler**
Edit `backend/src/cyclone/api.py` lines 394-415:
Replace the `except IntegrityError` block in `parse_837_endpoint` with:
```python
try:
store.add(rec, event_bus=request.app.state.event_bus)
except IntegrityError as exc:
# After migration 0013, the only way an IntegrityError fires here
# is a PK collision on claims.id (CLM01) — either within-file or
# against a prior batch. Look up the first claim's id and ask the
# helper which batch already holds it.
first_claim_id = (
result.claims[0].claim_id if result.claims else None
)
existing_batch_id = (
store.find_existing_batch_for_claim(first_claim_id)
if first_claim_id else None
)
body = {
"error": "Duplicate claim",
"detail": (
"This file (or one previously ingested with the same "
"claim control number) collides with an existing record. "
"Inspect the file for duplicate CLM01 control numbers, or "
"remove the existing batch before retrying."
),
"batch_id": rec.id,
}
if existing_batch_id and existing_batch_id != rec.id:
body["existing_batch_id"] = existing_batch_id
log.warning("Duplicate claim while persisting batch %s: %s", rec.id, exc)
return JSONResponse(status_code=409, content=body)
```
- [ ] **Step 4: Modify 835 409 handler**
Edit `backend/src/cyclone/api.py` lines 588-602:
Replace the `except IntegrityError` block in `parse_835_endpoint` with:
```python
try:
store.add(rec, event_bus=request.app.state.event_bus)
except IntegrityError as exc:
first_pcn = (
result.claims[0].payer_claim_control_number
if result.claims else None
)
existing_batch_id = (
store.find_existing_batch_for_remit(first_pcn)
if first_pcn else None
)
body = {
"error": "Duplicate remittance",
"detail": (
"This 835 file (or one previously ingested with the same "
"payer claim control number) collides with an existing record. "
"Remove the existing remittance before retrying."
),
"batch_id": rec.id,
}
if existing_batch_id and existing_batch_id != rec.id:
body["existing_batch_id"] = existing_batch_id
log.warning("Duplicate remittance while persisting batch %s: %s", rec.id, exc)
return JSONResponse(status_code=409, content=body)
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `cd backend && python -m pytest tests/test_api_parse_persists.py -v`
Expected: All PASS, including the new 409 test.
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/api.py backend/tests/test_api_parse_persists.py
git commit -m "feat(api): 409 responses for 837/435 include existing_batch_id"
```
---
## Phase 2 — Frontend
### Task 2.1: `ApiError.existingBatchId` + parse837/835 surface it
**Files:**
- Modify: `src/lib/api.ts:164-167` (ApiError class)
- Modify: `src/lib/api.ts:174-191` (readErrorBody)
- Modify: `src/lib/api.ts:280-339` (parse837, parse835)
- Create: `src/lib/api.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/lib/api.test.ts`:
```typescript
import { describe, it, expect, vi, afterEach } from "vitest";
import { ApiError, parse837 } from "@/lib/api";
afterEach(() => vi.restoreAllMocks());
describe("ApiError", () => {
it("carries existingBatchId when constructed", () => {
const e = new ApiError(409, "dup", "BATCH-1");
expect(e.status).toBe(409);
expect(e.existingBatchId).toBe("BATCH-1");
});
it("defaults existingBatchId to null", () => {
const e = new ApiError(500, "boom");
expect(e.existingBatchId).toBeNull();
});
});
describe("parse837 throws ApiError with existingBatchId", () => {
it("extracts existing_batch_id from 409 body", async () => {
vi.stubGlobal("import.meta.env", { VITE_API_BASE_URL: "http://x" });
const res = new Response(
JSON.stringify({
error: "Duplicate claim",
detail: "collision",
batch_id: "NEW",
existing_batch_id: "PRIOR",
}),
{ status: 409, headers: { "Content-Type": "application/json" } },
);
vi.stubGlobal(
"fetch",
vi.fn(async () => res),
);
const file = new File(["x"], "f.txt", { type: "text/plain" });
await expect(parse837(file, { onProgress: () => {} })).rejects.toMatchObject({
status: 409,
existingBatchId: "PRIOR",
});
});
it("sets existingBatchId null when body omits it", async () => {
vi.stubGlobal("import.meta.env", { VITE_API_BASE_URL: "http://x" });
const res = new Response(
JSON.stringify({ error: "X", detail: "y", batch_id: "N" }),
{ status: 409, headers: { "Content-Type": "application/json" } },
);
vi.stubGlobal(
"fetch",
vi.fn(async () => res),
);
const file = new File(["x"], "f.txt", { type: "text/plain" });
await expect(parse837(file)).rejects.toMatchObject({
status: 409,
existingBatchId: null,
});
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix && npm test -- src/lib/api.test.ts`
Expected: FAIL — `existingBatchId` not a constructor argument yet; `parse837` throws `Error` not `ApiError`.
- [ ] **Step 3: Update `ApiError`**
Edit `src/lib/api.ts` line 164-167:
```typescript
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public existingBatchId: string | null = null,
) {
super(message);
}
}
```
- [ ] **Step 4: Update `readErrorBody` to return parsed body**
Edit `src/lib/api.ts` line 174-191. Change return type and add JSON parse branch:
```typescript
type ErrorBody = {
detail?: unknown;
error?: unknown;
existing_batch_id?: unknown;
};
async function readErrorBody(
res: Response,
): Promise<{ message: string; existingBatchId: string | null }> {
try {
const t = await res.text();
if (!t) return { message: "", existingBatchId: null };
try {
const obj = JSON.parse(t) as ErrorBody;
let message = "";
if (typeof obj.detail === "string") message = obj.detail;
else if (typeof obj.error === "string") message = obj.error;
else message = t;
const existing =
typeof obj.existing_batch_id === "string"
? obj.existing_batch_id
: null;
return { message, existingBatchId: existing };
} catch {
return { message: t, existingBatchId: null };
}
} catch {
return { message: "", existingBatchId: null };
}
}
```
- [ ] **Step 5: Update parse837 to throw ApiError**
Edit `src/lib/api.ts` lines 293-298:
```typescript
if (!res.ok) {
const { message, existingBatchId } = await readErrorBody(res);
throw new ApiError(
res.status,
`${res.status} ${res.statusText}${message ? ` — ${message}` : ""}`,
existingBatchId,
);
}
```
- [ ] **Step 6: Update parse835 to throw ApiError**
Edit `src/lib/api.ts` lines 329-334:
```typescript
if (!res.ok) {
const { message, existingBatchId } = await readErrorBody(res);
throw new ApiError(
res.status,
`${res.status} ${res.statusText}${message ? ` — ${message}` : ""}`,
existingBatchId,
);
}
```
- [ ] **Step 7: Run tests to verify they pass**
Run: `cd .worktrees/claims-unique-fix && npm test -- src/lib/api.test.ts`
Expected: PASS for all 4 tests.
- [ ] **Step 8: Commit**
```bash
git add src/lib/api.ts src/lib/api.test.ts
git commit -m "feat(api): ApiError carries existingBatchId; parse837/parse835 surface it"
```
---
### Task 2.2: `Upload.tsx` inline error panel for 409
**Files:**
- Modify: `src/pages/Upload.tsx` (add error state + panel JSX + 409 catch branch)
- Create: `src/pages/Upload.test.tsx`
The panel renders above the streaming results when `uploadError.kind === "duplicate"`. It shows:
- A 409 badge
- "Duplicate claim — file not ingested" title
- Detail mentioning the file and (if `existingBatchId` is set) "Open the existing batch to compare, or pick a different file."
- A button linking to `/batches/{existingBatchId}` if present
- A "Pick a different file" ghost button that calls `pickFile(null)` and clears the error state
- [ ] **Step 1: Write the failing tests**
Create `src/pages/Upload.test.tsx`:
```tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as apiModule from "@/lib/api";
import { ApiError } from "@/lib/api";
import { Upload } from "@/pages/Upload";
// We mock the api module so the component doesn't actually call fetch.
vi.mock("@/lib/api", async () => {
const actual = await vi.importActual<typeof apiModule>("@/lib/api");
return { ...actual, parse837: vi.fn(), parse835: vi.fn() };
});
beforeEach(() => {
vi.clearAllMocks();
});
function renderUpload() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/upload"]}>
<Routes>
<Route path="/upload" element={<Upload />} />
<Route path="/batches/:id" element={<div data-testid="batch-page" />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("Upload error panel", () => {
it("renders panel with link when 409 carries existingBatchId", async () => {
vi.mocked(apiModule.parse837).mockRejectedValueOnce(
new ApiError(409, "409 Conflict — dup", "PRIOR-BATCH"),
);
renderUpload();
// Find the file input and upload a file.
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toBeTruthy();
const file = new File(["x"], "test.txt", { type: "text/plain" });
fireEvent.change(input, { target: { files: [file] } });
// Wait for the panel to render.
const link = await screen.findByRole("button", { name: /open existing batch/i });
expect(link).toBeInTheDocument();
expect(screen.getByText(/duplicate claim/i)).toBeInTheDocument();
});
it("renders panel without link when 409 omits existingBatchId", async () => {
vi.mocked(apiModule.parse837).mockRejectedValueOnce(
new ApiError(409, "409 Conflict — dup", null),
);
renderUpload();
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(["x"], "test.txt", { type: "text/plain" });
fireEvent.change(input, { target: { files: [file] } });
await screen.findByText(/duplicate claim/i);
expect(
screen.queryByRole("button", { name: /open existing batch/i }),
).toBeNull();
});
it("does NOT render panel for non-409 errors", async () => {
vi.mocked(apiModule.parse837).mockRejectedValueOnce(
new ApiError(400, "bad file"),
);
renderUpload();
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(["x"], "test.txt", { type: "text/plain" });
fireEvent.change(input, { target: { files: [file] } });
// Give the async error handler a tick.
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByText(/duplicate claim/i)).toBeNull();
});
it("'Pick a different file' button clears error", async () => {
vi.mocked(apiModule.parse837).mockRejectedValueOnce(
new ApiError(409, "409 Conflict — dup", "PRIOR"),
);
renderUpload();
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(["x"], "test.txt", { type: "text/plain" });
fireEvent.change(input, { target: { files: [file] } });
const clearBtn = await screen.findByRole("button", { name: /pick a different file/i });
fireEvent.click(clearBtn);
expect(screen.queryByText(/duplicate claim/i)).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd .worktrees/claims-unique-fix && npm test -- src/pages/Upload.test.tsx`
Expected: FAIL — no error panel exists yet, all 4 tests fail.
- [ ] **Step 3: Add error state and 409 catch branch in Upload.tsx**
Edit `src/pages/Upload.tsx`:
1. At the top imports, add:
```tsx
import { ApiError } from "@/lib/api";
import { useNavigate } from "react-router-dom";
```
2. Find the existing error-handling catch block (around line 683-687) and replace with:
```tsx
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setUploadError({
kind: "duplicate",
existingBatchId: err.existingBatchId,
filename: file.name,
});
toast.error("Duplicate claim — file not ingested");
} else {
toast.error(
err instanceof Error ? err.message : "Failed to parse file"
);
}
}
```
3. Add state declaration near the other useState calls:
```tsx
type UploadError =
| { kind: "duplicate"; existingBatchId: string | null; filename: string };
const [uploadError, setUploadError] = useState<UploadError | null>(null);
const navigate = useNavigate();
```
4. In the JSX, add the inline panel above the streaming-results section. Find the place where streaming results render (search for "streamDelay" or the section that shows the parsed batch) and insert just before it:
```tsx
{uploadError && uploadError.kind === "duplicate" ? (
<div
role="alert"
data-testid="duplicate-error-panel"
className="error-panel mx-auto max-w-3xl rounded-md border border-destructive/40 bg-destructive/5 p-4"
>
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-md bg-destructive px-2 py-0.5 text-xs font-semibold text-destructive-foreground">
409
</span>
<span className="font-semibold">Duplicate claim — file not ingested</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">
<span className="font-mono">{uploadError.filename}</span> collides with an
existing record.
{uploadError.existingBatchId
? " Open the existing batch to compare, or pick a different file."
: " Pick a different file."}
</p>
<div className="mt-3 flex gap-2">
{uploadError.existingBatchId ? (
<Button
onClick={() => navigate(`/batches/${uploadError.existingBatchId}`)}
>
Open existing batch →
</Button>
) : null}
<Button
variant="ghost"
onClick={() => {
setUploadError(null);
pickFile(null);
}}
>
Pick a different file
</Button>
</div>
</div>
) : null}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd .worktrees/claims-unique-fix && npm test -- src/pages/Upload.test.tsx`
Expected: PASS for all 4 tests.
- [ ] **Step 5: Commit**
```bash
git add src/pages/Upload.tsx src/pages/Upload.test.tsx
git commit -m "feat(upload): inline 409 error panel with existing-batch link"
```
---
## Phase 3 — End-to-end verification
### Task 3.1: Repro the original 409 with the actual multi-claim 837P file
**Files:** none (manual verification)
- [ ] **Step 1: Start backend + frontend**
In one terminal: `cd backend && uvicorn cyclone.api:app --reload --port 8000`
In another: `cd .worktrees/claims-unique-fix && npm run dev`
- [ ] **Step 2: Upload the reproducer file**
Use `docs/prodfiles/837p-from-axiscare/tp11525703-837P-20260618153339862-1of1.txt` (93696 bytes, 28 NM1*IL segments, multi-claim member-id collisions).
Expected: 200 OK + batch persisted with multiple claims having identical `member_id` (this was previously 409).
- [ ] **Step 3: Re-upload the same file to trigger 409**
Expected: 409 with `existing_batch_id` pointing at the first batch.
- [ ] **Step 4: Verify inline panel in the browser**
Open the upload page, drag the file in, verify the panel appears with the "Open existing batch →" link.
- [ ] **Step 5: Commit any tweaks**
If the panel needed any styling tweaks (e.g. spacing, colors), commit them:
```bash
git add src/pages/Upload.tsx
git commit -m "polish(upload): 409 error panel visual tweaks"
```
---
## Self-review checklist (run after writing the plan)
1. **Spec coverage** — Each section of the spec maps to a task:
- §3 Schema migration → Task 1.1
- §4 Store helpers → Task 1.2
- §5 API change → Task 1.3
- §6 Frontend `ApiError.existingBatchId` → Task 2.1
- §6 Frontend `Upload.tsx` panel → Task 2.2
- §10 Test plan (9 tests) → All 9 covered (5 backend + 4 frontend)
2. **Placeholder scan** — No "TBD", "TODO", "implement later" markers. The only intentional skip is the 835 duplicate-CLP01 test, called out explicitly.
3. **Type consistency**
- `find_existing_batch_for_claim` / `find_existing_batch_for_remit` return `str | None` everywhere.
- `ApiError.existingBatchId` is `string | null` in TS and `str | None` everywhere it's referenced.
- `setUploadError` payload shape `{ kind: "duplicate"; existingBatchId: string | null; filename: string }` is consistent in JSX and the catch branch.
4. **Risk acknowledged** — Migration reversibility, loss of uniqueness, race window are all documented in spec §9 and reflected in test scope (we don't test the race condition).
File diff suppressed because it is too large Load Diff
@@ -67,9 +67,15 @@ No CLI / settings changes.
-- The remittances table had a parallel constraint already removed in 0003
-- (because that one WAS a named index), so this migration only touches
-- claims.
--
-- The migration runner (db_migrate.py) wraps each .sql file in an
-- implicit transaction via engine.begin(), so we MUST NOT use BEGIN/COMMIT
-- inside the file (nested transactions fail in SQLite). We defer FK
-- enforcement with PRAGMA defer_foreign_keys instead of turning FKs off
-- (which is a no-op inside a transaction in SQLite). The deferred
-- checks fire at commit and validate against the renamed claims table.
PRAGMA foreign_keys=OFF;
BEGIN;
PRAGMA defer_foreign_keys = ON;
CREATE TABLE claims_new (
id TEXT PRIMARY KEY,
@@ -111,9 +117,6 @@ CREATE INDEX idx_claims_payer_rejected_at ON claims(payer_rejected_at);
CREATE INDEX idx_claims_payer_rejected_unack
ON claims(payer_rejected_at)
WHERE payer_rejected_acknowledged_at IS NULL;
COMMIT;
PRAGMA foreign_keys=ON;
```
**Note on the `db.py` ORM model:** `backend/src/cyclone/db.py` declares
@@ -165,29 +168,65 @@ Both pure reads; no transaction management needed.
### 837 path (line 394-415)
After the UNIQUE constraint is dropped in migration 0013, an `IntegrityError`
in `store.add` can only originate from the PK on `claims.id` (CLM01) —
either two claims in the same file share CLM01 (rare) or the same CLM01
exists in a prior batch. The handler picks the first claim from
`result.claims` and asks the helper whether a prior batch already holds
that CLM01:
```python
except IntegrityError as exc:
existing_batch_id = store.find_existing_batch_for_claim(...)
first_claim_id = result.claims[0].claim_id if result.claims else None
existing_batch_id = (
store.find_existing_batch_for_claim(first_claim_id)
if first_claim_id else None
)
body = {
"error": "Duplicate claim",
"detail": "...",
"detail": (
"This file (or one previously ingested with the same "
"claim control number) collides with an existing record. "
"Inspect the file for duplicate CLM01 control numbers, or "
"remove the existing batch before retrying."
),
"batch_id": rec.id,
}
if existing_batch_id:
if existing_batch_id and existing_batch_id != rec.id:
body["existing_batch_id"] = existing_batch_id
log.warning("Duplicate claim while persisting batch %s: %s", rec.id, exc)
return JSONResponse(status_code=409, content=body)
```
The `...` argument is the first `claim.claim_id` from `result.claims`
where the collision occurred. We pick the first one because the DB raises
on the second insert; iterating through the parser's claim list to find
which CLM01 already existed is cheap.
The `existing_batch_id != rec.id` guard avoids surfacing the just-failed
batch as a "previous" batch (it never persisted).
### 835 path (line 588-602)
Same pattern with `find_existing_batch_for_remit` and the existing remit's
`payer_claim_control_number`.
Same pattern with `find_existing_batch_for_remit` and the first remit's
`payer_claim_control_number` (`result.claims[0].payer_claim_control_number`):
```python
except IntegrityError as exc:
first_pcn = result.claims[0].payer_claim_control_number if result.claims else None
existing_batch_id = (
store.find_existing_batch_for_remit(first_pcn)
if first_pcn else None
)
body = {
"error": "Duplicate remittance",
"detail": (
"This 835 file (or one previously ingested with the same "
"payer claim control number) collides with an existing record. "
"Remove the existing remittance before retrying."
),
"batch_id": rec.id,
}
if existing_batch_id and existing_batch_id != rec.id:
body["existing_batch_id"] = existing_batch_id
log.warning("Duplicate remittance while persisting batch %s: %s", rec.id, exc)
return JSONResponse(status_code=409, content=body)
```
---
@@ -309,12 +348,16 @@ No new dependencies. No config changes.
## 9. Risk
* **Migration irreversibility**: SQLite has no `DROP CONSTRAINT`; the
* **Migration reversibility**: SQLite has no `DROP CONSTRAINT`; the
recreation is destructive to schema (but not to data — `INSERT INTO
claims_new SELECT * FROM claims` preserves every row). If the
migration fails mid-way, the transaction rolls back. The
`PRAGMA foreign_keys=OFF` block is required because SQLite otherwise
can't drop and rename a table that other tables reference.
migration fails mid-way, the implicit transaction (`engine.begin()`
in `db_migrate.py`) rolls back. `PRAGMA defer_foreign_keys = ON` is
required because SQLite otherwise can't drop a table that other
tables reference (`remittances.claim_id`, `matches.claim_id`,
`line_reconciliations.claim_id`, `activity_events.claim_id`). The
deferred checks fire at commit and validate against the renamed
`claims` table.
* **Loss of uniqueness**: after the migration, two claims in one batch
*can* share a `patient_control_number`. This is the intended behavior.
Claim identity is still unique via `claims.id` (CLM01, PK) and the
@@ -0,0 +1,798 @@
# Parse → Detect → Decide: 837P/835 Upload Workflow
**Date:** 2026-06-21
**Branch:** `claims-unique-fix` (worktree)
**Status:** Draft (brainstorming approved, awaiting writing-plans)
**Supersedes:** `2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md` (kept for the migration 0013 + store helper sections, which still apply).
---
## 1. Why this exists
Today's upload flow is "parse → validate → persist" in a single call.
When a claim's CLM01 collides with a prior batch, the persist raises
`IntegrityError`, the transaction rolls back, and the API returns 409
with **no parse result, no list of colliding claims, and no way to act**.
The user sees only an error message and a `batch_id` that doesn't exist.
This is wrong: the parse already happened. The user should see what was
parsed, see which claims collide with which prior batches, and decide
what to do (force-insert, delete the prior batch, or pick a different
file). The 409 response body today is too thin to make that decision.
The root cause of the 409s is a schema bug — see
`2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md` §1.
Migration 0013 drops the `UNIQUE(batch_id, patient_control_number)` inline
constraint that 0003 was supposed to drop. After 0013 lands, **multi-claim
837P files where many CLM segments share a subscriber's `member_id` will
ingest cleanly for the first time**.
But 0013 alone is not enough. The current schema has `claims.id` and
`remittances.id` as single-column PRIMARY KEYs, which means the same
CLM01 cannot exist in two different batches. That makes "cross-batch
CLM01 collisions" impossible to express in the data — but it also makes
resubmits impossible, and it makes the 409-with-collision-summary workflow
this SP describes unreachable. **Migration 0014 (added as Task 1.3 to the
plan) relaxes the PKs to composite `(batch_id, id)`.** After 0014 lands,
real resubmits are representable, the pre-flight 409 path actually fires,
and the workflow defined below is exercisable end-to-end against real data.
This SP defines the workflow for both classes of collision:
1. Multi-claim files with shared `member_id` (no longer a 409 after 0013).
2. Files where one or more CLM01s exist in a prior batch (a 409 after 0014; this SP defines the UX for it).
---
## 2. Operator surface
| Surface | Change |
|---|---|
| Backend | Pre-flight dedup check in `parse_837` and `parse_835`. New `?force=true` query param. New `DELETE /api/batches/{id}` endpoint. 409 body shape changes. |
| Frontend | `Upload.tsx` panel renders the full parse result + collision summary, with actions: "Force insert (skip dups)", "Open prior batch", "Delete prior batch and retry", "Pick a different file". |
| Tests | Migration + store helpers (already done in `claims-unique-fix`). New tests for: pre-flight dedup, force-insert, within-file dup, race 409, DELETE endpoint, frontend panel. |
---
## 3. The workflow
### 3.1 No collision (the happy path)
```
User → POST /api/parse-837 (file)
← 200 + ParseResult + batch_id
Batch persisted. UI shows parsed claims and links to the new batch.
```
### 3.2 Collision (the new path)
```
User → POST /api/parse-837 (file)
← 409 + {
error: "Duplicate claim",
detail: "...",
existing_batch_id: "B123", # most-recent prior batch with a colliding CLM01
collisions: {
colliding_claim_ids: ["A", "B"],
total_collisions: 2,
total_claims: 141, # claims in the file
new_claims_after_skip: 139, # claims that WOULD be inserted on force
},
parse_result: { ... full ParseResult ... },
}
User sees the parse result in the panel.
User can:
- Click "Force insert (skip 2 dups)" → POST /api/parse-837?force=true (same file)
← 200 + ParseResult + { skipped_claim_ids: ["A", "B"], inserted: 139 }
- Click "Open prior batch" → navigate to /batches/B123
- Click "Delete prior batch" → DELETE /api/batches/B123, then click "Re-upload"
- Click "Pick a different file" → clear the upload state
```
### 3.3 Force-insert after collision
`force=true` skips the pre-flight check. The store's existing per-row
`s.get(Claim, claim_id)` dedup still skips colliding rows silently, so
the new batch persists with only the non-colliding claims. The response
body includes `skipped_claim_ids` so the UI can show what was skipped.
`force=true` does NOT bypass the parser. If the file fails validation
(missing diagnosis, malformed segment), the response is still 422.
### 3.4 Race condition (pre-flight clean, persist fails)
If a pre-flight dedup check finds no collisions, but a concurrent process
ingests a colliding CLM01 between the check and the persist, the persist
will still raise `IntegrityError`. The handler catches it and returns
**the same 409 shape as the pre-flight collision** with
`existing_batch_id` set to the racing batch and `detail` mentioning
"another process ingested this between the check and the persist —
re-upload to retry". The user re-runs the same flow.
---
## 4. Within-file duplicates
If the file itself has the same CLM01 twice (a malformed file, not a
cross-batch collision), the pre-flight check catches it the same way:
it returns 409 with `existing_batch_id: null` and `detail: "CLM01 A
appears twice in this file"`. The user can only force-insert (which
skips the second instance). They can't "delete the prior batch" because
there isn't one — it's a bad file.
---
## 5. The 409 body shape
```json
{
"error": "Duplicate claim",
"detail": "This file (or one previously ingested with the same claim control number) collides with an existing record. 2 of 141 claims collide with batch B123.",
"batch_id": null,
"existing_batch_id": "B123",
"collisions": {
"colliding_claim_ids": ["A", "B"],
"total_collisions": 2,
"total_claims": 141,
"new_claims_after_skip": 139
},
"parse_result": { ... full ParseResult ... }
}
```
Field semantics:
- `error`: short tag for the UI ("Duplicate claim", "Duplicate remittance", "Within-file duplicate CLM01").
- `detail`: human-readable, mentions the count and the existing batch when known.
- `batch_id`: always `null` on 409 (the insert rolled back).
- `existing_batch_id`: the most-recent prior batch that contains a colliding CLM01, or `null` if (a) the collision is within-file, or (b) the colliding claim has since been deleted (race).
- `collisions.colliding_claim_ids`: subset of `parse_result.claims[].claim_id` that collides.
- `collisions.total_claims`: count from `parse_result.summary.total_claims`.
- `collisions.new_claims_after_skip`: `total_claims - total_collisions`.
- `parse_result`: the full `ParseResult` (same shape as a 200 response body). The UI uses this to render the parsed claims list.
The 200 body on `force=true` adds `skipped_claim_ids: ["A", "B"]` at the top level so the UI can show a "skipped" badge per claim.
---
## 6. `DELETE /api/batches/{id}`
New endpoint. Cascades through `ON DELETE CASCADE` FKs:
```
batches ─┬─ claims ─┬─ matches
│ ├─ activity_events (claim_id)
│ └─ line_reconciliations
├─ remittances ─┬─ cas_adjustments
│ ├─ service_line_payments
│ └─ activity_events (remittance_id)
└─ activity_events (batch_id only)
```
FKs already declare `ON DELETE CASCADE` in the migrations, so the
SQLite engine handles the cascade. The endpoint just needs to
`session.delete(batch_row)` and commit.
The endpoint:
- `204 No Content` on success.
- `404 Not Found` if the batch doesn't exist.
- `409 Conflict` if the batch has any claims in a non-`submitted` state
(e.g., `paid`, `reversed`, `denied`). Forces the user to first
unreconcile — same as the existing 409 pattern for `manual_match` /
`manual_unmatch` (see `store.py:AlreadyMatchedError`).
A `batch_deleted` activity event is recorded before the delete so the
audit log has a tombstone. The event's `batch_id` will be `null` after
the cascade (the FK is to `batches.id` with no `ON DELETE` clause
specified in any migration; verify in `migrations/0001_initial.sql`
the spec says we preserve audit history). If the FK is `ON DELETE
CASCADE`, we record the event AFTER the cascade with `batch_id` set to
the deleted id and rely on the cascade to remove it (acceptable, or we
use a no-cascade FK and keep the tombstone). **Open question resolved
during implementation by reading the actual FK clauses.**
---
## 7. Backend implementation
### 7.1 New dedup helper
`backend/src/cyclone/store.py` (already added in `claims-unique-fix`):
```python
def find_existing_batch_for_claim(claim_id: str) -> str | None:
"""Return the batch_id of the first batch containing this claim id, or None.
Pure read; opens a short-lived session. Used by the 837 409 handler to
surface which prior batch already holds the same CLM01.
Returns the most-recent batch (ORDER BY parsed_at DESC LIMIT 1) so the
UI links to the most likely "where did the dup come from" answer.
"""
from sqlalchemy import select
from cyclone.db import Claim
with db.SessionLocal()() as s:
row = s.execute(
select(Claim.batch_id)
.where(Claim.id == claim_id)
.order_by(Claim.state_changed_at.desc()) # most-recent touch
.limit(1)
).first()
return row[0] if row else None
def find_existing_batch_for_remit(remit_id: str) -> str | None:
"""Same shape as find_existing_batch_for_claim but for remittances."""
from sqlalchemy import select
from cyclone.db import Remittance
with db.SessionLocal()() as s:
row = s.execute(
select(Remittance.batch_id)
.where(Remittance.id == remit_id)
.order_by(Remittance.received_at.desc())
.limit(1)
).first()
return row[0] if row else None
```
The current `claims-unique-fix` implementation uses
`select(Claim.batch_id).where(Claim.id == claim_id).limit(1)` without
`ORDER BY`. We replace it with the ordered version to satisfy
"return the most-recent colliding batch".
### 7.2 New pre-flight dedup check
`backend/src/cyclone/dedup.py` (new file, single responsibility):
```python
"""Pre-flight dedup for parsed 837P/835 batches.
Splits the parsed result into "would-insert" and "would-skip" sets by
querying the DB for any claim_id / payer_claim_control_number already
present. Also detects within-file duplicates by counting claim_id
frequencies.
Used by the parse-837 and parse-835 endpoints between validation and
persist, so the user can see the parse result + collision summary
before any DB write.
"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from cyclone import db
from cyclone.db import Claim, Remittance
@dataclass(frozen=True)
class CollisionReport:
"""What the parse endpoint needs to render a 409 response."""
colliding_claim_ids: list[str] # CLM01s (837) or CLP01s (835)
existing_batch_id: str | None # most-recent prior batch with a collision, or None
within_file_duplicate_ids: list[str] # CLM01s appearing twice in this file (subset of colliding_claim_ids)
total_claims: int
def preflight_837(result, session: Session | None = None) -> CollisionReport:
"""Detect 837 collisions: within-file dupes + cross-batch CLM01 dupes."""
claim_ids = [c.claim_id for c in result.claims]
counts = Counter(claim_ids)
within_file_duplicate_ids = sorted(
cid for cid, n in counts.items() if n > 1
)
seen: set[str] = set(claim_ids)
if not seen:
return CollisionReport(
colliding_claim_ids=[],
existing_batch_id=None,
within_file_duplicate_ids=[],
total_claims=0,
)
own_session = session is None
if own_session:
session = db.SessionLocal()()
try:
rows = session.execute(
select(Claim.id, Claim.batch_id)
.where(Claim.id.in_(seen))
.order_by(Claim.state_changed_at.desc())
).all()
finally:
if own_session:
session.close()
db_collisions = {cid: bid for cid, bid in rows}
colliding = sorted(cid for cid in seen if cid in db_collisions)
existing_batch_id = next(iter(db_collisions.values()), None) if db_collisions else None
return CollisionReport(
colliding_claim_ids=colliding,
existing_batch_id=existing_batch_id,
within_file_duplicate_ids=within_file_duplicate_ids,
total_claims=len(claim_ids),
)
def preflight_835(result, session: Session | None = None) -> CollisionReport:
"""Same shape for 835 remittances. Payer claim control number = CLP01 = remittance.id."""
pcns = [c.payer_claim_control_number for c in result.claims]
counts = Counter(pcns)
within_file_duplicate_ids = sorted(p for p, n in counts.items() if n > 1)
seen = set(pcns)
if not seen:
return CollisionReport(
colliding_claim_ids=[],
existing_batch_id=None,
within_file_duplicate_ids=[],
total_claims=0,
)
own_session = session is None
if own_session:
session = db.SessionLocal()()
try:
rows = session.execute(
select(Remittance.id, Remittance.batch_id)
.where(Remittance.id.in_(seen))
.order_by(Remittance.received_at.desc())
).all()
finally:
if own_session:
session.close()
db_collisions = {pcn: bid for pcn, bid in rows}
colliding = sorted(pcn for pcn in seen if pcn in db_collisions)
existing_batch_id = next(iter(db_collisions.values()), None) if db_collisions else None
return CollisionReport(
colliding_claim_ids=colliding,
existing_batch_id=existing_batch_id,
within_file_duplicate_ids=within_file_duplicate_ids,
total_claims=len(pcns),
)
```
### 7.3 Modified `parse_837` endpoint
```python
@app.post("/api/parse-837")
async def parse_837(
request: Request,
file: UploadFile = File(...),
payer: str = Query("co_medicaid"),
include_raw_segments: bool = Query(True),
strict: bool = Query(False),
ack: bool = Query(False),
force: bool = Query(False), # NEW
) -> Any:
# ... existing parse + validate ...
if _has_claim_validation_errors(result):
return JSONResponse(status_code=422, content=json.loads(result.model_dump_json()))
# NEW: pre-flight dedup check
if not force and result.claims:
report = dedup.preflight_837(result)
if report.colliding_claim_ids or report.within_file_duplicate_ids:
return _build_409_response(
result=result,
report=report,
error="Duplicate claim",
kind="cross_batch" if report.existing_batch_id else "within_file",
)
# Persist (existing path). On IntegrityError (race), same 409 shape.
rec = BatchRecord(
id=uuid.uuid4().hex,
kind="837p",
input_filename=file.filename or "upload.txt",
parsed_at=utcnow(),
result=result,
)
try:
store.add(rec, event_bus=request.app.state.event_bus)
except IntegrityError as exc:
# Race: pre-flight said clean, but persist hit a PK. Re-run pre-flight
# so the 409 body has the same shape.
report = dedup.preflight_837(result)
return _build_409_response(
result=result,
report=report,
error="Duplicate claim (race condition)",
kind="race",
)
# ... existing response ...
if _client_wants_json(request):
body = json.loads(result.model_dump_json())
if ack:
ack_body = _build_and_persist_ack(rec.id)
if ack_body is not None:
body["ack"] = ack_body
# If force=true, the store.add silently skipped some claims.
# Surface what was skipped so the UI can show a "skipped" badge.
if force:
body["skipped_claim_ids"] = sorted({
c.claim_id for c in result.claims
if _claim_skipped(c.claim_id, rec.id)
})
return JSONResponse(content=body)
# ... streaming response ...
```
Where:
```python
def _build_409_response(
result, report, error: str, kind: str
) -> JSONResponse:
"""Build the standard 409 body for any dedup failure."""
if kind == "cross_batch":
detail = (
f"{len(report.colliding_claim_ids)} of {report.total_claims} "
f"claims collide with prior batch {report.existing_batch_id}. "
f"Force-insert to skip the duplicates, or delete the prior batch."
)
elif kind == "within_file":
detail = (
f"CLM01(s) {', '.join(report.within_file_duplicate_ids)} appear "
f"twice in this file. Force-insert will keep the first occurrence "
f"and skip the rest."
)
else: # race
detail = (
f"Another process ingested a colliding batch between the check "
f"and the persist. Re-upload to retry with the latest state."
)
body = {
"error": error,
"detail": detail,
"batch_id": None,
"existing_batch_id": report.existing_batch_id,
"collisions": {
"colliding_claim_ids": report.colliding_claim_ids,
"total_collisions": len(report.colliding_claim_ids),
"total_claims": report.total_claims,
"new_claims_after_skip": report.total_claims - len(report.colliding_claim_ids),
},
"parse_result": json.loads(result.model_dump_json()),
}
return JSONResponse(status_code=409, content=body)
```
`force=true` does NOT bypass validation (still 422 for bad data). It
only bypasses the pre-flight dedup. The `store.add` dedup still skips
colliding claims silently, but the response surfaces the skip list.
### 7.4 Modified `parse_835` endpoint
Same pattern, with `dedup.preflight_835` and the 835 parse result. Not
shown in detail; the structure mirrors 837.
### 7.5 New `DELETE /api/batches/{id}`
```python
@app.delete("/api/batches/{batch_id}")
def delete_batch(batch_id: str) -> dict:
"""Hard-delete a batch and all its child rows.
Returns 204 on success, 404 if missing, 409 if the batch has any
claims/remits in a non-`submitted` state (must unreconcile first).
"""
from cyclone import db
with db.SessionLocal()() as s:
batch = s.get(db.Batch, batch_id)
if batch is None:
raise HTTPException(404, f"Batch {batch_id} not found")
# Refuse if any claim/remittance is past 'submitted' state
non_submitted = s.execute(
select(db.Claim.id)
.where(db.Claim.batch_id == batch_id)
.where(db.Claim.state != "submitted")
.limit(1)
).first()
if non_submitted is not None:
raise HTTPException(
409,
f"Batch {batch_id} has claims in non-submitted state; "
f"unreconcile first before deleting.",
)
# Record tombstone activity event before the cascade
s.add(db.ActivityEvent(
ts=utcnow(),
kind="batch_deleted",
batch_id=batch_id,
payload_json={"message": f"Batch {batch_id} deleted"},
))
s.flush()
s.delete(batch)
s.commit()
return {"ok": True, "batch_id": batch_id}
```
The FKs in the schema (`migrations/0001_initial.sql` and later) declare
`ON DELETE CASCADE` on `claims.batch_id`, `remittances.batch_id`, etc.
SQLite handles the cascade at the engine level. We verify this assumption
in the implementation test by deleting a batch with child rows and
asserting the child rows are gone.
---
## 8. Frontend
### 8.1 `src/lib/api.ts`
`ApiError` carries more collision data:
```typescript
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public existingBatchId: string | null = null,
public collisions: CollisionSummary | null = null,
public parseResult: unknown = null,
) {
super(message);
}
}
export type CollisionSummary = {
colliding_claim_ids: string[];
total_collisions: number;
total_claims: number;
new_claims_after_skip: number;
};
```
`parse837` adds `?force=true` to the URL when called for the
"force-insert" action:
```typescript
export async function parse837(
file: File,
options: { onProgress?: (p: number) => void; force?: boolean } = {},
): Promise<ParseResult> {
const url = `${base}/api/parse-837${options.force ? "?force=true" : ""}`;
// ... existing fetch + body parse ...
if (!res.ok) {
const { message, existingBatchId, collisions, parseResult } = await readErrorBody(res);
throw new ApiError(res.status, message, existingBatchId, collisions, parseResult);
}
return res.json();
}
```
### 8.2 `src/pages/Upload.tsx`
New state:
```typescript
type UploadError = {
kind: "duplicate";
existingBatchId: string | null;
collisions: CollisionSummary;
parseResult: ParseResult;
filename: string;
};
const [uploadError, setUploadError] = useState<UploadError | null>(null);
const [forceInserting, setForceInserting] = useState(false);
```
Panel JSX (above the streaming results):
```tsx
{uploadError ? (
<div
role="alert"
className="rounded-md border border-destructive/40 bg-destructive/5 p-4 mx-auto max-w-3xl"
>
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-md bg-destructive px-2 py-0.5 text-xs font-semibold text-destructive-foreground">
409
</span>
<span className="font-semibold">
{uploadError.collisions.total_collisions} of {uploadError.collisions.total_claims} claims
collide
{uploadError.existingBatchId
? ` with batch ${uploadError.existingBatchId}`
: " within this file"}
</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">
File <span className="font-mono">{uploadError.filename}</span> would persist
{" "}{uploadError.collisions.new_claims_after_skip} of {uploadError.collisions.total_claims} claims.
Colliding CLM01s: {uploadError.collisions.colliding_claim_ids.join(", ")}.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button
disabled={forceInserting}
onClick={async () => {
setForceInserting(true);
try {
// re-call with force=true; the response will be 200 + skipped_claim_ids
const result = await parse837(file, { onProgress: () => {}, force: true });
setParseResult(result);
setUploadError(null);
toast.success(
`Force-inserted: ${result.summary.total_claims - (result.skipped_claim_ids?.length ?? 0)} of ${result.summary.total_claims} claims (skipped ${result.skipped_claim_ids?.length ?? 0} dups)`,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Force-insert failed");
} finally {
setForceInserting(false);
}
}}
>
Force insert (skip {uploadError.collisions.total_collisions} dups)
</Button>
{uploadError.existingBatchId ? (
<>
<Button variant="outline" onClick={() => navigate(`/batches/${uploadError.existingBatchId}`)}>
Open prior batch
</Button>
<Button
variant="outline"
onClick={async () => {
if (!confirm(`Delete batch ${uploadError.existingBatchId}? This cannot be undone.`)) return;
await deleteBatch(uploadError.existingBatchId);
toast.success(`Deleted ${uploadError.existingBatchId}`);
setUploadError(null);
pickFile(null);
}}
>
Delete prior batch
</Button>
</>
) : null}
<Button variant="ghost" onClick={() => { setUploadError(null); pickFile(null); }}>
Pick a different file
</Button>
</div>
{/* The full parse result is rendered below so the user can see what was parsed. */}
<details className="mt-3 text-sm">
<summary>Show parsed claims ({uploadError.parseResult.claims.length})</summary>
<pre className="mt-2 max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
{JSON.stringify(uploadError.parseResult.summary, null, 2)}
</pre>
</details>
</div>
) : null}
```
---
## 9. Database
Migration 0013 already exists on the `claims-unique-fix` worktree. It
drops the `UNIQUE(batch_id, patient_control_number)` inline constraint.
After it runs:
- The 409 fires only on actual CLM01 collisions (not the `member_id`
dedup that was over-constraining before).
- Multi-claim 837P files with shared `member_id` ingest cleanly for
the first time.
Migration 0014 (added as Task 1.3 in the plan) further relaxes the schema:
it changes the PKs on `claims` and `remittances` from single-column
(`id`) to composite (`batch_id`, `id`). This is what allows resubmits and
makes the workflow in §3 reachable.
No new tables. No new columns. The DELETE endpoint relies on existing
`ON DELETE CASCADE` FKs.
---
## 10. Files changed
| File | Change |
|---|---|
| `backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql` | new (DONE on `claims-unique-fix`) |
| `backend/src/cyclone/migrations/0014_relax_claims_remits_pk.sql` | new: composite PK `(batch_id, id)` on `claims` and `remittances`; updates FKs |
| `backend/src/cyclone/store.py` | new `find_existing_batch_for_claim` / `find_existing_batch_for_remit` (DONE) + new `delete_batch` method |
| `backend/src/cyclone/dedup.py` | new file: pre-flight `preflight_837` / `preflight_835` + `CollisionReport` dataclass |
| `backend/src/cyclone/api.py` | 837/835 endpoints: pre-flight check, force param, new 409 body, race handler, new DELETE endpoint |
| `src/lib/api.ts` | `ApiError` adds `collisions` + `parseResult`; `parse837`/`parse835` accept `force`; new `deleteBatch` |
| `src/pages/Upload.tsx` | new `UploadError` state, error panel JSX, force-insert handler, delete-prior handler |
| `src/pages/Upload.test.tsx` | new tests (4 cases from §11) |
| `backend/tests/test_db_migrate.py` | 0013 idempotency + UNIQUE-dropped tests (DONE); 0014 composite-PK + FK-cascade tests |
| `backend/tests/test_store.py` | `find_existing_batch_for_claim`/`remit` tests (DONE) |
| `backend/tests/test_dedup.py` | new tests for `preflight_837` / `preflight_835` (§11) |
| `backend/tests/test_api_parse_persists.py` | new tests: pre-flight 409, force-insert, within-file 409, race 409, DELETE endpoint (§11) |
No new dependencies. No config changes.
---
## 11. Test plan
### Backend (pytest)
| Test | File | Asserts |
|---|---|---|
| `test_preflight_837_finds_no_collisions_on_empty_db` | `test_dedup.py` | empty DB → empty `colliding_claim_ids`, no `existing_batch_id` |
| `test_preflight_837_finds_cross_batch_collision` | `test_dedup.py` | pre-seed a claim; pre-flight returns that claim_id in `colliding_claim_ids` and the seeded batch in `existing_batch_id` |
| `test_preflight_837_finds_within_file_duplicate` | `test_dedup.py` | parsed result has the same CLM01 twice; pre-flight returns it in both `colliding_claim_ids` and `within_file_duplicate_ids` |
| `test_preflight_837_returns_most_recent_batch_id` | `test_dedup.py` | pre-seed 3 batches with the same CLM01 at different times; pre-flight returns the most-recent batch_id |
| `test_preflight_835_mirrors_837` | `test_dedup.py` | same shape for remittances |
| `test_parse_837_409_includes_parse_result_and_collisions` | `test_api_parse_persists.py` | pre-seed a claim; upload a file with a colliding CLM01; assert 409 with `parse_result`, `collisions.colliding_claim_ids`, `existing_batch_id` |
| `test_parse_837_409_within_file_duplicate_has_null_batch_id` | `test_api_parse_persists.py` | upload a file with the same CLM01 twice; assert 409 with `existing_batch_id: null` and `within_file_duplicate_ids` populated |
| `test_parse_837_force_true_persists_non_colliding_claims` | `test_api_parse_persists.py` | pre-seed a claim; upload a file with 3 claims, 1 colliding; assert 200 with `skipped_claim_ids: [colliding_id]`, the 2 non-colliding claims persist |
| `test_parse_837_force_true_does_not_bypass_validation` | `test_api_parse_persists.py` | a file that fails validation still returns 422 with `force=true` |
| `test_parse_837_race_409_uses_same_body_shape` | `test_api_parse_persists.py` | mock `store.add` to raise IntegrityError; assert 409 body has the same shape as the pre-flight 409 |
| `test_delete_batch_cascades_to_claims` | `test_api_parse_persists.py` | persist a batch with 2 claims; DELETE; assert batch and both claims are gone |
| `test_delete_batch_404_on_unknown` | `test_api_parse_persists.py` | DELETE /api/batches/does-not-exist → 404 |
| `test_delete_batch_409_on_reconciled_claims` | `test_api_parse_persists.py` | persist a batch, mark a claim state='paid'; DELETE → 409 |
| `test_parse_837_after_delete_succeeds` | `test_api_parse_persists.py` | pre-seed a colliding claim; DELETE that batch; re-upload the same file; assert 200 |
### Frontend (vitest)
| Test | File | Asserts |
|---|---|---|
| `test_error_panel_renders_on_409_with_collisions` | `Upload.test.tsx` | mock `parse837` to throw `ApiError(409, ..., PRIOR, collisions, parseResult)`; assert panel visible with all collision data |
| `test_force_insert_button_re_calls_with_force_true` | `Upload.test.tsx` | user clicks "Force insert"; assert `parse837` is called with `{ force: true }` |
| `test_delete_prior_button_calls_deleteBatch` | `Upload.test.tsx` | user clicks "Delete prior batch"; assert `deleteBatch(existingBatchId)` is called |
| `test_pick_different_clears_error` | `Upload.test.tsx` | user clicks "Pick a different file"; assert `uploadError` is cleared and file picker is reset |
| `test_no_panel_on_non_409` | `Upload.test.tsx` | 400 error; assert panel absent |
| `test_within_file_duplicate_omits_prior_batch_actions` | `Upload.test.tsx` | 409 with `existingBatchId: null`; assert "Open prior batch" and "Delete prior batch" buttons are absent |
---
## 12. Out of scope
* Batch editing (update claim state, edit claim fields). Future SP.
* Cross-batch dedup REPORT (a "find all CLM01s in batches B1+B2+B3"
query). Future SP.
* Migration reversibility for 0013 — the recreation preserves data but
not schema history. Acceptable since 0013 just drops an inline
constraint; recreating the constraint would be a separate migration.
* Audit event for force-insert skips. The user explicitly chose to
skip silently; we honor that.
---
## 13. Risk
* **Pre-flight check race**: between the check and the persist, a
concurrent process could ingest a colliding claim. The persist would
then raise `IntegrityError`; the handler returns the same 409 shape
with `detail` mentioning the race. The user re-runs. Acceptable.
* **DELETE on a large batch**: cascade through `claims`, `remittances`,
`matches`, `line_reconciliations`, `activity_events`. SQLite handles
the cascade in a single transaction; a 140-claim batch deletes in
<100ms. The endpoint refuses if any claim is past `submitted` state.
* **`force=true` silent skip**: the user clicks "Force insert" and
the response says "X of Y claims persisted, Z skipped". They
acknowledged this in the panel before clicking. No undo.
* **Within-file duplicates and force-insert**: the user can force-insert
a file with the same CLM01 twice. The first instance persists, the
second is silently skipped. This is intentional — within-file dupes
are usually a typo, and the user has explicitly asked to proceed.
* **`existing_batch_id` may be stale**: the helper returns the
most-recent batch by `state_changed_at` (or `received_at` for 835).
The user clicks "Open prior batch" and the batch may have been
deleted in the meantime. The BatchesList page already handles 404
gracefully.
---
## 14. Rollout
1. **Schema**: migration 0013 applies on next `cyclone` startup.
Idempotent and reversible only by rebuilding the `claims` table
(acceptable; production data preserved by the INSERT...SELECT).
2. **Backend API**: new `force` param + new 409 body shape + new
DELETE endpoint. Existing clients that don't pass `force` see the
same behavior as before for collision-free files. Collision cases
now get a richer 409 body that includes `parse_result`; clients
that ignore the new fields keep working.
3. **Frontend**: `Upload.tsx` panel replaces the toast on 409. Users
who don't read the panel still see the toast and the 409 message
in the streaming view.
4. **No data migration**: nothing to migrate. 0013 is structural only.
@@ -0,0 +1,816 @@
# Cyclone Ubuntu Docker Deployment — Design
**Date:** 2026-06-22
**Status:** Approved (pending user review of this doc)
**Depends on:** [2026-06-19-cyclone-production-readiness-design.md](2026-06-19-cyclone-production-readiness-design.md) (in-memory store + react-query wiring; local-only)
**Replaces:** The "no auth, no Docker, local-only" posture of the parent spec with a production-grade posture for real PHI on a single Ubuntu server.
---
## 1. Overview
Cyclone's first sub-project shipped a usable local-only system: parse 837/835 files, browse the data, edit and resubmit rejected claims, export a corrected 837 ZIP. Everything ran on `127.0.0.1` with no auth, in-memory storage, and a `python -m cyclone serve` invocation.
This sub-project graduates that to a production-grade deployment on a single Ubuntu Linux server:
- **Two-container Docker Compose** (backend + frontend) replacing the dev-server split
- **App-layer authentication** with username + password, argon2id hashing, session cookies, and three fixed roles (admin / operator / viewer)
- **SQLCipher encryption at rest** for the SQLite DB, with the key as a Docker secret
- **Daily encrypted backups** with the existing AES-256-GCM `BackupService`, scheduler autostart, 14-day retention
- **LAN-only bind** (single operator, single Ubuntu server); VPN handles outside access
- **Manual `docker compose pull` updates** with semver tags; one-env-var rollback
- **Operator runbook** + bootstrap scripts so the deploy is reproducible
Real PHI flows through this deployment. The SFTP wire stays stubbed — operators download the corrected 837 ZIP and hand it to the Gainwell MFT UI manually. That's intentional and documented as v1 scope; SFTP wire is v2.
After this ships, the operator can:
1. `git clone <repo> /opt/cyclone && cd /opt/cyclone`
2. `docker compose build`
3. `bash scripts/cyclone-init.sh` → generates `/etc/cyclone/secrets/{db,secret,admin_pw}.key`, prints the admin password once
4. `docker compose up -d`
5. Open `http://<lan-ip>:8080/login` → log in as `admin`, change password, create operators
6. Upload parsed 837s, edit claims, export ZIPs, hand to MFT UI
7. Trust daily encrypted backups + the operator's off-box cron copy for ≤24h loss
## 2. Goals
1. **Dockerize the existing stack** so it runs the same way in production as in development. One repo, one `docker compose up -d`.
2. **Add app-layer authentication** with username/password + 3-role RBAC, replacing the current "no auth at all" posture.
3. **Encrypt the SQLite DB at rest** via SQLCipher; key as a Docker secret (not an env var, not a keychain on macOS).
4. **Wire the existing encrypted backup system** to autostart on container boot with sane defaults (24h interval, 14-day retention).
5. **Add an operator runbook + bootstrap scripts** so the system is reproducible from `git clone` and supportable without tribal knowledge.
6. **Preserve all existing functionality** — the parser, editor, exporter, scheduler, audit log, backup/restore flow all keep working unchanged from the user's perspective.
## 3. Non-goals (this sub-project)
- **Real SFTP wire (paramiko)** — keep stub. Operators download ZIPs and use the MFT UI. Listed as v2.
- **Public TLS / public domain / Caddy reverse proxy** — LAN-only bind for v1; VPN handles outside access. Listed as v2.
- **Multi-host HA / DB replication / load balancing** — single Ubuntu server. Listed as v2.
- **Prometheus / Grafana / real alerting** — v1 uses the simple `curl healthz | mail` cron pattern. Listed as v2.
- **Off-box automated backup shipping** — operator's responsibility via host cron / rsync. Documented in runbook, not automated.
- **2FA / SSO / OAuth** — v2.
- **Self-service password reset** — v2; v1 admin resets via `cyclone admin reset-password` CLI.
- **Per-file encryption of prodfiles / sftp_staging volumes** — v2; v1 relies on volume-level filesystem permissions + the SQLCipher-encrypted DB + the host being physically secure.
- **LUKS full-disk encryption** — out of scope; assumed to be an operator-level concern (Ubuntu installer offers it).
- **Automatic update mechanism** (Watchtower etc.) — v1 is manual `docker compose pull`. Listed as v2.
- **New X12 transaction types or validation rules** — not this sub-project.
## 4. Stack
**Backend additions:**
- `argon2-cffi` (new dep) for password hashing
- `cyclone.auth` module: `User` / `Session` SQLAlchemy models, password hashing helpers, lockout logic
- `cyclone.api.auth` routes: `/api/auth/{login,logout,change-password,me}`
- `cyclone.api.users` routes: `/api/admin/users` (admin-only CRUD)
- `cyclone.cli` additions: `cyclone admin {create-user,reset-password,list-users}`
- Migration `0012_users_sessions.sql` for `users` + `sessions` tables
- Existing `BackupService` (SP17) and `audit_log` machinery reused unchanged
**Backend Dockerfile** (`backend/Dockerfile`):
- Multi-stage: builder stage installs `.[sqlcipher]` (sqlcipher is optional but recommended; the engine falls back to plain SQLite if the package isn't actually present, same graceful default as today), runtime stage copies installed site-packages + source
- Base: `python:3.11-slim-bookworm` (matches `requires-python = ">=3.11"`)
- Non-root user `cyclone` (uid 1000)
- `HEALTHCHECK CMD curl -fs http://localhost:8000/api/healthz || exit 1`
- Entrypoint: `python -m cyclone serve`
**Frontend additions:**
- `nginx.conf` for SPA routing + reverse proxy to backend
- `Dockerfile.frontend`: multi-stage builder (node:20-alpine runs `npm ci && npm run build`) + runtime (nginx:1.27-alpine serving `dist/`)
- `useCurrentUser()` hook (react-query)
- `Login.tsx` page
- `<AuthGate>` wrapper in `App.tsx`
- `<NavBar>` shows username + role badge + logout button
- `/admin/users` page (admin-only)
**Infrastructure:**
- `docker-compose.yml` at repo root
- `scripts/cyclone-init.sh` — generates `/etc/cyclone/secrets/*` with `openssl rand -hex 32`
- `scripts/post-deploy.sh` — sets up logrotate + healthcheck cron on host
- `scripts/smoke.sh` — bring-up + login + parse + export end-to-end test
- `RUNBOOK.md` — daily/weekly/quarterly/as-needed ops procedures
- `.dockerignore` files for both build contexts
## 5. Architecture
```
┌─────────────────────────────────────────────────────┐
│ Ubuntu Linux server (your machine) │
│ │
│ cyclone_network (bridge) │
│ ┌─────────────────────┐ ┌────────────────────┐ │
│ │ cyclone-backend │ │ cyclone-frontend │ │
│ │ FastAPI │◄───┤ nginx (SPA + proxy)│ │
│ │ :8000 (internal) │ │ :8080 (host) │ │
│ └──────┬──────────────┘ └─────────┬──────────┘ │
│ │ │ │
└─────────┼─────────────────────────────┼────────────┘
│ │
┌──────▼──────────────────┐ ┌──────▼──────────┐
│ Named volumes │ │ Bind mounts │
│ cyclone_db │ │ ./dist (built) │
│ cyclone_backups │ │ ./nginx.conf │
│ cyclone_prodfiles │ └─────────────────┘
│ cyclone_sftp_staging │
└─────────────────────────┘
/etc/cyclone/secrets/ (host, chmod 600, root:root)
├─ db.key → /run/secrets/cyclone_db_key
├─ secret.key → /run/secrets/cyclone_secret_key
└─ admin_pw → /run/secrets/cyclone_admin_password
```
**Why two containers:** nginx serves the React build faster than FastAPI would, decouples frontend rebuild cadence from backend release cadence, and is the standard ops pattern. nginx also reverse-proxies `/api/*` to the backend so the browser only talks to one origin (no CORS needed, no preflight requests for the `Cookie` header).
**Why LAN-only bind:** nginx listens on `0.0.0.0:8080` inside the host network namespace, but the operator is expected to bind to the LAN IP via firewall rules / not exposing on the WAN. VPN handles outside access. No public TLS needed for v1.
**Container-to-container networking:** the frontend container reaches the backend at `http://cyclone-backend:8000` over the compose-managed bridge network. The browser only ever sees `http://<lan-ip>:8080`.
**Healthcheck:** container-level `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries. Compose `restart: unless-stopped` on healthcheck failure.
**Reverse proxy in nginx:**
```nginx
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# API + auth: proxy to backend
location /api/ {
proxy_pass http://cyclone-backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # long enough for big parse streams
client_max_body_size 50m; # claims files can be large
}
# SPA: serve index.html for all non-/api routes
location / {
try_files $uri $uri/ /index.html;
}
}
```
## 6. Backend changes
### 6.1 New module `backend/src/cyclone/auth/__init__.py`
```python
@dataclass(frozen=True)
class Role:
VIEWER = "viewer"
OPERATOR = "operator"
ADMIN = "admin"
ROLE_RANK = {Role.VIEWER: 0, Role.OPERATOR: 1, Role.ADMIN: 2}
class User(Base):
__tablename__ = "users"
id: Mapped[str] # uuid4 hex
username: Mapped[str] # UNIQUE
password_hash: Mapped[str] # argon2id
role: Mapped[str] # viewer | operator | admin
created_at: Mapped[datetime]
last_login_at: Mapped[datetime | None]
failed_count: Mapped[int] = 0
locked_until: Mapped[datetime | None] = None
class Session(Base):
__tablename__ = "sessions"
id: Mapped[str] # 32-byte hex token
user_id: Mapped[str]
created_at: Mapped[datetime]
expires_at: Mapped[datetime]
ip: Mapped[str | None]
user_agent: Mapped[str | None]
```
Password hashing uses `argon2-cffi` with the OWASP-recommended parameters (`time_cost=2`, `memory_cost=19456`, `parallelism=1`, `hash_len=32`).
### 6.2 Auth routes in `backend/src/cyclone/api.py` (new router `auth_router`)
| Method | Path | Behavior | Auth |
|---|---|---|---|
| POST | `/api/auth/login` | `{username, password}` → verify argon2id, increment `failed_count` on miss (lock at 5/15min), create session, set `HttpOnly; Secure; SameSite=Lax` cookie | none |
| POST | `/api/auth/logout` | delete session row, clear cookie | any logged-in |
| GET | `/api/auth/me` | `{user: {username, role}}` or 401 | any logged-in |
| POST | `/api/auth/change-password` | `{old_password, new_password}` → verify, rehash | any logged-in |
| POST | `/api/admin/users` | `{username, password, role}` → create user | admin |
| GET | `/api/admin/users` | list users | admin |
| PATCH | `/api/admin/users/{id}` | `{role?, password?}` | admin |
| DELETE | `/api/admin/users/{id}` | soft-disable (set `disabled_at`) | admin |
### 6.3 The `require_role` dependency
Every existing protected route gets a `dependencies=[Depends(require_role(Role.OPERATOR))]` annotation:
```python
def require_role(min_role: str):
"""FastAPI dependency factory: read cookie, load user, enforce role."""
def dep(request: Request) -> User:
sid = request.cookies.get("cyclone_session")
if not sid:
raise HTTPException(401, "not authenticated")
with db.SessionLocal()() as s:
sess = s.get(Session, sid)
if sess is None or sess.expires_at < utcnow():
raise HTTPException(401, "session expired")
user = s.get(User, sess.user_id)
if user is None:
raise HTTPException(401, "user gone")
if user.locked_until and user.locked_until > utcnow():
raise HTTPException(423, "account locked")
if ROLE_RANK[user.role] < ROLE_RANK[min_role]:
raise HTTPException(403, f"requires {min_role}")
request.state.user = user
request.state.actor = user.username # existing audit-log contract
return user
return dep
```
**Role → action matrix:**
| Action | viewer | operator | admin |
|---|:-:|:-:|:-:|
| View claims, batches, acks | ✓ | ✓ | ✓ |
| `POST /api/parse-837`, `/api/parse-835` | — | ✓ | ✓ |
| Edit claim fields, resubmit rejected | — | ✓ | ✓ |
| `POST /api/batches/{id}/export-837` | — | ✓ | ✓ |
| `POST /api/clearhouse/submit` | — | ✓ | ✓ |
| `/api/admin/users` CRUD | — | — | ✓ |
| `/api/admin/backup/*` | — | — | ✓ |
| `/api/config/*` (clearhouse, payers) | — | — | ✓ |
| `/api/admin/audit-log` | — | — | ✓ |
| Backup scheduler on/off | — | — | ✓ |
**Login rate-limit:** the existing `cyclone.api.security.request_rejected` machinery already rate-limits by IP; we add per-username lockout on top:
- `failed_count >= 5` within 15min → `locked_until = now + 15min`
- Locked accounts return 423; the lock clears after the cooldown
### 6.4 Audit log wiring
The existing `audit_log.append(actor=..., ...)` calls already accept an actor string. The new auth dependency sets `request.state.actor = user.username`, and a small middleware reads it for every request. No audit-log schema change.
### 6.5 CLI additions
```bash
cyclone admin create-user --username X --role admin --password <from_stdin or --password-file>
cyclone admin reset-password --username X --new-password <from_stdin or --password-file>
cyclone admin list-users
```
Used by `scripts/cyclone-init.sh` for the bootstrap admin, and by the operator for password recovery.
### 6.6 Migration `0012_users_sessions.sql`
```sql
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'operator', 'viewer')),
created_at TIMESTAMP NOT NULL,
last_login_at TIMESTAMP,
failed_count INTEGER NOT NULL DEFAULT 0,
locked_until TIMESTAMP,
disabled_at TIMESTAMP
);
CREATE INDEX idx_users_username ON users(username);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
ip TEXT,
user_agent TEXT
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
```
### 6.7 Session timeout config
| Setting | Default | Env var |
|---|---|---|
| Session absolute lifetime | 30 days | `CYCLONE_SESSION_ABSOLUTE_DAYS` |
| Session sliding expiry | 8 hours | `CYCLONE_SESSION_SLIDING_HOURS` |
| Cookie name | `cyclone_session` | `CYCLONE_COOKIE_NAME` |
| Cookie `Secure` flag | true | always true in prod |
### 6.8 Backend Dockerfile
```dockerfile
# Builder stage
FROM python:3.11-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libsqlcipher-dev libffi-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY pyproject.toml .
COPY src/ ./src/
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]'
# Runtime stage
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
libsqlcipher-dev curl tini \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 1000 cyclone
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links /wheels cyclone
COPY src/ /app/src/
USER cyclone
ENV PYTHONUNBUFFERED=1
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fs http://localhost:8000/api/healthz || exit 1
ENTRYPOINT ["tini", "--"]
CMD ["python", "-m", "cyclone", "serve"]
```
`sqlcipher` is included but the engine falls back to plain SQLite if the package isn't actually installed — same graceful default as today, just biased toward enabling encryption in prod.
## 7. Frontend changes
### 7.1 New login page `src/pages/Login.tsx`
A simple form: username + password inputs, submit button, error message slot. On success, navigate to `/`. Lives outside the auth-gated shell, served from a route that doesn't require authentication.
```tsx
export default function Login() {
const login = useMutation({
mutationFn: ({ username, password }) => api.login(username, password),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me'] }),
});
// ...
}
```
### 7.2 `useCurrentUser` hook
```ts
export function useCurrentUser() {
return useQuery({
queryKey: ['me'],
queryFn: () => api.me(),
retry: false,
staleTime: 60_000,
});
}
```
### 7.3 AuthGate in `App.tsx`
```tsx
function AuthGate({ children }: { children: React.ReactNode }) {
const { data: user, isLoading } = useCurrentUser();
const location = useLocation();
if (isLoading) return <FullPageSkeleton />;
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
return <>{children}</>;
}
```
Wrap every route except `/login` in `<AuthGate>`. `/login` itself does *not* require auth.
### 7.4 Role-based UI gating
```ts
const ROLE_RANK = { viewer: 0, operator: 1, admin: 2 };
export function useCan(minRole: 'viewer' | 'operator' | 'admin'): boolean {
const { data: user } = useCurrentUser();
if (!user) return false;
return ROLE_RANK[user.role] >= ROLE_RANK[minRole];
}
```
Used in NavBar to hide "Admin" links from operators/viewers, and on the `/admin/users` page as a guard (`if (!useCan('admin')) return <Forbidden />`).
### 7.5 NavBar changes
- Username + role badge (admin/operator/viewer) shown right-aligned
- Logout button (calls `api.logout()`, clears react-query cache, navigates to `/login`)
- "Admin" dropdown appears only when `useCan('admin')`
### 7.6 `/admin/users` page (admin only)
Table of users (username, role, last login, status). Actions:
- **Create user** (modal with username + password + role)
- **Edit role** (inline select)
- **Reset password** (modal)
- **Disable / enable** (toggle; sets `disabled_at`)
### 7.7 api.ts additions
```ts
api.login(username: string, password: string): Promise<{ user: User }>
api.logout(): Promise<void>
api.me(): Promise<{ user: User }> // throws 401 if not logged in
api.changePassword(oldPw: string, newPw: string): Promise<void>
api.listUsers(): Promise<User[]>
api.createUser(...): Promise<User>
api.updateUser(id: string, ...): Promise<User>
api.deleteUser(id: string): Promise<void>
```
All `api.*` calls send `credentials: 'include'` so the session cookie flows.
### 7.8 Frontend Dockerfile + nginx.conf
`frontend/Dockerfile`:
```dockerfile
# Build stage
FROM node:20-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist /usr/share/nginx/html
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8080/ >/dev/null || exit 1
```
The nginx config shown in §5 lives at `frontend/nginx.conf`.
## 8. Data, secrets, backups
### 8.1 Named volumes
| Volume | Mount path in container | Contents |
|---|---|---|
| `cyclone_db` | `/var/lib/cyclone/db/cyclone.db` | SQLCipher-encrypted SQLite |
| `cyclone_backups` | `/var/lib/cyclone/backups/` | AES-256-GCM `.bin` + `.meta.json` |
| `cyclone_prodfiles` | `/var/lib/cyclone/prodfiles/` | Uploaded 837 `.txt`, downloaded 835 |
| `cyclone_sftp_staging` | `/var/lib/cyclone/sftp_staging/` | SFTP stub's outbound/inbound |
These are managed by Docker (`docker volume create` / compose `volumes:`); they live under `/var/lib/docker/volumes/` on the host.
### 8.2 Docker secrets (host-managed)
| File | Mounted as | Purpose |
|---|---|---|
| `/etc/cyclone/secrets/db.key` | `/run/secrets/cyclone_db_key` | SQLCipher `PRAGMA key` — 64 hex chars |
| `/etc/cyclone/secrets/secret.key` | `/run/secrets/cyclone_secret_key` | Cookie signing key — 64 hex chars |
| `/etc/cyclone/secrets/admin_pw` | `/run/secrets/cyclone_admin_password` | One-time bootstrap admin password |
`scripts/cyclone-init.sh` generates all three with `openssl rand -hex 32` (db.key + secret.key) and a memorable-but-random admin password, sets permissions to `chmod 600 root:root`, and prints the admin password once.
### 8.3 Backup strategy (≤24h loss is acceptable)
- The existing `BackupService` (SP17) is already implemented and tested: takes online snapshot via SQLite `.backup()` API, encrypts with AES-256-GCM, writes `.bin` + `.meta.json` in `cyclone_backups` volume
- `CYCLONE_BACKUP_AUTOSTART=1` in compose starts the in-container backup scheduler on container start
- `CYCLONE_BACKUP_INTERVAL_HOURS=24` (default)
- 14-day retention (default; configurable via `CYCLONE_BACKUP_RETENTION_DAYS`)
- Restore via existing `/api/admin/backup/{id}/restore/{initiate,confirm}` (admin-only after auth is wired)
- Existing audit-log call `actor="backup-scheduler"` becomes `actor="backup-scheduler"` (unchanged — system actor, not a user)
**Off-box backup copy** (operator's responsibility, documented in runbook):
The operator sets up a host cron / systemd timer that rsyncs `/var/lib/docker/volumes/cyclone_backups/_data/` to an external drive or NAS nightly. The `.bin` files are already encrypted so the destination doesn't need its own encryption. Documented in `RUNBOOK.md` as a required post-deploy step.
### 8.4 Data loss scenarios
| Scenario | Outcome |
|---|---|
| Container crashes | Docker restarts; SQLite WAL recovers; no loss |
| Disk corruption | Restore from latest local backup (≤24h old) |
| Disk total loss | Restore from off-box backup copy (depends on operator's cron cadence) |
| DB key compromise | Rotate via `POST /api/admin/db/rotate-key`; old backups re-encrypted on next cycle |
| Operator forgets admin password | Reset via `cyclone admin reset-password --username admin --new-password X` |
### 8.5 Encryption-at-rest coverage
| Asset | Encrypted at rest (v1) | Notes |
|---|---|---|
| Live SQLite DB | ✓ SQLCipher | AES-256, key in Docker secret |
| Backup `.bin` files | ✓ AES-256-GCM | Key from backup passphrase |
| Prodfiles (uploaded 837s) | — plain on disk | v2: per-file encryption or move to DB |
| sftp_staging | — plain on disk | v2: per-file encryption |
| Log files | — plain on disk | rotated, contained |
v1 assumes the host is physically secure (single-operator server, locked room) and that LUKS is operator-level.
## 9. Logging, monitoring, updates, ops
### 9.1 Logging
- JSON to stdout (already configured via `logging_config.py`) — Docker collects via `docker logs`
- Bind-mount `/var/log/cyclone/` from the host so logrotate can manage retention (configured in `scripts/post-deploy.sh`)
- Log levels: INFO in prod, DEBUG opt-in via `CYCLONE_LOG_LEVEL=DEBUG`
- Every login event (success + failure) logged with username, ip, user-agent, role
- Every state-changing API call carries `actor=<user_id>` (existing audit-log machinery; we wire the auth user into `request.state.actor`)
- New audit events: `auth.login`, `auth.logout`, `auth.login_failed`, `auth.password_changed`, `auth.user_created`, `auth.user_updated`, `auth.user_disabled`
### 9.2 Healthcheck
- Container-level: `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries (configured in Dockerfile)
- Compose `restart: unless-stopped` on healthcheck failure (max 3 restarts then backoff)
- `GET /api/healthz` returns `{db_ok: bool, scheduler_running: bool, last_backup_at: timestamp}` (existing endpoint, unchanged)
### 9.3 Monitoring (v1 minimal)
- No Prometheus/Grafana in v1
- Host-level cron: `curl -fs http://localhost:8080/api/healthz >/dev/null || echo "Cyclone down at $(date)" | mail -s "cyclone DOWN" you@example.com`
- Set up by `scripts/post-deploy.sh` during initial bootstrap
### 9.4 Updates
- Images tagged semver from `package.json` / `pyproject.toml`: `cyclone-backend:0.1.0`, `cyclone-frontend:0.1.0`
- Two tag aliases per image:
- `latest` — set automatically by `docker compose build` (most recent build)
- `stable` — manually promoted by the operator once a release has run in prod for ≥1 week without issues, via `docker tag cyclone-backend:0.1.0 cyclone-backend:stable && docker tag cyclone-frontend:0.1.0 cyclone-frontend:stable`. Compose defaults to `:stable` so a new build doesn't get auto-rolled-out.
- Update procedure (to pick up a new `:stable`):
```bash
cd /opt/cyclone
docker compose pull # pulls newer :stable tags
docker compose up -d # recreates containers, preserves volumes + secrets
```
- DB migrations run automatically on backend start; migrations are forward-only with documented downgrade procedures (existing pattern)
- Rollback: `TAG=0.0.9 docker compose up -d` (one env var override; compose reads `${TAG:-stable}` for the image tag)
- Previous image stays in Docker's local cache for one cycle so rollback is offline
### 9.5 Initial bootstrap
```bash
git clone <repo> /opt/cyclone && cd /opt/cyclone
docker compose build # or: docker compose pull
bash scripts/cyclone-init.sh # generates /etc/cyclone/secrets/*, prints admin password
docker compose up -d # starts both containers
# wait ~10s for migrations
docker compose exec backend cyclone admin create-user \
--username admin --role admin --password-file /run/secrets/cyclone_admin_password
# open browser to http://<lan-ip>:8080/login
# log in as admin, change password, create operators
bash scripts/post-deploy.sh # logrotate + healthcheck cron
```
### 9.6 Daily ops (operator runbook)
- **Daily:** check `GET /api/healthz` (or trust the healthcheck email)
- **Weekly:** review `/admin/audit-log` page for unusual activity
- **Quarterly:** rotate DB key via `POST /api/admin/db/rotate-key`
- **As needed:** create operators via `/admin/users`; restore from backup via the restore UI
- **Annual:** rotate cookie signing key (re-login all users)
Full procedures live in `RUNBOOK.md` shipped in the repo.
## 10. docker-compose.yml
```yaml
name: cyclone
services:
backend:
image: cyclone-backend:${TAG:-stable}
build: ./backend
restart: unless-stopped
environment:
CYCLONE_DB_URL: "sqlite:////var/lib/cyclone/db/cyclone.db"
CYCLONE_BACKUP_AUTOSTART: "1"
CYCLONE_BACKUP_INTERVAL_HOURS: "24"
CYCLONE_BACKUP_RETENTION_DAYS: "14"
CYCLONE_LOG_LEVEL: "INFO"
CYCLONE_LOG_FILE: "/var/log/cyclone/cyclone.log"
CYCLONE_LOG_JSON: "1"
CYCLONE_SESSION_ABSOLUTE_DAYS: "30"
CYCLONE_SESSION_SLIDING_HOURS: "8"
CYCLONE_COOKIE_SECURE: "1"
secrets:
- cyclone_db_key
- cyclone_secret_key
volumes:
- cyclone_db:/var/lib/cyclone/db
- cyclone_backups:/var/lib/cyclone/backups
- cyclone_prodfiles:/var/lib/cyclone/prodfiles
- cyclone_sftp_staging:/var/lib/cyclone/sftp_staging
- cyclone_logs:/var/log/cyclone
healthcheck:
test: ["CMD", "curl", "-fs", "http://localhost:8000/api/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks: [cyclone_network]
frontend:
image: cyclone-frontend:${TAG:-stable}
build: ./frontend
restart: unless-stopped
ports:
- "8080:8080"
depends_on:
backend:
condition: service_healthy
networks: [cyclone_network]
secrets:
cyclone_db_key:
file: /etc/cyclone/secrets/db.key
cyclone_secret_key:
file: /etc/cyclone/secrets/secret.key
cyclone_admin_password:
file: /etc/cyclone/secrets/admin_pw
volumes:
cyclone_db:
cyclone_backups:
cyclone_prodfiles:
cyclone_sftp_staging:
cyclone_logs:
networks:
cyclone_network:
driver: bridge
```
The `8080:8080` port is the only one published to the host. The operator is expected to bind to the LAN IP at the firewall level (UFW rules or similar).
## 11. Error handling
- **Login failures:** increment `failed_count`; lock at 5 fails / 15min; log `auth.login_failed`; 423 on locked accounts.
- **Session expiry:** 401; frontend redirects to `/login` with `state.from` preserved.
- **Missing role:** 403 with explicit message; UI shows a "Forbidden" page instead of trying to render.
- **Backend crash:** Docker healthcheck restarts the container; SQLite WAL recovers in-flight writes.
- **DB key missing:** container starts but `db.is_encryption_enabled()` returns False; `BackupService` refuses to run (existing behavior); compose logs a warning.
- **Migration failure on startup:** container exits with non-zero; compose `restart: unless-stopped` backs off after 3 attempts; operator checks logs via `docker compose logs backend`.
- **Backup failure:** logged at ERROR; audit event `backup.failed`; existing retry behavior in `BackupService`.
## 12. Testing
### 12.1 New unit tests
- `tests/test_auth.py` (~12 tests)
- `test_create_user_with_argon2_hash`
- `test_login_with_correct_password_returns_session`
- `test_login_with_wrong_password_increments_failed_count`
- `test_login_locks_account_after_5_failures`
- `test_login_lock_clears_after_15min`
- `test_session_cookie_is_httponly_secure_samesite_lax`
- `test_session_expires_after_sliding_window`
- `test_session_absolute_lifetime_enforced`
- `test_logout_deletes_session_and_clears_cookie`
- `test_change_password_invalidates_other_sessions`
- `test_require_role_rejects_below_minimum`
- `test_require_role_returns_user_object_to_dependency`
- `tests/test_users.py` (~6 tests)
- `test_admin_can_create_user`
- `test_operator_cannot_create_user`
- `test_admin_can_change_user_role`
- `test_admin_can_disable_user`
- `test_disabled_user_cannot_login`
- `test_audit_event_for_user_lifecycle`
Total new backend tests: **~18**.
### 12.2 Docker smoke tests
- `tests/test_docker.py` (~4 tests)
- `test_compose_config_validates`
- `test_dockerfile_backend_builds`
- `test_dockerfile_frontend_builds`
- `test_compose_up_brings_up_healthy_stack` (uses `testcontainers-python` or skips if not available; gated on a `DOCKER_TESTS=1` env var)
### 12.3 Smoke script
`scripts/smoke.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
# 1. Bring up stack
docker compose up -d --build
# 2. Wait for healthcheck
for i in {1..30}; do
if curl -fs http://localhost:8080/api/healthz > /dev/null; then break; fi
sleep 2
done
# 3. Login
COOKIE_JAR=$(mktemp)
curl -fs -c "$COOKIE_JAR" -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"$(cat /etc/cyclone/secrets/admin_pw)\"}"
# 4. Parse a sample 837
curl -fs -b "$COOKIE_JAR" -X POST http://localhost:8080/api/parse-837 \
-F "file=@docs/goodclaim.x12"
# 5. List batches
curl -fs -b "$COOKIE_JAR" http://localhost:8080/api/batches
# 6. Export
curl -fs -b "$COOKIE_JAR" -X POST http://localhost:8080/api/batches/<id>/export-837 \
-H 'Content-Type: application/json' -d '{"claim_ids":["..."]}' \
-o /tmp/export.zip
unzip -l /tmp/export.zip | grep -E '\.x12$'
echo "smoke: OK"
```
### 12.4 Existing tests
- All existing backend tests must continue to pass
- All existing frontend tests must continue to pass
- Pre-existing prodfile smoke failures (rate-limit / orphan-set refs on 999/TA1) remain pre-existing and out of scope
## 13. Migration / rollout
### 13.1 First-time deploy
For a brand-new install on a fresh Ubuntu 22.04 server, follow §9.5 above. No data to migrate — this is the first deployment.
### 13.2 Upgrading an existing dev install
If an operator has been running `python -m cyclone serve` locally with the in-memory store or a plaintext SQLite file, the upgrade path is:
1. **Stop the existing server.**
2. **Export any production data:** SQLite is at `~/.local/share/cyclone/cyclone.db`. The operator takes a final plaintext backup *before* the SQLCipher transition; we don't try to encrypt-in-place (would need a different migration).
3. **Run `scripts/cyclone-init.sh`** to generate secrets.
4. **Run `docker compose up -d`** — the backend starts with an empty SQLCipher DB. Migrations run.
5. **Bootstrap admin user** via the CLI in the container.
6. **Re-import data** from the plaintext export (manual step; documented in RUNBOOK).
The in-memory store from the parent spec is *lost* on upgrade (by design — local-only). The plaintext SQLite DB *can* be carried over manually if needed.
### 13.3 Updating the running stack
```bash
cd /opt/cyclone
docker compose pull # or: docker compose build
docker compose up -d # recreates containers
docker compose logs -f backend | head -200 # verify migrations + healthcheck
```
DB migrations run automatically on backend start; they're forward-only. To roll back the code (not the schema): `TAG=0.0.9 docker compose up -d`.
## 14. Out of scope (explicit)
- Real SFTP wire (paramiko)
- Public TLS / domain / Caddy reverse proxy in compose
- Multi-host HA / DB replication / load balancing
- Prometheus / Grafana / alerting beyond the simple email cron
- Off-box automated backup shipping (operator's cron)
- 2FA / SSO / OAuth
- Self-service password reset
- Per-file encryption of prodfiles / sftp_staging
- LUKS full-disk encryption
- Watchtower / automatic updates
- Linting/pre-commit/dev tooling polish
- Component / E2E browser tests (Playwright)
## 15. Acceptance checklist
### 15.1 Backend
- [ ] `pytest tests/test_auth.py tests/test_users.py tests/test_docker.py -v` passes
- [ ] All previously-passing tests still pass; new total ≥ previous + 18
- [ ] `docker compose config` validates with no errors
- [ ] `docker compose build` succeeds for both services
- [ ] `docker compose up -d` brings both containers to `healthy` within 60s
- [ ] `curl http://localhost:8080/api/healthz` returns `{db_ok: true, scheduler_running: true}`
### 15.2 Auth + RBAC
- [ ] `POST /api/auth/login` with correct creds sets `cyclone_session` cookie (HttpOnly, Secure, SameSite=Lax) and returns `{user: {...}}`
- [ ] 5 wrong logins within 15min locks the account; 423 returned
- [ ] `GET /api/batches` without session cookie returns 401
- [ ] `GET /api/admin/users` as operator returns 403; as admin returns list
- [ ] Logout clears the cookie and invalidates the session in the DB
- [ ] Password change invalidates other sessions for that user
### 15.3 Encryption + backups
- [ ] `cyclone.db_crypto.is_encryption_enabled()` returns True in container
- [ ] Inspecting the DB file on disk shows binary ciphertext, not plaintext
- [ ] `POST /api/admin/backup/create` creates an encrypted `.bin` in `/var/lib/cyclone/backups/`
- [ ] Backup scheduler runs every 24h (`docker compose logs backend | grep backup-scheduler`)
- [ ] Restore flow works end-to-end: create backup → wipe DB → restore → verify data present
### 15.4 Frontend
- [ ] Unauthenticated browser request to `/` redirects to `/login`
- [ ] Login as admin → land on `/`; NavBar shows username + role
- [ ] Operator does not see "Admin" link in NavBar
- [ ] `/admin/users` as operator shows "Forbidden" page
- [ ] Logout button clears session and returns to `/login`
- [ ] `npm run build` produces a `dist/` that serves correctly from nginx
### 15.5 Smoke
- [ ] `scripts/smoke.sh` passes end-to-end against a fresh stack
- [ ] `RUNBOOK.md` exists and contains the §9.6 procedures
- [ ] `scripts/post-deploy.sh` sets up logrotate + healthcheck cron without errors
+62
View File
@@ -12,6 +12,7 @@
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.15",
"@tanstack/react-query": "^5.101.0",
"ansi-styles": "^6.2.3",
"class-variance-authority": "^0.7.0",
@@ -1331,6 +1332,37 @@
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz",
"integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-collection": "1.1.10",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.1.tgz",
@@ -1393,6 +1425,36 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz",
"integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-presence": "1.1.6",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-roving-focus": "1.1.13",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.15",
"@tanstack/react-query": "^5.101.0",
"ansi-styles": "^6.2.3",
"class-variance-authority": "^0.7.0",
+192
View File
@@ -0,0 +1,192 @@
// @vitest-environment happy-dom
// AckDrawer wires `useAckDetail` (TanStack Query) and renders a Radix
// Dialog portal — both need an act-aware, DOM-backed environment or
// React logs warnings and the portal can't mount.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, describe, it, expect, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { ApiError } from "@/lib/api";
import { AckDrawer } from "@/components/AckDrawer";
import type { Ack } from "@/types";
// Mock the hook BEFORE the import above is resolved (vitest hoists
// `vi.mock` to the top of the file regardless of where it appears
// syntactically). Mocking the hook directly — rather than mocking
// `api.getAck` — lets each test pin the hook's exact return shape
// without standing up a real `QueryClient`.
const { useAckDetail } = vi.hoisted(() => ({
useAckDetail: vi.fn(),
}));
vi.mock("@/hooks/useAckDetail", () => ({
useAckDetail,
}));
/**
* Minimal valid `Ack` fixture every required key present so the
* component typechecks. The wire shape extends with `raw_999_text`
* (and `rawJson`), per `useAckDetail`'s `AckDetail` type populated
* when the backend serves it; absent on older rows.
*/
const SAMPLE_ACK: Ack & { raw_999_text: string } = {
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
raw_999_text: "ISA*...*~\nGS*...*~\nST*999*0001~",
};
/**
* Configure the mocked hook's return value for a single test. The
* `refetch` default is a fresh `vi.fn()` tests that need to assert
* on it can override via `overrides.refetch`.
*/
function mockDetail(
overrides: Partial<{
data: (Ack & { raw_999_text?: string }) | null;
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
}> = {}
) {
useAckDetail.mockReturnValue({
data: null,
isLoading: false,
isError: false,
error: null,
refetch: vi.fn(),
...overrides,
});
}
// happy-dom keeps `document.body` between tests; without cleanup,
// `screen.getByText(...)` would find nodes from earlier renders.
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("AckDrawer", () => {
it("test_renders_nothing_when_ackId_is_null", () => {
mockDetail({ data: null });
render(<AckDrawer ackId={null} onClose={() => {}} />);
// No ack content should be in the document when the drawer is
// closed — Radix's Dialog gates the portal on `open`.
expect(document.body.textContent).not.toContain("b-uuid-1");
});
it("test_calls_useAckDetail_with_ackId", () => {
mockDetail({ data: SAMPLE_ACK });
render(<AckDrawer ackId="42" onClose={() => {}} />);
expect(useAckDetail).toHaveBeenCalledWith("42");
});
it("test_renders_ack_summary_on_success", () => {
mockDetail({ data: SAMPLE_ACK });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// Header shows the source batch id as the title.
expect(document.body.textContent).toContain("b-uuid-1");
// Counts (3 accepted, 1 rejected, 4 received) are surfaced as
// StatTile values.
expect(document.body.textContent).toContain("3");
expect(document.body.textContent).toContain("1");
expect(document.body.textContent).toContain("4");
});
it("test_renders_ack_code_pill_with_human_label", () => {
mockDetail({ data: { ...SAMPLE_ACK, ackCode: "P" } });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// The pill renders a human label, not just the bare code letter.
expect(document.body.textContent).toContain("Partially accepted");
});
it("test_renders_skeleton_while_loading", () => {
mockDetail({ isLoading: true });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// The `Skeleton` primitive sets `aria-busy="true"` — a stable
// hook for the loading state.
expect(document.querySelectorAll('[aria-busy="true"]').length).toBeGreaterThan(0);
// And the ack id should NOT have leaked in yet.
expect(document.body.textContent).not.toContain("b-uuid-1");
});
it("test_renders_not_found_error_on_404", () => {
mockDetail({
isError: true,
error: new ApiError(404, "Ack ghost not found"),
});
render(<AckDrawer ackId="9999" onClose={() => {}} />);
const errEl = document.querySelector('[data-testid="ack-drawer-error-not_found"]');
expect(errEl).not.toBeNull();
// Body should mention "doesn't exist" — the not_found COPY key.
expect(errEl?.textContent).toContain("doesn't exist");
// And the retry button should NOT be present (not_found has no
// retry affordance — retrying a 404 won't help).
expect(document.querySelector('[data-testid="error-retry"]')).toBeNull();
});
it("test_renders_network_error_with_retry", () => {
mockDetail({ isError: true, error: new Error("network down") });
render(<AckDrawer ackId="42" onClose={() => {}} />);
const errEl = document.querySelector('[data-testid="ack-drawer-error-network"]');
expect(errEl).not.toBeNull();
expect(errEl?.textContent).toContain("Couldn't reach the server");
// Network variant shows a Retry button.
const retryBtn = document.querySelector(
'[data-testid="error-retry"]'
) as HTMLButtonElement | null;
expect(retryBtn).not.toBeNull();
});
it("test_close_button_calls_onClose", () => {
const onClose = vi.fn<() => void>();
mockDetail({ isError: true, error: new Error("network down") });
render(<AckDrawer ackId="42" onClose={onClose} />);
const closeBtn = document.querySelector(
'[data-testid="error-close"]'
) as HTMLButtonElement | null;
expect(closeBtn).not.toBeNull();
closeBtn!.click();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("test_renders_download_button_when_raw_999_text_present", () => {
mockDetail({ data: SAMPLE_ACK });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// The header action slot populates with the Download 999 button
// only when the ack detail carries raw_999_text.
const dlBtn = document.querySelector('[data-testid="ack-drawer-download"]');
expect(dlBtn).not.toBeNull();
});
it("test_omits_download_button_when_raw_999_text_absent", () => {
const data: Ack = {
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "A",
parsedAt: "2026-06-20T12:00:00Z",
};
mockDetail({ data });
render(<AckDrawer ackId="42" onClose={() => {}} />);
// No raw_999_text → no Download button.
expect(document.querySelector('[data-testid="ack-drawer-download"]')).toBeNull();
});
});
+324
View File
@@ -0,0 +1,324 @@
import { useCallback, useState } from "react";
import { Download } from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { ApiError } from "@/lib/api";
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useAckDetail, type AckDetail } from "@/hooks/useAckDetail";
import { SegmentStatusList } from "./SegmentStatusList";
interface Props {
/**
* Currently-open ack id (string), or `null` when the drawer is
* closed. The URL stores ids as strings so deep links round-trip
* cleanly; the hook does the `Number()` coercion before calling
* `api.getAck`.
*/
ackId: string | null;
/** Fired when the user dismisses the drawer (X button, Escape, etc.). */
onClose: () => void;
}
/**
* Roll a hex code into a "kind" so the drawer's error branch can
* pick the right copy. Mirrors `ProviderDrawer`'s `errorKind`
* computation.
*/
type ErrorKind = "not_found" | "network";
function AckDrawerError({
kind,
onRetry,
onClose,
}: {
kind: ErrorKind;
onRetry?: () => void;
onClose: () => void;
}) {
const COPY = {
not_found: {
eyebrow: "NOT FOUND",
message: "This 999 ACK doesn't exist or has been removed.",
},
network: {
eyebrow: "CONNECTION",
message:
"Couldn't reach the server. Check your connection and try again.",
},
} as const;
const { eyebrow, message } = COPY[kind];
return (
<div
className="flex h-full flex-col"
role="alert"
data-testid={`ack-drawer-error-${kind}`}
>
<DrillDrawerHeader eyebrow="999 ACK" title="—" onClose={onClose} />
<div className="flex flex-1 flex-col items-start gap-4 px-6 py-6">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive">
{eyebrow}
</span>
<p className="text-sm text-muted-foreground max-w-sm">{message}</p>
<div className="flex items-center gap-2">
{kind === "network" && onRetry ? (
<button
type="button"
onClick={onRetry}
data-testid="error-retry"
className="rounded-md border px-2.5 py-1 text-[12.5px] font-medium hover:bg-muted"
>
Retry
</button>
) : null}
<button
type="button"
onClick={onClose}
data-testid="error-close"
className="rounded-md border px-2.5 py-1 text-[12.5px] font-medium hover:bg-muted"
>
Close
</button>
</div>
</div>
</div>
);
}
/**
* Inline ack code pill same color palette as `AcksPage`
* (`Acks.tsx`) so the badge reads the same in both surfaces.
*/
function AckCodePill({ code }: { code: AckDetail["ackCode"] }) {
const cfg =
code === "A"
? {
fg: "hsl(152 64% 30%)",
bg: "hsl(152 50% 88%)",
border: "hsl(152 64% 38% / 0.30)",
label: "Accepted",
}
: code === "R"
? {
fg: "hsl(358 70% 36%)",
bg: "hsl(358 70% 92%)",
border: "hsl(358 70% 50% / 0.30)",
label: "Rejected",
}
: code === "P"
? {
fg: "hsl(36 92% 30%)",
bg: "hsl(36 82% 88%)",
border: "hsl(36 92% 50% / 0.30)",
label: "Partially accepted",
}
: {
fg: "hsl(36 92% 30%)",
bg: "hsl(36 82% 88%)",
border: "hsl(36 92% 50% / 0.30)",
label: "Accepted w/ errors",
};
return (
<span
className="inline-flex items-center rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
style={{ color: cfg.fg, backgroundColor: cfg.bg, borderColor: cfg.border }}
>
{cfg.label}
</span>
);
}
function StatTile({
label,
value,
tone,
}: {
label: string;
value: number | string;
tone: "success" | "destructive" | "ink";
}) {
const color =
tone === "success"
? "hsl(152 64% 30%)"
: tone === "destructive"
? "hsl(358 70% 36%)"
: "hsl(var(--foreground))";
return (
<div
className="rounded-lg border px-3.5 py-3"
style={{
backgroundColor: "hsl(var(--card) / 0.4)",
borderColor: "hsl(var(--border) / 0.5)",
}}
data-testid="ack-stat"
>
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{label}
</div>
<div
className="display mono tabular-nums mt-1"
style={{ color, fontSize: 22, lineHeight: 1.1 }}
>
{value}
</div>
</div>
);
}
/**
* 999 ACK drill-down drawer (SP21 Phase 5 Task 5.2).
*
* Mirror of `ProviderDrawer` same right-anchored side-panel shell,
* same `errorKind` + `useAckDetail` shape (404 vs network), same
* skeleton-first loading state. The body is slim: rolled-up counts
* at the top, the ack code pill, the source batch id, and a
* per-segment status list. The header carries a "Download 999"
* action so the user can grab the original X12 file from the drawer
* without a second round-trip to `/api/acks/{id}/raw`.
*
* Layout mirrors `ProviderDrawer`/`ClaimDrawer`: Radix Dialog
* repositioned to the right edge as a fixed-height side panel, with
* the shared `DrillDrawerHeader` on top and a scrollable body below.
*
* Error branching:
* - `ApiError(404)` "not_found" (no retry, the ack is gone)
* - anything else "network" (retry available)
*/
export function AckDrawer({ ackId, onClose }: Props) {
const { data, isLoading, isError, error, refetch } = useAckDetail(ackId);
const errorKind: ErrorKind | null = isError
? error instanceof ApiError && error.status === 404
? "not_found"
: "network"
: null;
// Download affordance for the regenerated 999 X12 text. Lives in
// the drawer (not the page) so a deep-linked user can grab the file
// without bouncing back to /acks. `raw_999_text` may be empty for
// older rows without the field — we silently no-op rather than
// surfacing an error toast (the spec calls this a "low stakes"
// affordance).
const [downloading, setDownloading] = useState(false);
const onDownload = useCallback(async () => {
if (!data) return;
const raw =
(data as AckDetail & { raw_999_text?: string }).raw_999_text ?? "";
if (!raw || downloading) return;
setDownloading(true);
try {
const blob = new Blob([raw], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `ack-${data.sourceBatchId}.999`;
a.click();
URL.revokeObjectURL(url);
} finally {
setDownloading(false);
}
}, [data, downloading]);
const downloadAction =
data && (data as AckDetail & { raw_999_text?: string }).raw_999_text ? (
<button
type="button"
onClick={onDownload}
disabled={downloading}
aria-label="Download 999 file"
title="Download 999 file"
data-testid="ack-drawer-download"
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-medium uppercase tracking-[0.14em] mono transition-colors",
downloading && "opacity-50 cursor-not-allowed",
)}
style={{
borderColor: "hsl(var(--border) / 0.7)",
color: "hsl(var(--foreground))",
backgroundColor: "hsl(var(--card) / 0.5)",
}}
>
<Download className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
999
</button>
) : null;
return (
<Dialog open={ackId !== null} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent
className="fixed right-0 top-0 flex h-full w-full max-w-2xl flex-col translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
aria-describedby={undefined}
data-testid="ack-drawer"
>
{errorKind ? (
<AckDrawerError
kind={errorKind}
onRetry={() => {
void refetch();
}}
onClose={onClose}
/>
) : isLoading || !data ? (
<div className="flex h-full flex-col overflow-y-auto">
<DrillDrawerHeader
eyebrow="999 ACK"
title="Loading…"
onClose={onClose}
/>
<div className="space-y-2 p-6">
<Skeleton variant="row" />
<Skeleton variant="row" />
<Skeleton variant="row" />
</div>
</div>
) : (
<div
className="flex h-full flex-col overflow-y-auto"
data-testid="ack-drawer-content"
>
<DrillDrawerHeader
eyebrow="999 ACK"
title={data.sourceBatchId}
onClose={onClose}
action={downloadAction}
/>
<div className="flex flex-col divide-y divide-border/40">
<section
className="flex flex-col gap-4 px-6 py-4"
data-testid="ack-summary"
>
<div className="flex items-center gap-3 flex-wrap">
<AckCodePill code={data.ackCode} />
<span className="mono text-[12px] text-muted-foreground">
ID {data.id}
</span>
<span className="mono text-[12px] text-muted-foreground">
· {data.parsedAt ? data.parsedAt.slice(0, 10) : "—"}
</span>
</div>
<div className="grid grid-cols-3 gap-3">
<StatTile
label="Accepted"
value={data.acceptedCount}
tone="success"
/>
<StatTile
label="Rejected"
value={data.rejectedCount}
tone="destructive"
/>
<StatTile
label="Received"
value={data.receivedCount}
tone="ink"
/>
</div>
</section>
<SegmentStatusList segments={[]} />
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,150 @@
import { CheckCircle2, AlertTriangle, Minus } from "lucide-react";
/**
* One 999 ACK segment status row. The 999 spec labels each transaction
* set with a 3-char code:
*
* "A" = Accepted
* "E" = Accepted, but errors are noted (one or more segments had
* errors; the transaction set as a whole was accepted)
* "R" = Rejected
* "P" = Partially accepted (mixed some segments accepted, some
* not; a degraded success the operator must investigate)
*
* The wire shape's `rawJson` (set by the parser, see backend
* `cyclone.parsers.parsers_999`) carries an array of segment rows.
* Until that array is parsed into a stable UI shape, we render the
* rolled-up counts on the ack row (`acceptedCount` / `rejectedCount`)
* and a placeholder explaining how to read it.
*/
interface SegmentRow {
/** Loop / segment reference like "ST*999*0001" or "AK2*HC*0001". */
reference?: string;
/** "A" | "E" | "R" | "P". */
status: "A" | "E" | "R" | "P";
/** Free-form note from the parser (e.g. "SVC*HC:9450 missing"). */
note?: string;
}
interface Props {
segments: SegmentRow[];
}
/**
* Pill for one segment status. Color mirrors the badge palette used on
* the Acks page (`Acks.tsx`) so a status reads the same in both
* surfaces.
*/
function StatusPill({ status }: { status: SegmentRow["status"] }) {
const cfg = {
A: {
fg: "hsl(152 64% 30%)",
bg: "hsl(152 50% 88%)",
border: "hsl(152 64% 38% / 0.30)",
label: "Accepted",
Icon: CheckCircle2,
},
E: {
fg: "hsl(36 92% 30%)",
bg: "hsl(36 82% 88%)",
border: "hsl(36 92% 50% / 0.30)",
label: "Accepted w/ errors",
Icon: AlertTriangle,
},
R: {
fg: "hsl(358 70% 36%)",
bg: "hsl(358 70% 92%)",
border: "hsl(358 70% 50% / 0.30)",
label: "Rejected",
Icon: AlertTriangle,
},
P: {
fg: "hsl(36 92% 30%)",
bg: "hsl(36 82% 88%)",
border: "hsl(36 92% 50% / 0.30)",
label: "Partially accepted",
Icon: Minus,
},
}[status];
const Icon = cfg.Icon;
return (
<span
className="inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
style={{ color: cfg.fg, backgroundColor: cfg.bg, borderColor: cfg.border }}
>
<Icon className="h-3 w-3" strokeWidth={1.75} aria-hidden />
{cfg.label}
</span>
);
}
/**
* Per-segment status list inside the AckDrawer body (SP21 Phase 5
* Task 5.2). Renders one row per parsed 999 segment, each with the
* segment reference (when present), the parsed status code, and the
* parser's note (when present). An empty list renders a small
* explanatory note so the drawer doesn't look broken on old acks
* without the per-segment slice.
*/
export function SegmentStatusList({ segments }: Props) {
return (
<section
className="flex flex-col gap-3 px-6 py-4"
data-testid="ack-segment-list"
>
<div className="flex items-baseline justify-between gap-3">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Segment status
</span>
<span
className="mono text-[10.5px] text-muted-foreground"
data-testid="ack-segment-count"
>
{segments.length} segment{segments.length === 1 ? "" : "s"}
</span>
</div>
{segments.length === 0 ? (
<p
className="text-[12.5px] text-muted-foreground"
data-testid="ack-segment-empty"
>
No per-segment breakdown for this ack the rolled-up accepted
/ rejected counts above are the only signal.
</p>
) : (
<ul className="flex flex-col divide-y divide-border/40 border-y border-border/30">
{segments.map((seg, idx) => (
<li
key={`${seg.reference ?? idx}`}
className="flex items-start gap-3 py-2.5"
data-testid="ack-segment-row"
>
<span
className="mono text-[12px] tabular-nums text-muted-foreground shrink-0"
style={{ minWidth: 32 }}
>
{idx + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<StatusPill status={seg.status} />
{seg.reference ? (
<span className="mono text-[12px] truncate">
{seg.reference}
</span>
) : null}
</div>
{seg.note ? (
<p className="text-[12.5px] text-muted-foreground mt-1 leading-snug">
{seg.note}
</p>
) : null}
</div>
</li>
))}
</ul>
)}
</section>
);
}
+4
View File
@@ -0,0 +1,4 @@
// Barrel export for the AckDrawer module (SP21 Phase 5 Task 5.2).
export { AckDrawer } from "./AckDrawer";
export { SegmentStatusList } from "./SegmentStatusList";
+22 -5
View File
@@ -5,6 +5,7 @@ import {
Plus,
type LucideIcon,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -15,6 +16,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { DrillableCell } from "@/components/drill/DrillableCell";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type {
@@ -263,13 +265,28 @@ function RowIndicator({
// ---------------------------------------------------------------------------
function ClaimIdCell({ id }: { id: string }) {
// SP21 Phase 5 Task 5.6: each claim id is drillable to
// /claims?claim=ID so the operator can jump straight from a
// "Removed from A" or "Changed" row into the ClaimDrawer.
// DrillableCell handles e.stopPropagation internally — important
// if these rows ever get a row-level onClick. For removed claims
// that no longer exist in the DB, the ClaimDrawer's 404 state
// takes over (verified in Phase 2 testing).
const navigate = useNavigate();
return (
<span
className="font-mono text-[12px] tracking-tight"
data-testid="diff-claim-id"
<DrillableCell
onClick={() =>
navigate(`/claims?claim=${encodeURIComponent(id)}`)
}
ariaLabel={`View claim ${id} in detail`}
>
{id}
</span>
<span
className="font-mono text-[12px] tracking-tight"
data-testid="diff-claim-id"
>
{id}
</span>
</DrillableCell>
);
}
+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>
);
}
@@ -9,6 +9,7 @@ import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ClaimDrawer } from "./ClaimDrawer";
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
import { api, ApiError } from "@/lib/api";
import type { ClaimDetail } from "@/types";
@@ -162,14 +163,22 @@ function renderDrawer(
React.createElement(
QueryClientProvider,
{ client: qc },
React.createElement(ClaimDrawer, {
claimId: props.claimId,
claims,
onClose,
onNavigate,
onToggleHelp,
})
)
// SP21 Phase 5 Task 5.8: PartiesGrid (mounted by ClaimDrawer)
// calls useDrillStack(). Wrap the drawer in DrillStackProvider
// so the hook has a context. (The provider is also mounted at
// the App root in production.)
React.createElement(
DrillStackProvider,
null,
React.createElement(ClaimDrawer, {
claimId: props.claimId,
claims,
onClose,
onNavigate,
onToggleHelp,
}),
),
),
);
});
+14 -12
View File
@@ -141,12 +141,14 @@ export function ClaimDrawer({
// Right-anchored side panel. We override the Dialog primitive's
// default centered positioning (`left-1/2 top-1/2 -translate-x-1/2
// -translate-y-1/2`) by re-asserting `right-0 top-0 translate-x-0
// translate-y-0`. `h-full` + `rounded-none` + the left border
// translate-y-0`. `h-full` + `rounded-none` + the left hairline
// give it the visual identity of a drawer rather than a modal.
// `bg-card` matches the rest of the dark instrument chrome; the
// outer shadow is a soft directional falloff to the left.
// `aria-describedby={undefined}` suppresses the Radix warning
// about a missing description — the drawer content is its own
// description and there's nothing useful to point at.
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)]/60 bg-[color:var(--m-surface)] p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border/60 bg-card p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
data-testid="claim-drawer"
aria-describedby={undefined}
>
@@ -167,7 +169,7 @@ export function ClaimDrawer({
>
<ClaimDrawerHeader claim={data} onClose={onClose} />
<div
className="flex gap-0 px-6 border-b border-[color:var(--surface-line)]/40"
className="flex gap-0 px-6 border-b border-border/40"
data-testid="claim-drawer-tabs"
>
<button
@@ -176,15 +178,15 @@ export function ClaimDrawer({
className={cn(
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
activeTab === "details"
? "text-[color:var(--surface-ink)]"
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
data-testid="tab-button-details"
data-active={activeTab === "details" ? "true" : "false"}
>
Details
{activeTab === "details" ? (
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
) : null}
</button>
<button
@@ -193,29 +195,29 @@ export function ClaimDrawer({
className={cn(
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
activeTab === "line-reconciliation"
? "text-[color:var(--surface-ink)]"
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
data-testid="tab-button-line-reconciliation"
data-active={activeTab === "line-reconciliation" ? "true" : "false"}
>
Line Reconciliation
{activeTab === "line-reconciliation" ? (
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
) : null}
</button>
</div>
{activeTab === "line-reconciliation" ? (
lr.loading ? (
<p
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
className="px-6 py-4 text-sm text-muted-foreground"
data-testid="line-reconciliation-loading"
>
Loading line reconciliation
</p>
) : lr.error ? (
<p
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
className="px-6 py-4 text-sm text-muted-foreground"
data-testid="line-reconciliation-error"
>
Failed to load line reconciliation.
@@ -224,7 +226,7 @@ export function ClaimDrawer({
<LineReconciliationTab data={lr.data} />
) : null
) : (
<div className="flex flex-col divide-y divide-[color:var(--surface-line)]/12">
<div className="flex flex-col divide-y divide-border/40">
<ValidationPanel validation={data.validation} />
<ServiceLinesTable
serviceLines={data.serviceLines}
@@ -32,21 +32,21 @@ export function ClaimDrawerError({ kind, onRetry, onClose }: ClaimDrawerErrorPro
return (
<div
className="flex flex-col items-start gap-4 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
className="flex flex-col items-start gap-4 p-6 bg-card text-foreground"
role="alert"
data-testid={`claim-drawer-error-${kind}`}
>
<div className="flex items-center gap-2">
<Icon
className="h-5 w-5 text-[color:var(--m-error)]"
className="h-5 w-5 text-destructive"
strokeWidth={1.75}
aria-hidden
/>
<span className="eyebrow text-[color:var(--m-error)]">
<span className="eyebrow text-destructive">
{eyebrow}
</span>
</div>
<p className="text-sm text-[color:var(--m-ink-secondary)] max-w-sm">{message}</p>
<p className="text-sm text-muted-foreground max-w-sm">{message}</p>
<div className="flex items-center gap-2">
{kind === "network" && onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
@@ -131,14 +131,17 @@ describe("ClaimDrawerHeader", () => {
it("test_renders_claim_id_label_and_value", () => {
const { container, unmount } = renderHeader({ id: "CLM-42" });
// The eyebrow label "Claim" is rendered as uppercase text.
// The eyebrow label "Claim" is rendered as uppercase text by
// DrillDrawerHeader.
const text = (container.textContent ?? "").toLowerCase();
expect(text).toContain("claim");
// The claim id sits in a node tagged with data-testid="header-id".
const idEl = container.querySelector('[data-testid="header-id"]');
expect(idEl).not.toBeNull();
expect(idEl?.textContent).toBe("CLM-42");
// SP21 Phase 5 Task 5.10: the claim id is now the title passed
// to DrillDrawerHeader, which renders it inside an <h2>. Find
// the h2 and assert its text matches the claim id.
const titleEl = container.querySelector("h2");
expect(titleEl).not.toBeNull();
expect(titleEl?.textContent).toBe("CLM-42");
unmount();
});
@@ -212,8 +215,11 @@ describe("ClaimDrawerHeader", () => {
const onClose = vi.fn();
const { container, unmount } = renderHeader({}, onClose);
// SP21 Phase 5 Task 5.10: the close button is now rendered by
// DrillDrawerHeader (no `data-testid`); find it via its
// accessible name instead.
const closeBtn = container.querySelector(
'[data-testid="header-close"]'
'button[aria-label="Close drawer"]'
) as HTMLButtonElement | null;
expect(closeBtn).not.toBeNull();
expect(onClose).not.toHaveBeenCalled();
@@ -226,18 +232,25 @@ describe("ClaimDrawerHeader", () => {
unmount();
});
it("test_uses_modern_palette_surface", () => {
// The drawer header anchors itself on the light surface palette
// token (matches ClaimDrawerSkeleton / ClaimDrawerError). The root
// <header> element is tagged with data-testid="claim-drawer-header"
// so we can sniff its className without coupling to the badge
// or close-button wrappers.
const { container, unmount } = renderHeader({});
it("test_uses_shared_drilldrawerheader_shell", () => {
// SP21 Phase 5 Task 5.10: the header is now a thin wrapper
// around DrillDrawerHeader — verify the wrapper is present and
// that the underlying shell is the shared one (an h2 with the
// expected Tailwind treatment). The "Claim" eyebrow + claim id
// title prove the shell rendered.
const { container, unmount } = renderHeader({ id: "CLM-42" });
const root = container.querySelector('[data-testid="claim-drawer-header"]');
expect(root).not.toBeNull();
const cls = root?.className ?? "";
expect(cls).toContain("bg-[color:var(--m-surface)]");
// The h2 is DrillDrawerHeader's title slot.
const titleEl = root?.querySelector("h2");
expect(titleEl).not.toBeNull();
expect(titleEl?.textContent).toBe("CLM-42");
// The className pattern DrillDrawerHeader uses for the title.
const cls = titleEl?.className ?? "";
expect(cls).toContain("text-[18px]");
expect(cls).toContain("font-semibold");
unmount();
});
@@ -1,11 +1,11 @@
import { useState } from "react";
import { Download, X } from "lucide-react";
import { Download } from "lucide-react";
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
import { api } from "@/lib/api";
import { downloadTextFile } from "@/lib/download";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ClaimDetail } from "@/types";
type ClaimDrawerHeaderProps = {
@@ -47,12 +47,19 @@ function badgeVariantFor(state: string): BadgeProps["variant"] {
}
/**
* Header band for the claim detail drawer (SP4).
* Header band for the claim detail drawer (SP4 refactored SP21
* Phase 5 Task 5.10).
*
* Top-left: instrument-style "Claim" eyebrow + large mono ID.
* Top-right: state badge + total billed amount + close button. The
* badge color encodes state so the user can read the drawer's status
* at a glance without scrolling.
* The shell is now the shared `DrillDrawerHeader` (same as
* `ProviderDrawer` / `AckDrawer`) eyebrow + title on the left,
* close button on the right. The right-side `action` slot carries
* the state badge, total billed amount, and the "Download 837"
* button, all of which used to live in a custom <header> block.
*
* Top-left: instrument-style "Claim" eyebrow + the claim ID.
* Top-right: state badge + total billed amount + download button.
* The badge color encodes state so the user can read the drawer's
* status at a glance without scrolling.
*/
export function ClaimDrawerHeader({
claim,
@@ -80,66 +87,48 @@ export function ClaimDrawerHeader({
}
}
return (
<header
className={cn(
"flex items-start justify-between gap-4 px-6 py-5",
"border-b border-[color:var(--m-border-heavy)]/40",
"bg-[color:var(--m-surface)]"
)}
data-testid="claim-drawer-header"
>
{/* Left: eyebrow + mono claim ID */}
<div className="flex flex-col gap-1 min-w-0">
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
Claim
</span>
// The action slot is rendered by DrillDrawerHeader to the left of
// the close button. Group the three action pieces (badge, amount,
// download) in a single flex row so they read as a unit.
const action = (
<div className="flex items-center gap-2" data-testid="claim-header-actions">
<div className="flex items-center gap-2">
<Badge
variant={badgeVariantFor(claim.state)}
data-testid="header-state"
className="uppercase tracking-[0.14em]"
>
{claim.stateLabel}
</Badge>
<span
data-testid="header-id"
className="mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
data-testid="header-amount"
className="mono text-sm tabular-nums text-muted-foreground"
>
{claim.id}
{fmt.usdPrecise(claim.billedAmount)}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleDownload}
disabled={downloading}
aria-label={downloading ? "Downloading 837 file" : "Download 837 file"}
title="Download 837 file"
data-testid="header-download-837"
>
<Download className="h-4 w-4" strokeWidth={1.75} />
</Button>
</div>
);
{/* Right: state badge + total amount + download + close */}
<div className="flex items-start gap-2">
<div className="flex flex-col items-end gap-1">
<Badge
variant={badgeVariantFor(claim.state)}
data-testid="header-state"
className="uppercase tracking-[0.14em]"
>
{claim.stateLabel}
</Badge>
<span
data-testid="header-amount"
className="mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
>
{fmt.usdPrecise(claim.billedAmount)}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleDownload}
disabled={downloading}
aria-label={downloading ? "Downloading 837 file" : "Download 837 file"}
title="Download 837 file"
data-testid="header-download-837"
>
<Download className="h-4 w-4" strokeWidth={1.75} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close drawer"
data-testid="header-close"
>
<X className="h-4 w-4" strokeWidth={1.75} />
</Button>
</div>
return (
<header data-testid="claim-drawer-header">
<DrillDrawerHeader
eyebrow="Claim"
title={claim.id}
onClose={onClose}
action={action}
/>
</header>
);
}
@@ -10,7 +10,7 @@ import { Skeleton } from "@/components/ui/skeleton";
export function ClaimDrawerSkeleton() {
return (
<div
className="flex flex-col gap-6 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
className="flex flex-col gap-6 p-6 bg-card text-foreground"
data-testid="claim-drawer-skeleton"
aria-busy="true"
>
+5 -5
View File
@@ -35,7 +35,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
<section className="flex flex-col gap-3 px-6 py-4">
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Diagnoses ({diagnoses.length})
</h3>
@@ -43,7 +43,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
{diagnoses.length === 0 ? (
<p
data-testid="diagnoses-empty"
className="text-sm text-[color:var(--m-ink-tertiary)]"
className="text-sm text-muted-foreground"
>
No diagnoses
</p>
@@ -58,17 +58,17 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
className="flex items-baseline gap-3 text-sm"
>
<span
className="mono text-[color:var(--m-ink-primary)] font-medium"
className="mono text-foreground font-medium"
>
{d.qualifier ? `${d.qualifier} ${d.code}` : d.code}
</span>
{desc ? (
<>
<span
className="h-px w-3 bg-[color:var(--m-border-heavy)]/30"
className="h-px w-3 bg-border/60"
aria-hidden
/>
<span className="text-[color:var(--m-ink-secondary)] italic">
<span className="text-muted-foreground italic">
{desc}
</span>
</>
@@ -31,26 +31,26 @@ export function LineReconciliationTab({
>
<div className="flex gap-5 font-mono tabular-nums text-sm">
<span data-testid="billed-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
<span className="text-muted-foreground eyebrow">
Billed
</span>
<span className="text-[color:var(--m-ink-primary)] font-semibold">
<span className="text-foreground font-semibold">
{fmt.usdPrecise(Number(summary.billedTotal))}
</span>
</span>
<span data-testid="paid-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
<span className="text-muted-foreground eyebrow">
Paid
</span>
<span className="text-[color:var(--m-ink-primary)] font-semibold">
<span className="text-foreground font-semibold">
{fmt.usdPrecise(Number(summary.paidTotal))}
</span>
</span>
<span data-testid="adjustments-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
<span className="text-muted-foreground eyebrow">
Adjustments
</span>
<span className="text-[color:var(--m-ink-primary)] font-semibold">
<span className="text-foreground font-semibold">
{fmt.usdPrecise(Number(summary.adjustmentTotal))}
</span>
</span>
@@ -70,7 +70,7 @@ export function LineReconciliationTab({
<div className="grid grid-cols-2 gap-4">
<div data-testid="left-column">
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
<h3 className="eyebrow text-muted-foreground mb-2">
Billed (837 SV1)
</h3>
{leftRows.map((row) => (
@@ -78,7 +78,7 @@ export function LineReconciliationTab({
))}
</div>
<div data-testid="right-column">
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
<h3 className="eyebrow text-muted-foreground mb-2">
Adjudicated (835 SVC)
</h3>
{rightRows.map((row) => (
@@ -114,7 +114,7 @@ function LineCard({
return (
<div
className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-[color:var(--m-ink-tertiary)]/5"
className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-muted/40"
style={{
borderLeft: `3px solid ${accentColor}`,
background: "rgba(255,255,255,0.02)",
@@ -123,9 +123,9 @@ function LineCard({
>
<div className="flex items-baseline justify-between">
<div className="font-mono text-sm">
<span className="font-semibold text-[color:var(--m-ink-primary)]">{procCode}</span>
<span className="font-semibold text-foreground">{procCode}</span>
{lineNumber !== null ? (
<span className="text-[color:var(--m-ink-tertiary)] ml-2">
<span className="text-muted-foreground ml-2">
#{lineNumber}
</span>
) : null}
@@ -146,16 +146,16 @@ function LineCard({
</span>
) : null}
</div>
<div className="font-mono tabular-nums text-xs text-[color:var(--m-ink-primary)] font-semibold">
<div className="font-mono tabular-nums text-xs text-foreground font-semibold">
{side === "left" && cl ? <span>{fmt.usdPrecise(Number(cl.charge))}</span> : null}
{side === "right" && svc ? <span>{fmt.usdPrecise(Number(svc.payment))}</span> : null}
</div>
</div>
{row.adjustments.length > 0 ? (
<ul className="text-xs font-mono text-[color:var(--m-ink-tertiary)] mt-1 space-y-0.5">
<ul className="text-xs font-mono text-muted-foreground mt-1 space-y-0.5">
{row.adjustments.map((a, i) => (
<li key={i}>
<span className="text-[color:var(--m-ink-secondary)]">{a.groupCode}-{a.reasonCode}</span>
<span className="text-foreground/80">{a.groupCode}-{a.reasonCode}</span>
<span className="ml-2 tabular-nums">{fmt.usdPrecise(Number(a.amount))}</span>
</li>
))}
@@ -56,20 +56,20 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
>
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Matched Remittance
</h3>
<div
data-testid="matched-remit-card-inner"
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 p-4 sm:flex-row sm:items-start sm:justify-between"
className="flex flex-col gap-4 rounded-lg border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between"
>
{/* Left: remit id + status badge + received date */}
<div className="flex min-w-0 flex-col gap-2">
<span
data-testid="matched-remit-id"
className="mono text-sm text-[color:var(--m-ink-primary)]"
className="mono text-sm text-foreground"
>
{id}
</span>
@@ -83,7 +83,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
</Badge>
<span
data-testid="matched-remit-received"
className="text-xs text-[color:var(--m-ink-tertiary)]"
className="text-xs text-muted-foreground"
>
Received {fmt.date(receivedAt)}
</span>
@@ -94,7 +94,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
<div className="flex flex-col items-start gap-2 sm:items-end">
<span
data-testid="matched-remit-total-paid"
className="display text-3xl text-[color:var(--m-ink-primary)] tabular-nums"
className="display text-3xl text-foreground tabular-nums"
>
{fmt.usdPrecise(totalPaid)}
</span>
@@ -103,7 +103,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
size="sm"
onClick={handleViewRemittance}
data-testid="view-remittance-link"
className="gap-1 text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)]"
className="gap-1 text-muted-foreground hover:text-foreground"
>
View remittance
<ArrowRight
+123 -2
View File
@@ -6,13 +6,23 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { PartiesGrid } from "./PartiesGrid";
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
import type {
ClaimDetailAddress,
ClaimDetailParties,
} from "@/types";
// Mock the api module so PayerPeekContent's usePayerSummary call
// doesn't make a real network request. The spy is set per-test in
// beforeEach.
vi.mock("@/hooks/usePayerSummary", () => ({
usePayerSummary: vi.fn(),
}));
import { usePayerSummary } from "@/hooks/usePayerSummary";
function renderIntoContainer(element: React.ReactElement): {
container: HTMLDivElement;
unmount: () => void;
@@ -20,8 +30,11 @@ function renderIntoContainer(element: React.ReactElement): {
const container = document.createElement("div");
document.body.appendChild(container);
const root: Root = createRoot(container);
// SP21 Phase 5 Task 5.8: PartiesGrid now uses useDrillStack() to
// open payer peeks. Wrap every render in a DrillStackProvider so
// the hook has a context to read from.
act(() => {
root.render(element);
root.render(<DrillStackProvider>{element}</DrillStackProvider>);
});
return {
container,
@@ -279,4 +292,112 @@ describe("PartiesGrid", () => {
unmount();
});
it("SP21 Task 5.8: payer name is drillable (renders as a button)", () => {
// The payer card's name now wraps in a button with the
// `drillable` affordance + `data-testid="party-payer-name-drill"`.
// The other two cards (billing-provider, subscriber) keep the
// name as a plain div.
const { container, unmount } = renderIntoContainer(
<PartiesGrid parties={makeParties()} />
);
const payerNameBtn = container.querySelector(
'[data-testid="party-payer-name-drill"]',
);
expect(payerNameBtn).not.toBeNull();
expect(payerNameBtn?.textContent).toContain("Aetna");
// The billing-provider and subscriber names stay plain divs.
expect(
container.querySelector(
'[data-testid="party-billing-provider-name-drill"]',
),
).toBeNull();
expect(
container.querySelector(
'[data-testid="party-subscriber-name-drill"]',
),
).toBeNull();
unmount();
});
it("SP21 Task 5.8: clicking the payer name opens the PeekModal", async () => {
// Mock the payer summary fetch so PayerPeekContent resolves
// without a real network call. The peek modal renders
// regardless of fetch state (loading skeleton → content).
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: null,
isLoading: true,
});
const { container, unmount } = renderIntoContainer(
<PartiesGrid parties={makeParties()} />
);
const payerNameBtn = container.querySelector(
'[data-testid="party-payer-name-drill"]',
) as HTMLButtonElement | null;
expect(payerNameBtn).not.toBeNull();
await act(async () => {
payerNameBtn!.click();
await Promise.resolve();
});
// The PeekModal is a Radix Dialog that portals into document.body.
// Look for the [role="dialog"] (modal) with our eyebrow "Payer".
const dialogs = document.body.querySelectorAll('[role="dialog"]');
// One dialog is the modal itself (the peek). PartiesGrid doesn't
// open a drawer, so we expect exactly one.
expect(dialogs.length).toBeGreaterThanOrEqual(1);
const peekDialog = Array.from(dialogs).find((d) =>
d.textContent?.includes("Payer"),
);
expect(peekDialog).not.toBeNull();
expect(peekDialog?.textContent).toContain("Aetna");
unmount();
});
it("SP21 Task 5.8: peek uses the X12 payer id, not the human name", () => {
// The PayerPeekContent is given the X12 payer id from
// `payer.id`. Verify the click flow passes the right id by
// checking usePayerSummary was called with "PAYER01".
const mockUsePayerSummary = vi.fn().mockReturnValue({
data: null,
isLoading: true,
});
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockImplementation(
mockUsePayerSummary,
);
const { container, unmount } = renderIntoContainer(
<PartiesGrid parties={makeParties()} />
);
// No peek yet → no usePayerSummary call.
expect(mockUsePayerSummary).not.toHaveBeenCalled();
const payerNameBtn = container.querySelector(
'[data-testid="party-payer-name-drill"]',
) as HTMLButtonElement | null;
act(() => {
payerNameBtn!.click();
});
// After the click, PayerPeekContent mounts and calls
// usePayerSummary("PAYER01") — NOT the human name "Aetna".
expect(mockUsePayerSummary).toHaveBeenCalledWith("PAYER01");
unmount();
});
});
beforeEach(() => {
// Reset the mock between tests so per-test implementations stick.
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: null,
isLoading: true,
});
});
+55 -8
View File
@@ -1,4 +1,7 @@
import type { ClaimDetail, ClaimDetailAddress } from "@/types";
import { useDrillStack } from "@/components/drill/DrillStackProvider";
import { PeekModal } from "@/components/drill/PeekModal";
import { PayerPeekContent } from "@/components/drill/PayerPeekContent";
type PartiesGridProps = {
parties: ClaimDetail["parties"];
@@ -25,7 +28,7 @@ function AddressBlock({ address }: { address: AddressLike }) {
const a = address as ClaimDetailAddress;
return (
<div
className="mt-1 border-t border-dashed border-[color:var(--m-border-heavy)]/30 pt-2 text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
className="mt-1 border-t border-dashed border-border/60 pt-2 text-xs leading-relaxed text-muted-foreground"
data-testid="party-address"
>
<div>{a.line1}</div>
@@ -48,26 +51,40 @@ function PartyCard({
name,
identity,
address,
onNameClick,
}: {
testId: string;
label: string;
name: string;
identity: React.ReactNode;
address?: AddressLike;
onNameClick?: () => void;
}) {
return (
<div
data-testid={testId}
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 px-4 py-3 transition-colors hover:border-[color:var(--m-border-heavy)]/50"
className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/30 px-4 py-3 transition-colors hover:border-border hover:bg-muted/40"
>
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-muted-foreground">
{label}
</span>
<div className="display text-lg text-[color:var(--m-ink-primary)]">
{name}
</div>
{onNameClick ? (
<button
type="button"
onClick={onNameClick}
data-testid={`${testId}-name-drill`}
aria-label={`Drill into ${label.toLowerCase()} ${name}`}
className="display text-lg text-foreground text-left cursor-pointer drillable rounded-sm px-0 -mx-0"
>
{name}
</button>
) : (
<div className="display text-lg text-foreground">
{name}
</div>
)}
<div
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
className="mono text-[12.5px] text-muted-foreground tabular-nums"
>
{identity}
</div>
@@ -88,6 +105,13 @@ function PartyCard({
*/
export function PartiesGrid({ parties }: PartiesGridProps) {
const { billingProvider, subscriber, payer } = parties;
// SP21 Phase 5 Task 5.8: payer name opens a PeekModal on top of
// the drawer — the peek stack (DrillStackProvider) holds at most
// one peek at a time, so opening payer-peek replaces any earlier
// peek. The X12 payer id (`payer.id`) is what the peek endpoint
// expects, NOT the human name.
const { stack, openPeek, closeTop } = useDrillStack();
const topPeek = stack[stack.length - 1];
return (
<section
@@ -96,7 +120,7 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
>
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Parties
</h3>
@@ -142,8 +166,31 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
<div>ID {payer.id}</div>
</>
}
// SP21 Phase 5 Task 5.8: payer name is drillable. Clicking
// pushes a payer peek onto the drill stack; the PeekModal
// at the bottom of this section renders when the top of
// the stack is "payer". The payer id used here is the X12
// payer_id (e.g. "SKCO0") — verified in ClaimDetailPayer
// type — which is what usePayerSummary expects.
onNameClick={() => openPeek({ kind: "payer", payerId: payer.id })}
/>
</div>
{/* SP21 Phase 5 Task 5.8: payer peek. Mounted at the bottom
of PartiesGrid so the peek sits on top of the drawer (Radix
Dialog portals). Closing the peek pops the stack. Only one
peek renders at a time (the stack caps at 1) when the
user opens a payer peek, any earlier peek is replaced. */}
{topPeek?.kind === "payer" ? (
<PeekModal
open
onClose={closeTop}
eyebrow="Payer"
title={payer.name}
>
<PayerPeekContent payerId={topPeek.payerId} />
</PeekModal>
) : null}
</section>
);
}
@@ -33,17 +33,17 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
>
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Raw Segments ({rawSegments.length})
</h3>
<details className="group rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/40 overflow-hidden transition-colors hover:border-[color:var(--m-border-heavy)]/50">
<details className="group rounded-lg border border-border/60 bg-muted/30 overflow-hidden transition-colors hover:border-border">
<summary
className="cursor-pointer select-none px-4 py-2.5 text-sm text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)] transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2"
className="cursor-pointer select-none px-4 py-2.5 text-sm text-muted-foreground hover:text-foreground transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2"
data-testid="raw-segments-summary"
>
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[color:var(--m-ink-tertiary)] group-open:bg-[color:var(--m-accent)] transition-colors" />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground group-open:bg-accent transition-colors" />
{rawSegments.length === 0
? `Show raw segments`
: `Show ${rawSegments.length} raw segment${rawSegments.length === 1 ? "" : "s"}`}
@@ -52,14 +52,14 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
{rawSegments.length === 0 ? (
<p
data-testid="raw-segments-empty"
className="px-4 pb-3 text-sm text-[color:var(--m-ink-tertiary)]"
className="px-4 pb-3 text-sm text-muted-foreground"
>
No segments
</p>
) : (
<pre
data-testid="raw-segments-pre"
className="overflow-x-auto whitespace-pre-wrap border-t border-[color:var(--m-border-heavy)]/20 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-[color:var(--m-ink-primary)] font-mono"
className="overflow-x-auto whitespace-pre-wrap border-t border-border/40 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-foreground font-mono"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{rawSegments.map((segment, i) => (
@@ -49,7 +49,7 @@ export function ServiceLinesTable({
<section className="flex flex-col gap-3 px-6 py-4">
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
Service Lines ({serviceLines.length})
</h3>
@@ -57,7 +57,7 @@ export function ServiceLinesTable({
{serviceLines.length === 0 ? (
<p
data-testid="service-lines-empty"
className="text-sm text-[color:var(--m-ink-tertiary)]"
className="text-sm text-muted-foreground"
>
No service lines
</p>
@@ -84,34 +84,34 @@ export function ServiceLinesTable({
data-line-number={line.lineNumber}
className="row-hover"
>
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
<TableCell className="font-mono text-muted-foreground">
{line.lineNumber}
</TableCell>
<TableCell className="font-mono text-[color:var(--m-ink-primary)]">
<TableCell className="font-mono text-foreground">
{formatProcedure(line)}
</TableCell>
<TableCell
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)] font-semibold"
className="text-right font-mono text-base tabular-nums text-foreground font-semibold"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{fmt.usdPrecise(line.charge)}
</TableCell>
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
<TableCell className="font-mono text-muted-foreground">
{formatUnits(line)}
</TableCell>
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
<TableCell className="font-mono text-muted-foreground">
{line.serviceDate ? fmt.date(line.serviceDate) : ""}
</TableCell>
<TableCell
data-testid="paid-cell"
className="text-right font-mono tabular-nums text-[color:var(--m-ink-primary)]"
className="text-right font-mono tabular-nums text-foreground"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{formatMoneyOrDash(lr?.paid ?? null)}
</TableCell>
<TableCell
data-testid="adjustments-cell"
className="text-right font-mono tabular-nums text-[color:var(--m-ink-secondary)]"
className="text-right font-mono tabular-nums text-muted-foreground"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{formatMoneyOrDash(lr?.adjustmentsSum ?? null)}
@@ -64,7 +64,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
>
<h3
data-testid="section-label"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-muted-foreground"
>
State History ({history.length})
</h3>
@@ -72,14 +72,14 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
{history.length === 0 ? (
<p
data-testid="state-history-empty"
className="text-sm text-[color:var(--m-ink-tertiary)]"
className="text-sm text-muted-foreground"
>
No history events
</p>
) : (
<ol
data-testid="state-history-timeline"
className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-[color:var(--m-border-heavy)]/40 pl-6"
className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-border/40 pl-6"
>
{history.map((event, i) => (
<li
@@ -91,18 +91,18 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
<span
data-testid="state-history-dot"
aria-hidden
className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-[color:var(--m-surface)] ${dotColorFor(event.kind)}`}
className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-card ${dotColorFor(event.kind)}`}
/>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span
data-testid="state-history-kind"
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-primary)]"
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-foreground"
>
{kindLabel(event.kind)}
</span>
<span
data-testid="state-history-ts"
className="mono text-xs text-[color:var(--m-ink-secondary)] tabular-nums"
className="mono text-xs text-muted-foreground tabular-nums"
>
{fmt.date(event.ts)} · {fmt.time(event.ts)}
</span>
@@ -110,7 +110,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
{event.remittanceId ? (
<span
data-testid="history-remit-id"
className="mono text-xs text-[color:var(--m-ink-tertiary)]"
className="mono text-xs text-muted-foreground"
>
Remit {event.remittanceId}
</span>
@@ -6,8 +6,9 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it } from "vitest";
import { describe, expect, it, beforeEach } from "vitest";
import { ValidationPanel } from "./ValidationPanel";
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
import type { ClaimDetailValidation, ClaimDetailValidationIssue } from "@/types";
function renderIntoContainer(element: React.ReactElement): {
@@ -17,8 +18,11 @@ function renderIntoContainer(element: React.ReactElement): {
const container = document.createElement("div");
document.body.appendChild(container);
const root: Root = createRoot(container);
// SP21 Phase 5 Task 5.9: ValidationPanel now uses useDrillStack()
// to open validation-rule peeks. Wrap every render in a
// DrillStackProvider so the hook has a context to read from.
act(() => {
root.render(element);
root.render(<DrillStackProvider>{element}</DrillStackProvider>);
});
return {
container,
@@ -244,4 +248,129 @@ describe("ValidationPanel", () => {
unmount();
});
it("SP21 Task 5.9: rule code is drillable (renders as a button)", () => {
// The rule code in each IssueGroup now wraps in a button with
// data-testid="...-rule-drill". The other rule codes also drill
// the same way.
const { container, unmount } = renderIntoContainer(
<ValidationPanel
validation={makeValidation({
passed: false,
errors: [
makeIssue({ rule: "R050_diagnosis_present" }),
],
warnings: [
makeIssue({
rule: "R200_units_recommended",
severity: "warning",
}),
],
})}
/>
);
const errorRuleDrill = container.querySelector(
'[data-testid="validation-errors-rule-drill"]',
);
const warningRuleDrill = container.querySelector(
'[data-testid="validation-warnings-rule-drill"]',
);
expect(errorRuleDrill).not.toBeNull();
expect(errorRuleDrill?.tagName).toBe("BUTTON");
expect(errorRuleDrill?.textContent).toContain("R050_diagnosis_present");
expect(warningRuleDrill).not.toBeNull();
expect(warningRuleDrill?.tagName).toBe("BUTTON");
expect(warningRuleDrill?.textContent).toContain("R200_units_recommended");
unmount();
});
it("SP21 Task 5.9: clicking the rule code opens the PeekModal", async () => {
// Clicking the rule code in the errors sub-section pushes a
// `{ kind: "rule", rule }` peek onto the drill stack. The peek
// is a Radix Dialog portal with the eyebrow "Validation rule"
// and the rule code as the title.
const { container, unmount } = renderIntoContainer(
<ValidationPanel
validation={makeValidation({
passed: false,
errors: [
makeIssue({ rule: "R050_diagnosis_present" }),
],
})}
/>
);
const ruleBtn = container.querySelector(
'[data-testid="validation-errors-rule-drill"]',
) as HTMLButtonElement | null;
expect(ruleBtn).not.toBeNull();
await act(async () => {
ruleBtn!.click();
await Promise.resolve();
});
// The PeekModal portals to document.body as a Radix dialog. Find
// the dialog with the "Validation rule" eyebrow.
const dialogs = document.body.querySelectorAll('[role="dialog"]');
const peekDialog = Array.from(dialogs).find((d) =>
d.textContent?.includes("Validation rule"),
);
expect(peekDialog).not.toBeNull();
// Title is the rule code.
expect(peekDialog?.textContent).toContain("R050_diagnosis_present");
// Body includes the catalog description for R050.
expect(peekDialog?.textContent).toContain("Diagnosis pointer present");
unmount();
});
it("SP21 Task 5.9: unknown rule still opens the peek (fallback note)", async () => {
// Rules not in the catalog still open the peek — operators
// should be able to correlate unknown rule codes to whatever
// they were just looking at. The peek shows an "Unknown rule"
// note instead of the catalog text.
const { container, unmount } = renderIntoContainer(
<ValidationPanel
validation={makeValidation({
passed: false,
errors: [
makeIssue({ rule: "R999_totally_made_up" }),
],
})}
/>
);
const ruleBtn = container.querySelector(
'[data-testid="validation-errors-rule-drill"]',
) as HTMLButtonElement | null;
expect(ruleBtn).not.toBeNull();
await act(async () => {
ruleBtn!.click();
await Promise.resolve();
});
const dialogs = document.body.querySelectorAll('[role="dialog"]');
const peekDialog = Array.from(dialogs).find((d) =>
d.textContent?.includes("Validation rule"),
);
expect(peekDialog).not.toBeNull();
expect(peekDialog?.textContent).toContain("R999_totally_made_up");
expect(peekDialog?.textContent).toContain("Unknown rule");
unmount();
});
});
beforeEach(() => {
// The PeekModal portals to document.body as a Radix dialog. Wipe
// any leftover dialogs between tests so the "find dialog by
// eyebrow" assertions in the rule-drill tests don't see stale
// portals from a prior test.
document.body.querySelectorAll('[role="dialog"]').forEach((d) => d.remove());
});
+51 -15
View File
@@ -1,4 +1,7 @@
import { AlertCircle, AlertTriangle, CheckCircle2 } from "lucide-react";
import { useDrillStack } from "@/components/drill/DrillStackProvider";
import { PeekModal } from "@/components/drill/PeekModal";
import { ValidationRulePeekContent } from "@/components/drill/ValidationRulePeekContent";
import type { ClaimDetail } from "@/types";
type ValidationPanelProps = {
@@ -28,6 +31,13 @@ function groupByRule(issues: IssueList): Array<[string, IssueList]> {
/**
* One rule-group block: header with rule code + count chip, followed by
* the list of messages underneath.
*
* SP21 Phase 5 Task 5.9: the rule code is now drillable clicking it
* opens the validation-rule peek on top of the drawer (via the drill
* stack). The peek renders ValidationRulePeekContent for the rule
* code, falling back to a "unknown rule" note when the catalog has
* no entry. Unknown rules still render the peek so operators can
* correlate the code to whatever they were just looking at.
*/
function IssueGroup({
rule,
@@ -43,17 +53,22 @@ function IssueGroup({
testId === "validation-errors"
? "text-[color:var(--m-error)]"
: "text-[color:var(--m-warning)]";
const { openPeek } = useDrillStack();
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
<button
type="button"
onClick={() => openPeek({ kind: "rule", rule })}
data-testid={`${testId}-rule-drill`}
aria-label={`Drill into rule ${rule}`}
className="mono text-[12px] font-semibold tracking-tight text-foreground cursor-pointer drillable rounded-sm px-0 -mx-0"
>
{rule}
</span>
</button>
<span
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums"
className="inline-flex items-center rounded-full bg-muted-foreground/15 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground tabular-nums"
data-testid={`${testId}-count`}
>
{issues.length}
@@ -63,7 +78,7 @@ function IssueGroup({
{issues.map((issue, idx) => (
<li
key={`${rule}-${idx}`}
className="flex items-start gap-2 text-[13px] leading-snug text-[color:var(--m-ink-secondary)]"
className="flex items-start gap-2 text-[13px] leading-snug text-muted-foreground"
data-testid={`${testId}-message`}
>
<Icon
@@ -93,6 +108,11 @@ function IssueGroup({
*/
export function ValidationPanel({ validation }: ValidationPanelProps) {
const allPassed = validation.passed && validation.warnings.length === 0;
// SP21 Phase 5 Task 5.9: peek stack for rule drill. The drill
// provider is mounted at the App root; we only read the top entry
// here so the peek renders regardless of which section pushed it.
const { stack, closeTop } = useDrillStack();
const topPeek = stack[stack.length - 1];
if (allPassed) {
return (
@@ -101,14 +121,14 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
className="flex items-center gap-2 px-6 py-3"
>
<CheckCircle2
className="h-4 w-4 text-[color:var(--m-success)]"
className="h-4 w-4 text-[hsl(var(--success))]"
strokeWidth={1.75}
aria-hidden
/>
<span className="text-[13px] font-medium text-[color:var(--m-ink-primary)]">
<span className="text-[13px] font-medium text-foreground">
All checks passed
</span>
<span className="ml-auto inline-flex items-center rounded-full bg-[color:var(--m-success)]/12 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-success)]">
<span className="ml-auto inline-flex items-center rounded-full bg-[hsl(var(--success)/0.12)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[hsl(var(--success))]">
Valid
</span>
</section>
@@ -120,10 +140,10 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
return (
<section
className="flex flex-col gap-4 px-6 py-4 bg-[color:var(--m-surface)]"
className="flex flex-col gap-4 px-6 py-4"
data-testid="validation-panel"
>
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-muted-foreground">
Validation
</span>
@@ -132,15 +152,15 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
data-testid="validation-errors"
data-rule-group="errors"
role="alert"
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.06)] px-3.5 py-3"
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.08)] px-3.5 py-3"
>
<div className="flex items-center gap-2">
<AlertCircle
className="h-3.5 w-3.5 text-[color:var(--m-error)]"
className="h-3.5 w-3.5 text-destructive"
strokeWidth={1.75}
aria-hidden
/>
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-error)]">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-destructive">
Errors ({validation.errors.length})
</span>
</div>
@@ -165,11 +185,11 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
>
<div className="flex items-center gap-2">
<AlertTriangle
className="h-3.5 w-3.5 text-[color:var(--m-warning)]"
className="h-3.5 w-3.5 text-[hsl(var(--warning))]"
strokeWidth={1.75}
aria-hidden
/>
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-warning)]">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-[hsl(var(--warning))]">
Warnings ({validation.warnings.length})
</span>
</div>
@@ -185,6 +205,22 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
</div>
</div>
) : null}
{/* SP21 Phase 5 Task 5.9: validation-rule peek. Mounted at the
bottom of the panel so the peek (a Radix Dialog portal) sits
on top of the drawer. Only renders when the top of the
drill stack is a rule peek payer peek (from PartiesGrid)
wins when it's on top because the stack caps at 1 entry. */}
{topPeek?.kind === "rule" ? (
<PeekModal
open
onClose={closeTop}
eyebrow="Validation rule"
title={topPeek.rule}
>
<ValidationRulePeekContent rule={topPeek.rule} />
</PeekModal>
) : null}
</section>
);
}
+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 (
<div className="relative min-h-screen z-10">
<SkipLink />
{/* Ambient page halo a soft, fixed radial that creates a
subtle lighter zone behind the page content (centered
just below the top bar). Pure decoration; pointer-events
disabled so it never intercepts clicks. Two layered
radials: a neutral softbox centered above the page
header, and a cool accent in the upper-right that
mirrors the body::before composition. The halo sits
behind everything else (z-0 inside the z-10 root). */}
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 top-0 z-0 h-[70vh]"
style={{
background: `
radial-gradient(ellipse 55% 30% at 50% 6%, hsla(220, 32%, 30%, 0.16), transparent 70%),
radial-gradient(ellipse 35% 25% at 82% 10%, hsla(212, 70%, 60%, 0.10), transparent 65%)
`,
}}
/>
<div
className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none transition-opacity duration-200"
style={{ opacity: showScan ? 1 : 0 }}
@@ -44,6 +44,51 @@ const SAMPLE_PROVIDER: Provider = {
outstandingAr: 12450,
};
/**
* Extended provider fixture (SP21 Task 3.1) adds the
* `recent_claims` and `recent_activity` slices the Claims/Activity
* tabs read from. Used by the tabs tests below. Field shapes match
* `ClaimSummary` and `ActivityEvent` from `@/types`.
*/
const SAMPLE_PROVIDER_WITH_DETAILS: Provider = {
...SAMPLE_PROVIDER,
recent_claims: [
{
id: "CLM-0001",
state: "submitted",
billedAmount: 250,
patientName: "Jane Q Patient",
providerNpi: "1881068062",
payerName: "Aetna",
cptCode: "99213",
submissionDate: "2026-06-20",
parsedAt: "2026-06-20",
status: "submitted",
batchId: "batch-001",
},
],
recent_activity: [
{
id: 1,
ts: "2026-06-20T15:30:00Z",
kind: "claim_submitted",
batchId: "batch-001",
claimId: "CLM-0001",
remittanceId: null,
payload: {},
},
{
id: 2,
ts: "2026-06-20T15:35:00Z",
kind: "claim_paid",
batchId: "batch-001",
claimId: "CLM-0001",
remittanceId: null,
payload: {},
},
],
};
/**
* Configure the mocked hook's return value for a single test. The
* `refetch` default is a fresh `vi.fn()` tests that need to assert
@@ -193,4 +238,66 @@ describe("ProviderDrawer", () => {
fireEvent.click(closeBtn!);
expect(onClose).toHaveBeenCalledTimes(1);
});
// -- Tabs (SP21 Task 3.1) -------------------------------------------------
//
// Radix Tabs only mounts the active `Tabs.Content` into the DOM (no
// `forceMount`), so clicking a tab trigger causes the previous panel to
// unmount and the new one to mount. The tests below rely on that —
// "panel X renders" is asserted by checking that unique text from that
// panel's component is present in `document.body` after the click.
it("test_renders_three_tabs_overview_claims_activity", () => {
mockDetail({ data: SAMPLE_PROVIDER_WITH_DETAILS });
render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
// All three tab triggers are present, with the spec-mandated labels
// in the spec-mandated order.
const triggers = Array.from(document.querySelectorAll('[role="tab"]'));
expect(triggers.length).toBe(3);
expect(triggers.map((t) => t.textContent)).toEqual([
"Overview",
"Claims",
"Activity",
]);
// Overview is the default; its trigger must be selected on mount.
expect(triggers[0]?.getAttribute("aria-selected")).toBe("true");
expect(triggers[0]?.getAttribute("data-state")).toBe("active");
});
it("test_switches_to_claims_tab_and_shows_recent_claims", () => {
mockDetail({ data: SAMPLE_PROVIDER_WITH_DETAILS });
render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
const claimsTrigger = Array.from(document.querySelectorAll('[role="tab"]'))
.find((t) => t.textContent === "Claims");
expect(claimsTrigger).not.toBeNull();
// Radix Tabs wires `onMouseDown` (not `onClick`) to its
// `onValueChange` handler — fire the matching event so the tab
// actually activates under happy-dom.
fireEvent.mouseDown(claimsTrigger!);
// Claims tab is now selected — and ProviderRecentClaims content is in
// the DOM (claim id + patient name + the "View all claims" link).
expect(claimsTrigger?.getAttribute("aria-selected")).toBe("true");
expect(document.body.textContent).toContain("CLM-0001");
expect(document.body.textContent).toContain("Jane Q Patient");
expect(document.body.textContent).toContain("View all claims");
});
it("test_switches_to_activity_tab_and_shows_recent_activity", () => {
mockDetail({ data: SAMPLE_PROVIDER_WITH_DETAILS });
render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
const activityTrigger = Array.from(document.querySelectorAll('[role="tab"]'))
.find((t) => t.textContent === "Activity");
expect(activityTrigger).not.toBeNull();
fireEvent.mouseDown(activityTrigger!);
// Activity tab is now selected — and ProviderRecentActivity content
// is in the DOM (both `kind` strings from the fixture).
expect(activityTrigger?.getAttribute("aria-selected")).toBe("true");
expect(document.body.textContent).toContain("claim_submitted");
expect(document.body.textContent).toContain("claim_paid");
});
});
@@ -1,9 +1,12 @@
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Tabs } from "@/components/ui/tabs";
import { ApiError } from "@/lib/api";
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
import { Skeleton } from "@/components/ui/skeleton";
import { useProviderDetail } from "@/hooks/useProviderDetail";
import { ProviderOverview } from "./ProviderOverview";
import { ProviderRecentClaims } from "./ProviderRecentClaims";
import { ProviderRecentActivity } from "./ProviderRecentActivity";
import { ProviderDrawerError } from "./ProviderDrawerError";
interface Props {
@@ -12,12 +15,17 @@ interface Props {
}
/**
* Provider drill-down drawer (SP21 Task 2.2).
* Provider drill-down drawer (SP21 Task 2.2 + 3.1).
*
* Side-panel shell that consumes ``useProviderDetail(npi)`` and renders
* the Overview tab content (Phase 2 ships only this tab; Phase 3 adds
* Claims/Activity tabs once ``recent_claims`` and ``recent_activity``
* rendering lands).
* a three-tab body:
*
* - Overview base provider fields (identity + activity shape)
* - Claims top-10 claims joined to this provider
* (extended `/api/config/providers/{npi}.recent_claims`,
* populated by Task 1.6)
* - Activity top-10 events joined to this provider's claims
* (extended `/api/config/providers/{npi}.recent_activity`)
*
* Layout mirrors the ClaimDrawer / RemitDrawer pattern: Radix Dialog
* repositioned to the right edge as a fixed-height side panel, with
@@ -76,9 +84,22 @@ export function ProviderDrawer({ npi, onClose }: Props) {
title={data.name}
onClose={onClose}
/>
<div className="p-6">
<ProviderOverview provider={data} />
</div>
<Tabs.Root defaultValue="overview" className="px-6 py-4">
<Tabs.List>
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger value="claims">Claims</Tabs.Trigger>
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview">
<ProviderOverview provider={data} />
</Tabs.Content>
<Tabs.Content value="claims">
<ProviderRecentClaims provider={data} />
</Tabs.Content>
<Tabs.Content value="activity">
<ProviderRecentActivity provider={data} />
</Tabs.Content>
</Tabs.Root>
</div>
)}
</DialogContent>
@@ -0,0 +1,34 @@
import type { Provider } from "@/types";
/**
* Activity tab content for the ProviderDrawer (SP21 Task 3.1).
*
* STUB renders the top-10 `recent_activity` events as a flat list
* (`ts` + `kind`). Real activity-event routing (linking each event to
* its source claim/remit/provider) arrives in Phase 4 once
* `provider_added` and `remit_received` events get their drawer
* surfaces; for now the events aren't drillable from this view
* clicking them does nothing. The ProviderDrawer's Overview already
* surfaces the provider's `provider_added` event via the Dashboard
* activity feed (Task 2.5).
*/
export function ProviderRecentActivity({ provider }: { provider: Provider }) {
const items = provider.recent_activity ?? [];
if (items.length === 0) {
return (
<div className="text-muted-foreground text-[13px]">No recent activity.</div>
);
}
return (
<ul className="space-y-1.5 text-[12.5px]">
{items.map((a) => (
<li key={a.id} className="flex items-center gap-2">
<span className="mono text-[10.5px] text-muted-foreground">
{new Date(a.ts).toLocaleString()}
</span>
<span>{a.kind}</span>
</li>
))}
</ul>
);
}
@@ -0,0 +1,43 @@
import type { Provider } from "@/types";
import { fmt } from "@/lib/format";
/**
* Claims tab content for the ProviderDrawer (SP21 Task 3.1).
*
* Reads `provider.recent_claims` the top-10 claims joined to this
* provider from the extended `GET /api/config/providers/{npi}` endpoint
* (Task 1.6). Renders one row per claim (`id` + `patientName` +
* `billedAmount`) with a "View all claims →" link to the global claims
* page at the bottom. Falls back to an empty-state line when the
* provider has no recent claims (e.g. legacy callers that hit the
* detail endpoint without the recent-claims slice).
*/
export function ProviderRecentClaims({ provider }: { provider: Provider }) {
const claims = provider.recent_claims ?? [];
if (claims.length === 0) {
return (
<div className="text-muted-foreground text-[13px]">No recent claims.</div>
);
}
return (
<div className="space-y-2">
{claims.map((c) => (
<div
key={c.id}
className="flex items-center gap-3 py-2 border-b border-border/30 last:border-0"
>
<div className="display mono text-[12.5px] w-32 shrink-0">{c.id}</div>
<div className="flex-1 min-w-0 text-[12.5px] text-muted-foreground truncate">
{c.patientName ?? "—"}
</div>
<div className="display mono text-[13px]">
{fmt.usd(c.billedAmount)}
</div>
</div>
))}
<a href="/claims" className="text-[12.5px] text-accent hover:underline">
View all claims
</a>
</div>
);
}
+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>
);
}
+22 -9
View File
@@ -1,9 +1,19 @@
import type { ReactNode } from "react";
import { X } from "lucide-react";
interface Props {
eyebrow: string;
title: string;
onClose: () => void;
/**
* Optional slot for a right-side action (e.g. "Download 999" on the
* AckDrawer, "Download 837" on the ClaimDrawer added in SP21
* Phase 5 Task 5.2/5.10). Rendered to the left of the close
* button with a small visual gap. Anything goes a button, a
* status pill, an icon link. Default `null` so existing callers
* (ProviderDrawer) render unchanged.
*/
action?: ReactNode;
}
/**
@@ -15,7 +25,7 @@ interface Props {
* the app (see ``.eyebrow`` in ``src/index.css`` and the
* ``DrillDrawerHeader`` usage in ``ClaimDrawerHeader``).
*/
export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
export function DrillDrawerHeader({ eyebrow, title, onClose, action }: Props) {
return (
<div className="flex items-center justify-between border-b border-border/30 px-6 py-4">
<div>
@@ -24,14 +34,17 @@ export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
</div>
<h2 className="text-[18px] font-semibold tracking-tight mt-0.5">{title}</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close drawer"
className="rounded-md p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4" aria-hidden />
</button>
<div className="flex items-center gap-2">
{action}
<button
type="button"
onClick={onClose}
aria-label="Close drawer"
className="rounded-md p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
</div>
);
}
@@ -0,0 +1,134 @@
interface Props {
/** The rule code (e.g. "R050_diagnosis_present" or just "R050"). */
rule: string;
}
interface RuleDoc {
/** Short human title (e.g. "Diagnosis pointer present"). */
title: string;
/** Plain-English description of what the rule checks. */
description: string;
/** Why the rule matters — operator-facing rationale. */
whyItMatters: string;
/** How to fix — short, actionable. */
howToFix: string;
}
/**
* SP21 Phase 5 Task 5.9: rule catalog used by ValidationRulePeekContent.
*
* The catalog is intentionally small it covers the rules we actually
* emit today (R050_diagnosis_present, R200_units_recommended). For
* anything not in the catalog the peek still renders (with an "Unknown
* rule" note) operators should still be able to open the peek for
* any rule code so they can see the originating message verbatim.
*
* Adding a new entry here is the source-of-truth for the rule's
* documentation. The ValidationPanel wires the peek by rule code; if
* we add new rules later (Phase 6+), add a new entry here.
*/
const RULE_CATALOG: Record<string, RuleDoc> = {
R050_diagnosis_present: {
title: "Diagnosis pointer present",
description:
"Each service line must point to at least one diagnosis code in the claim header (the HL segment's HI element). A missing pointer makes the line unprocessable on the payer side.",
whyItMatters:
"Payers reject claims with missing diagnosis pointers at the 999 stage, which would otherwise re-trigger the 999 rejection loop. Catching it here gives the operator a chance to attach the dx before submission.",
howToFix:
"Open the claim's Service Lines table and attach the relevant diagnosis code (e.g. E11.9) to the line. The pointer is the line's diagnosis pointer list.",
},
R200_units_recommended: {
title: "Service line units recommended",
description:
"Service lines that represent timed procedures (anesthesia, critical care, psychotherapy time-based codes) should carry an explicit units value. Defaulting to 1 is acceptable for most codes but flagged here for review.",
whyItMatters:
"Timed codes without units get under-reimbursed — payers default to 1 unit when the field is blank, even when the procedure took 45 minutes. The warning exists so an operator can verify the units are correct before submission.",
howToFix:
"Confirm the units value on the service line matches the documented encounter time. If the code is not time-based, no action is required.",
},
};
/**
* Peek body for a validation rule opens on top of the ClaimDrawer
* via PeekModal when the operator clicks a rule code in the
* ValidationPanel. The body shows the rule's title, description, why
* it matters, and how to fix it.
*
* Unknown rules (codes not in the catalog) render a small "Unknown
* rule see originating message" note rather than blowing up. The
* peek still renders so the operator can correlate the rule code to
* whatever they were just looking at.
*
* No fetch the catalog is static and bundled. (A future phase
* could swap this for a backend-served catalog if rules become
* user-extensible.)
*/
export function ValidationRulePeekContent({ rule }: Props) {
// Normalize: the rule code in the validation payload is the full
// form (`R050_diagnosis_present`), but a future backend response
// might use the short form (`R050`). Look up both.
const doc =
RULE_CATALOG[rule] ??
RULE_CATALOG[rule.split("_")[0] ?? ""] ??
null;
if (!doc) {
return (
<div className="space-y-2">
<div className="display text-[14px] text-foreground">
{rule}
</div>
<div
className="text-[12.5px]"
style={{ color: "hsl(var(--muted-foreground))" }}
>
Unknown rule. The originating message is the authoritative
description this peek is a no-op for undocumented rule codes.
</div>
</div>
);
}
return (
<div className="space-y-3">
<div className="display text-[15px] text-foreground">
{doc.title}
</div>
<div
className="mono text-[11px]"
style={{ color: "hsl(var(--muted-foreground))" }}
>
{rule}
</div>
<div className="text-[13px] leading-relaxed text-[color:var(--m-ink-primary)]">
{doc.description}
</div>
<div className="space-y-1.5 pt-1">
<Section heading="Why it matters">{doc.whyItMatters}</Section>
<Section heading="How to fix">{doc.howToFix}</Section>
</div>
</div>
);
}
function Section({
heading,
children,
}: {
heading: string;
children: React.ReactNode;
}) {
return (
<div>
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-0.5"
style={{ color: "hsl(var(--muted-foreground))" }}
>
{heading}
</div>
<div className="text-[12.5px] leading-relaxed text-[color:var(--m-ink-secondary)]">
{children}
</div>
</div>
);
}
+7 -2
View File
@@ -102,10 +102,15 @@ export function Lane({
<section
className="flex-1 min-w-[280px] flex flex-col rounded-md overflow-hidden"
style={{
background: "var(--tt-bg-elev)",
// "Lit from above" gradient — same treatment as the main
// .surface cards, in the inbox's cool/blue-grey hue family.
// Each lane becomes its own small lighter area against the
// surrounding ticker-tape background.
background:
"linear-gradient(180deg, hsl(220 18% 11.5%) 0%, hsl(220 18% 9.5%) 55%, hsl(220 18% 8.5%) 100%)",
border: "1px solid hsl(220 8% 18%)",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.03), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
"inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
}}
data-lane={name.toLowerCase()}
>
+84
View File
@@ -0,0 +1,84 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
/**
* Tabs primitive SP21 Universal Drill-Down (Task 3.1).
*
* Thin wrapper over `@radix-ui/react-tabs`. Mirrors the same re-export
* pattern used by `dialog.tsx`: the individual primitives are forwardRef'd
* styled components, and the `Tabs` namespace object lets callers use
* the spec's `<Tabs.Root>`, `<Tabs.List>`, `<Tabs.Trigger>`,
* `<Tabs.Content>` dot-notation directly:
*
* import { Tabs } from "@/components/ui/tabs";
* <Tabs.Root defaultValue="overview">
* <Tabs.List>
* <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
* ...
* </Tabs.List>
* <Tabs.Content value="overview">...</Tabs.Content>
* </Tabs.Root>
*/
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"flex gap-2 border-b border-border/30 mb-4",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"px-3 py-2 text-[12.5px] text-muted-foreground",
"data-[state=active]:text-foreground",
"data-[state=active]:border-b-2 data-[state=active]:border-accent",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
/**
* Namespace export matching the spec's `import * as Tabs from ...`
* dot-notation. `Root` is the unstyled Radix primitive; the rest are the
* styled wrappers above.
*/
export const Tabs = {
Root: TabsPrimitive.Root,
List: TabsList,
Trigger: TabsTrigger,
Content: TabsContent,
};
export { TabsList, TabsTrigger, TabsContent };
+89
View File
@@ -0,0 +1,89 @@
import { useQuery } from "@tanstack/react-query";
import { api, ApiError } from "@/lib/api";
import type { Ack } from "@/types";
/**
* UI-facing ack detail shape returned by `GET /api/acks/{id}`.
*
* Extends the base `Ack` shape with the `raw_999_text` field that
* `api.getAck` populates on top of the canonical row. The download
* button inside `AckDrawer` reads this string to hand the user the
* regenerated X12 file.
*/
export interface AckDetail extends Ack {
/**
* Full regenerated 999 X12 text. The backend re-emits the parsed
* transaction set so a user can grab the original file from the
* drawer without a second round-trip to `/api/acks/{id}/raw`.
*/
raw_999_text?: string;
/**
* Raw JSON envelope captured by the parser (the same dict the
* parser wrote into `raw_json`). Optional so older rows without
* it still typecheck.
*/
rawJson?: unknown;
}
/**
* Per-ack detail drawer query (AckDrawer · SP21 Phase 5 Task 5.2).
*
* Twin of `useProviderDetail` and `useClaimDetail` same return
* shape, same retry semantics, no in-memory fallback (the spec §5.2
* calls out that ACKs are backend-only; `useAcks` has no sample-data
* path so there's nothing to fall back on).
*
* Returns `{ data, isLoading, isError, error, refetch }`:
* - `ackId === null` (drawer closed): the query is disabled and the
* hook short-circuits to the empty drawer state so a closed drawer
* doesn't burn a network request or a TanStack cache slot.
* - `ackId` is set: fetches `GET /api/acks/{id}` via `api.getAck`.
* Cached 60 s the underlying ack rows don't change after
* parse-time.
* - On 404: the hook's retry predicate short-circuits (no retries)
* so the drawer's not-found state appears immediately rather than
* being masked by three back-to-back retry attempts.
*
* The drawer accepts `ackId: string | null` (the URL keeps the id as
* a string for clean deep-link round-tripping) and does the
* `Number()` coercion here, matching how `useProviderDetail`
* (`string`) and `useRemitDetail` (`string`) already work.
*/
export function useAckDetail(ackId: string | null): {
data: AckDetail | null;
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
} {
const q = useQuery<AckDetail>({
queryKey: ["ack-detail", ackId],
queryFn: () => api.getAck(Number(ackId)),
enabled: ackId !== null,
staleTime: 60 * 1000,
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 3;
},
});
if (ackId === null) {
return {
data: null,
isLoading: false,
isError: false,
error: null,
refetch: () => {},
};
}
return {
data: q.data ?? null,
isLoading: q.isLoading,
isError: q.isError,
error: q.error,
refetch: () => {
void q.refetch();
},
};
}
+219
View File
@@ -0,0 +1,219 @@
// @vitest-environment happy-dom
// Mirror the IS_REACT_ACT_ENVIRONMENT setup from useProviderDrawerUrlState.test.ts
// so React doesn't log act() warnings about the createRoot render/unmount
// and the popstate-driven state updates.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { useAckDrawerUrlState } from "./useAckDrawerUrlState";
/**
* Minimal renderHook shim same pattern as the other hook tests.
*/
function renderHook<TResult>(setup: () => TResult): {
result: { current: TResult | undefined };
unmount: () => void;
} {
const result: { current: TResult | undefined } = { current: undefined };
const container = document.createElement("div");
document.body.appendChild(container);
function Probe() {
result.current = setup();
return null;
}
const root: Root = createRoot(container);
act(() => {
root.render(React.createElement(Probe));
});
return {
result,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
/**
* Point happy-dom's URL at a known value. happy-dom v20 doesn't expose a
* writable `window.location.search`, but `window.happyDOM.setURL` updates
* the URL the window reports without triggering a navigation exactly
* what we want for mounting the hook at `/acks?ack=42` etc.
*/
function setLocation(url: string): void {
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url);
}
describe("useAckDrawerUrlState", () => {
type PushState = (state: unknown, unused: string, url?: string | URL | null) => void;
let pushStateMock: ReturnType<typeof vi.fn<PushState>>;
let replaceStateMock: ReturnType<typeof vi.fn<PushState>>;
beforeEach(() => {
pushStateMock = vi.fn();
replaceStateMock = vi.fn();
vi.stubGlobal("history", {
pushState: pushStateMock,
replaceState: replaceStateMock,
state: null,
});
});
afterEach(() => {
vi.unstubAllGlobals();
setLocation("http://localhost/");
});
it("reads the ?ack= param from window.location.search on mount", () => {
setLocation("http://localhost/acks?ack=42");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
expect(result.current?.ackId).toBe("42");
expect(typeof result.current?.open).toBe("function");
expect(typeof result.current?.close).toBe("function");
expect(typeof result.current?.setAckId).toBe("function");
unmount();
});
it("returns null ackId when no ?ack= param is set", () => {
setLocation("http://localhost/acks");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
expect(result.current?.ackId).toBeNull();
unmount();
});
it("returns null ackId when ?ack= is present but empty", () => {
setLocation("http://localhost/acks?ack=");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
expect(result.current?.ackId).toBeNull();
unmount();
});
it("open(id) pushes a new history entry containing ?ack=ID", () => {
setLocation("http://localhost/acks");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
act(() => {
result.current?.open("42");
});
expect(pushStateMock).toHaveBeenCalledTimes(1);
expect(replaceStateMock).not.toHaveBeenCalled();
const urlArg = pushStateMock.mock.calls[0][2] as string;
expect(urlArg).toContain("?ack=42");
unmount();
});
it("setAckId(id) replaces the current history entry (no new entry) and does NOT pushState", () => {
setLocation("http://localhost/acks?ack=42");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
act(() => {
result.current?.setAckId("43");
});
expect(replaceStateMock).toHaveBeenCalledTimes(1);
expect(pushStateMock).not.toHaveBeenCalled();
const urlArg = replaceStateMock.mock.calls[0][2] as string;
expect(urlArg).toContain("?ack=43");
unmount();
});
it("open() and close() preserve other query params (only ?ack= is touched)", () => {
setLocation("http://localhost/acks?sort=date&ack=42");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
act(() => {
result.current?.open("43");
});
const openUrl = pushStateMock.mock.calls[0][2] as string;
expect(openUrl).toContain("sort=date");
expect(openUrl).toMatch(/[?&]ack=43/);
act(() => {
result.current?.close();
});
const closeUrl = pushStateMock.mock.calls[1][2] as string;
expect(closeUrl).toContain("sort=date");
expect(closeUrl).not.toContain("ack=");
unmount();
});
it("close() pushes a new history entry with the ?ack= param stripped", () => {
setLocation("http://localhost/acks?ack=42");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
act(() => {
result.current?.close();
});
expect(pushStateMock).toHaveBeenCalledTimes(1);
expect(replaceStateMock).not.toHaveBeenCalled();
const urlArg = pushStateMock.mock.calls[0][2] as string;
expect(urlArg).not.toContain("?ack=");
expect(result.current?.ackId).toBeNull();
unmount();
});
it("updates ackId in response to popstate (browser back/forward)", async () => {
setLocation("http://localhost/acks?ack=42");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
expect(result.current?.ackId).toBe("42");
setLocation("http://localhost/acks?ack=43");
await act(async () => {
window.dispatchEvent(new PopStateEvent("popstate"));
await Promise.resolve();
});
expect(result.current?.ackId).toBe("43");
unmount();
});
it("does not collide with the existing ?claim=, ?remit=, or ?provider= params (orthogonal keys)", () => {
// The acks drawer is independent of the claim/remit/provider
// drawers — opening an ack must leave the others intact so a user
// can deep-link to multiple states simultaneously (though the UI
// currently only shows one drawer at a time, the URL params don't
// know that).
setLocation("http://localhost/?claim=CLM-1&remit=REM-1&provider=1881068062");
const { result, unmount } = renderHook(() => useAckDrawerUrlState());
act(() => {
result.current?.open("42");
});
const openUrl = pushStateMock.mock.calls[0][2] as string;
expect(openUrl).toContain("claim=CLM-1");
expect(openUrl).toContain("remit=REM-1");
expect(openUrl).toContain("provider=1881068062");
expect(openUrl).toMatch(/[?&]ack=42/);
unmount();
});
});
+105
View File
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useState } from "react";
/**
* Read the current `?ack=…` query param off `window.location.search`.
* Returns `null` when the param is absent or empty.
*
* `URLSearchParams` is the standard, locale-free way to parse query
* strings in the browser. Using it (rather than hand-rolled string
* slicing) means we correctly handle multiple params and percent-encoded
* characters in ack ids without surprises.
*
* Param name is `?ack=` chosen to mirror the existing `?provider=…`,
* `?claim=…`, and `?remit=…` drilldown conventions (one-word token,
* alphabetical brevity, no collision with the existing `Acks` table
* columns). The id value is treated as a string in the URL so deep
* links (`/acks?ack=42`) round-trip identically across the app, even
* though the backend `Ack.id` is a numeric `AckDrawer` does the
* `Number()` coercion when calling `api.getAck`.
*/
function readAckId(): string | null {
const params = new URLSearchParams(window.location.search);
const value = params.get("ack");
return value === "" ? null : value;
}
/**
* Build the URL we want to push/replace into history.
*
* - `ackId === null` drop the `?ack=` param, preserving any
* other params (e.g. `?page=2&ack=…` keeps `page=2`).
* - `ackId !== null` set the param to the new id, also preserving
* any other params.
*
* We return `pathname + search + hash` (a relative URL) rather than the
* full href `history.pushState` accepts a relative URL and rewriting
* only the relative form keeps the document's origin stable.
*/
function buildUrl(ackId: string | null): string {
const url = new URL(window.location.href);
if (ackId === null) {
url.searchParams.delete("ack");
} else {
url.searchParams.set("ack", ackId);
}
return url.pathname + url.search + url.hash;
}
/**
* Per-ack detail drawer URL state (AckDrawer).
*
* Mirrors `useProviderDrawerUrlState` / `useRemitDrawerUrlState` but
* for the 999-ACK drawer reads `?ack=` from the URL on mount and
* keeps the value in sync with history as the drawer is opened and
* closed.
*
* - `ackId`: the id parsed from the URL (or `null` when the param
* is absent). React state so consumers re-render on changes.
* - `open(id)`: pushes a NEW history entry with `?ack={id}` so the
* browser Back button returns to the previous page (e.g. the
* acks list) and not just to the previously-open ack.
* - `setAckId(id)`: REPLACES the current history entry kept for
* symmetry with the other drawer hooks so a future j/k nav
* handler doesn't have to special-case ack ids.
* - `close()`: pushes a NEW entry that strips the param, so Back
* from the closed drawer returns to whatever page the user was
* on before opening the drawer.
*
* The hook subscribes to `popstate` so that browser Back/Forward
* (which fire popstate rather than our own pushState) propagate into
* the React state. Without this, hitting Back would change the URL
* but leave the drawer open on the stale id.
*/
export function useAckDrawerUrlState(): {
ackId: string | null;
open: (id: string) => void;
close: () => void;
setAckId: (id: string) => void;
} {
const [ackId, setAckIdState] = useState<string | null>(() => readAckId());
const open = useCallback((id: string) => {
window.history.pushState(null, "", buildUrl(id));
setAckIdState(id);
}, []);
const setAckId = useCallback((id: string) => {
window.history.replaceState(null, "", buildUrl(id));
setAckIdState(id);
}, []);
const close = useCallback(() => {
window.history.pushState(null, "", buildUrl(null));
setAckIdState(null);
}, []);
useEffect(() => {
const onPopState = () => {
setAckIdState(readAckId());
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
return { ackId, open, close, setAckId };
}
+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;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
/* Subtle "lit from above" wash gives every page a slightly
lighter zone in the upper third without lifting the base
color. A vertical gradient + a soft top-center halo, both
very low opacity. The composition is additive: a soft glow
behind the page header, fading to nothing well before the
fold. Pointer-events unaffected (it's a background). */
background-image:
radial-gradient(ellipse 90% 38% at 50% -6%, hsla(220, 28%, 26%, 0.22), transparent 62%),
linear-gradient(180deg, hsla(220, 22%, 14%, 0.30) 0%, hsla(220, 22%, 8%, 0.10) 22%, transparent 42%);
background-attachment: fixed;
}
/* A single, precise light source anchored top-right.
One light, not two an instrument, not a wash. */
/* A precise top-right accent + a complementary soft top-center
softbox. Two light sources, both very subtle the result reads
as "the chrome is being lit from above" rather than "there's a
gradient in the background". The softbox adds the small lift
the cards sit in; the accent preserves the instrument character. */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background: radial-gradient(
55rem 35rem at 100% -15%,
hsla(212, 100%, 60%, 0.07),
transparent 62%
);
background:
radial-gradient(ellipse 70% 38% at 50% 0%, hsla(220, 32%, 28%, 0.16), transparent 65%),
radial-gradient(55rem 35rem at 100% -15%, hsla(212, 100%, 60%, 0.09), transparent 62%);
}
/* Hairline grid the substrate of a precision instrument.
Sits behind everything, gives the dark surfaces a sense of
scale and the work a sense of "field". */
scale and the work a sense of "field". Slightly more present
than before so the texture actually reads against the softer
top wash. */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.5;
opacity: 0.6;
background-image:
linear-gradient(to right, hsl(222 10% 16% / 0.18) 1px, transparent 1px),
linear-gradient(to bottom, hsl(222 10% 16% / 0.18) 1px, transparent 1px);
linear-gradient(to right, hsl(222 10% 18% / 0.20) 1px, transparent 1px),
linear-gradient(to bottom, hsl(222 10% 18% / 0.20) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%);
@@ -226,22 +239,40 @@
}
/* Soft card surface a 1px hairline + a 1px inner highlight to
give the surface a sense of depth without a heavy shadow. */
give the surface a sense of depth without a heavy shadow.
The vertical gradient (lighter at top, darker at bottom) plus
a slightly more present inner highlight makes the card read
as a subtle "lit from above" panel each card becomes its
own small lighter area without lifting the base card color. */
.surface {
background-color: hsl(var(--card));
background: linear-gradient(
180deg,
hsl(222 16% 10.5%) 0%,
hsl(222 16% 8.5%) 55%,
hsl(222 16% 7.5%) 100%
);
border: 1px solid hsl(var(--border));
box-shadow:
inset 0 1px 0 0 hsl(0 0% 100% / 0.04),
inset 0 1px 0 0 hsl(0 0% 100% / 0.06),
0 1px 2px 0 hsl(0 0% 0% / 0.4);
}
/* Subtle elevated surface used for cards that should lift a
little more than the default. */
little more than the default. Same "lit from above" treatment
as .surface, with a touch more lift in the gradient stops and
a more visible inner highlight. The card body becomes a
slightly more present lighter area than the surrounding
chrome. */
.surface-2 {
background: linear-gradient(180deg, hsl(222 16% 9%) 0%, hsl(222 16% 7.5%) 100%);
background: linear-gradient(
180deg,
hsl(222 16% 11%) 0%,
hsl(222 16% 9%) 55%,
hsl(222 16% 7.5%) 100%
);
border: 1px solid hsl(var(--border));
box-shadow:
inset 0 1px 0 0 hsl(0 0% 100% / 0.05),
inset 0 1px 0 0 hsl(0 0% 100% / 0.08),
0 1px 3px 0 hsl(0 0% 0% / 0.5);
}
+135
View File
@@ -217,4 +217,139 @@ describe("api GET helpers", () => {
);
await expect(api.serializeClaim837("ghost")).rejects.toBeInstanceOf(ApiError);
});
it("exportBatch837 returns the blob and filename from a 200 ZIP response", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // ZIP magic
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition":
'attachment; filename="batch-abc123-3-claims.zip"',
},
})
);
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
expect(out.filename).toBe("batch-abc123-3-claims.zip");
expect(out.blob).toBeInstanceOf(Blob);
// POST to the batch-scoped endpoint with claim_ids in the body.
const called = mockFetch.mock.calls[0][0] as string;
expect(called).toContain("/api/batches/abc123/export-837");
const init = mockFetch.mock.calls[0][1] as RequestInit;
expect(init.method).toBe("POST");
const body = JSON.parse(init.body as string);
expect(body).toEqual({ claim_ids: ["CLM-1", "CLM-2", "CLM-3"] });
});
it("exportBatch837 parses the X-Cyclone-Serialize-Errors header into serializeErrors", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
const errs = [
{ claim_id: "CLM-2", reason: "no raw_json" },
{ claim_id: "CLM-3", reason: "raw_json invalid: ..." },
];
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition":
'attachment; filename="batch-abc123-1-claims.zip"',
"x-cyclone-serialize-errors": JSON.stringify(errs),
},
})
);
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
expect(out.serializeErrors).toEqual(errs);
});
it("exportBatch837 returns an empty serializeErrors array when the header is missing", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition": 'attachment; filename="batch-x-1-claims.zip"',
},
})
);
const out = await api.exportBatch837("x", ["CLM-1"]);
expect(out.serializeErrors).toEqual([]);
});
it("exportBatch837 falls back to a default filename when Content-Disposition is missing", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: { "content-type": "application/zip" },
})
);
const out = await api.exportBatch837("xyz", ["CLM-1"]);
expect(out.filename).toBe("batch-xyz-claims.zip");
});
it("exportBatch837 throws ApiError on 404", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({ error: "Not found", detail: "unknown batch: ghost" }),
{ status: 404, headers: { "content-type": "application/json" } }
)
);
await expect(
api.exportBatch837("ghost", ["CLM-1"])
).rejects.toBeInstanceOf(ApiError);
});
it("exportBatch837 throws on empty claim_ids without making a request", async () => {
await expect(api.exportBatch837("any", [])).rejects.toThrow();
expect(mockFetch).not.toHaveBeenCalled();
});
it("getBatchDiff surfaces a clear ApiError when a backend 400 wraps detail in a nested object", async () => {
// The backend can wrap structured errors as
// `{"detail": {"error": "Missing param", "detail": "..."}}`. The
// client must walk into the nested `detail` so the user sees the
// human-readable message rather than a blob of raw JSON.
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({
detail: {
error: "Missing param",
detail: "Both ?a=<batch_id> and ?b=<batch_id> are required.",
},
}),
{ status: 400, headers: { "content-type": "application/json" } }
)
);
let caught: unknown;
try {
await api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "930f25e07eab4ac0908c9770689385e1");
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(ApiError);
expect((caught as ApiError).status).toBe(400);
// The unwrapped message must surface — not the raw JSON.
expect((caught as ApiError).message).toBe(
"Both ?a=<batch_id> and ?b=<batch_id> are required."
);
});
it("getBatchDiff throws ApiError(400) before hitting the network if either id is empty", async () => {
// Defense-in-depth: the hook's `enabled` guard should prevent this
// from ever firing, but if it ever does, the client must refuse to
// make a malformed request and surface a clear local error.
await expect(api.getBatchDiff("", "930f25e07eab4ac0908c9770689385e1")).rejects.toMatchObject({
status: 400,
message: expect.stringContaining("?a=<batch_id>"),
});
await expect(api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "")).rejects.toMatchObject({
status: 400,
message: expect.stringContaining("?b=<batch_id>"),
});
// Network must not be touched.
expect(mockFetch).not.toHaveBeenCalled();
});
});
+96 -5
View File
@@ -175,12 +175,24 @@ async function readErrorBody(res: Response): Promise<string> {
try {
const t = await res.text();
if (!t) return "";
// FastAPI errors are `{ "error": "...", "detail": "..." }`. Surface the
// detail if present, else the raw text.
// FastAPI errors can be flat `{ "error": "...", "detail": "..." }`
// or wrapped as `{ "detail": { "error": "...", "detail": "..." } }`
// (the wrapper our app uses for structured errors). Walk into nested
// `detail` objects until we hit a string field — guards against the
// raw JSON leaking into the user-visible error message.
try {
const obj = JSON.parse(t) as { detail?: unknown; error?: unknown };
if (typeof obj.detail === "string") return obj.detail;
if (typeof obj.error === "string") return obj.error;
let current: unknown = JSON.parse(t);
for (let depth = 0; depth < 5; depth++) {
if (!current || typeof current !== "object") break;
const obj = current as { detail?: unknown; error?: unknown };
if (typeof obj.detail === "string") return obj.detail;
if (typeof obj.error === "string") return obj.error;
if (obj.detail && typeof obj.detail === "object") {
current = obj.detail;
continue;
}
break;
}
} catch {
// not JSON; fall through
}
@@ -558,6 +570,16 @@ async function getRemittance<T = unknown>(id: string): Promise<T> {
*/
async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
if (!isConfigured) throw notConfiguredError();
// Defense-in-depth: even though the `useBatchDiff` hook gates on both
// ids being non-empty, refuse to fire the request if either is
// missing. Surfaces a clear local error instead of letting the
// backend return its generic "Missing param" 400.
if (typeof a !== "string" || a.length === 0) {
throw new ApiError(400, "Missing param: ?a=<batch_id> is required.");
}
if (typeof b !== "string" || b.length === 0) {
throw new ApiError(400, "Missing param: ?b=<batch_id> is required.");
}
const res = await fetch(
joinUrl(
`/api/batch-diff?${qs({ a, b })}`,
@@ -786,6 +808,74 @@ async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
return { ...mapAck(row), rawJson: row.raw_json };
}
/**
* Download a ZIP of regenerated X12 837 files for a parsed batch.
*
* Drives `POST /api/batches/{batchId}/export-837` with the requested
* claim_ids in the body. The backend returns a binary ZIP whose
* entries follow the HCPF X12 File Naming Standards template
* ``{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (one file per
* successfully serialized claim, with a per-claim millisecond offset
* so every entry has a unique 17-digit timestamp). The ZIP itself is
* named ``batch-{batchId}-{N}-claims.zip`` via Content-Disposition,
* where N is the success count.
*
* Per-claim serialization failures are surfaced via the
* `X-Cyclone-Serialize-Errors` response header (JSON-encoded array of
* `{claim_id, reason}`). The ZIP still contains the successful claims;
* the failures are returned alongside so the UI can show a partial-
* success toast like "Exported 18 · 2 couldn't be regenerated".
*
* Throws `ApiError` on non-2xx 404 (batch missing) is the most
* likely case. The client-side guard rejects empty `claimIds` without
* making a request.
*/
export interface BatchExportResult {
blob: Blob;
filename: string;
serializeErrors: Array<{ claim_id: string; reason: string }>;
}
async function exportBatch837(
batchId: string,
claimIds: string[],
): Promise<BatchExportResult> {
if (!isConfigured) throw notConfiguredError();
if (claimIds.length === 0) {
throw new Error("claimIds is empty");
}
const res = await fetch(
joinUrl(`/api/batches/${encodeURIComponent(batchId)}/export-837`),
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/zip",
},
body: JSON.stringify({ claim_ids: claimIds }),
},
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
const blob = await res.blob();
const cd = res.headers.get("content-disposition") ?? "";
const match = /filename="?([^";]+)"?/i.exec(cd);
const filename = match?.[1] ?? `batch-${batchId}-claims.zip`;
const errHeader = res.headers.get("x-cyclone-serialize-errors");
let serializeErrors: Array<{ claim_id: string; reason: string }> = [];
if (errHeader) {
try {
serializeErrors = JSON.parse(errHeader);
} catch {
// Malformed header — treat as empty rather than failing the download.
serializeErrors = [];
}
}
return { blob, filename, serializeErrors };
}
export const api = {
isConfigured,
baseUrl: BASE_URL,
@@ -799,6 +889,7 @@ export const api = {
listClaims,
getClaimDetail,
serializeClaim837,
exportBatch837,
listRemittances,
getRemittance,
listProviders,
+126 -7
View File
@@ -9,13 +9,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Acks } from "./Acks";
import { api } from "@/lib/api";
vi.mock("@/lib/api", () => ({
api: {
isConfigured: true,
listAcks: vi.fn(),
getAck: vi.fn(),
},
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
api: {
isConfigured: true,
listAcks: vi.fn(),
getAck: vi.fn(),
},
};
});
function renderIntoContainer(element: React.ReactElement): {
container: HTMLDivElement;
@@ -105,6 +109,10 @@ function hasExactlyOneSelectedRow(): boolean {
describe("Acks", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset URL state between tests so a previous `?ack=` doesn't leak.
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
"http://localhost/acks"
);
});
it("renders a single ack row with counts and ack code", async () => {
@@ -448,4 +456,115 @@ describe("Acks", () => {
unmount();
});
it("test_clicking_a_row_opens_the_ack_drawer", async () => {
// SP21 Phase 5 Task 5.3: clicking an acks row drills into the
// matching ack via `?ack=ID` URL state. The AckDrawer mounts
// but the actual content depends on `useAckDetail` — we don't
// need to verify drawer internals here, just that the URL got
// pushed and the drawer portal opens.
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
},
],
total: 1,
returned: 1,
has_more: false,
});
// Stub the per-ack fetch so `useAckDetail` resolves cleanly
// (avoids TanStack Query's "Query data cannot be undefined"
// warning). We only assert on the drawer's presence, so the
// shape doesn't need to be precise.
(api.getAck as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
raw_999_text: "ISA*~\n",
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-uuid-1");
// No drawer yet.
expect(
document.body.querySelector('[data-testid="ack-drawer"]')
).toBeNull();
// Click the row.
const row = rowAt(0);
expect(row).not.toBeNull();
await act(async () => {
row!.click();
await Promise.resolve();
});
await settle(
() => document.body.querySelector('[data-testid="ack-drawer"]') !== null
);
// The drawer is now in the DOM.
expect(
document.body.querySelector('[data-testid="ack-drawer"]')
).not.toBeNull();
unmount();
});
it("test_deep_link_with_ack_param_opens_drawer_on_mount", async () => {
// /acks?ack=42 deep link → drawer opens on mount without a click.
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
},
],
total: 1,
returned: 1,
has_more: false,
});
(api.getAck as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 42,
sourceBatchId: "b-uuid-1",
acceptedCount: 3,
rejectedCount: 1,
receivedCount: 4,
ackCode: "P",
parsedAt: "2026-06-20T12:00:00Z",
raw_999_text: "ISA*~\n",
});
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
"http://localhost/acks?ack=42"
);
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("b-uuid-1");
await settle(
() => document.body.querySelector('[data-testid="ack-drawer"]') !== null
);
// The drawer is in the DOM on first render.
expect(
document.body.querySelector('[data-testid="ack-drawer"]')
).not.toBeNull();
unmount();
});
});
+403 -693
View File
File diff suppressed because it is too large Load Diff
+110
View File
@@ -21,6 +21,7 @@ vi.mock("@/lib/api", () => ({
api: {
isConfigured: true,
listActivity: vi.fn(),
getRemittance: vi.fn(),
},
}));
@@ -158,6 +159,14 @@ describe("ActivityLog page filters", () => {
(api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
EMPTY,
);
// Default for the per-remit detail fetch — the drawer fetches
// this whenever `?remit=` is in the URL or `remit_received`
// events drill in. Return a never-resolving promise so the
// drawer stays in the loading state; the smoke tests only
// assert the drawer mounts.
(
api.getRemittance as unknown as ReturnType<typeof vi.fn>
).mockReturnValue(new Promise(() => {}));
});
it("test_renders_filter_controls_when_mounted", async () => {
@@ -447,3 +456,104 @@ describe("ActivityLog page filters", () => {
unmount();
});
});
describe("SP21 Task 4.7: ActivityLog → RemitDrawer wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
(api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
EMPTY,
);
(
api.getRemittance as unknown as ReturnType<typeof vi.fn>
).mockReturnValue(new Promise(() => {}));
});
it("clicking a remit_received event opens the RemitDrawer", async () => {
(api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: "A-1",
kind: "remit_received",
message: "Remit PCN-1 received",
timestamp: "2026-06-20T10:00:00Z",
remittanceId: "REM-1",
},
],
total: 1,
returned: 1,
has_more: false,
});
const { unmount } = renderActivity();
// Wait for the row to render so we can click it.
await settle(() =>
document.body.textContent?.includes("Remit PCN-1 received") ?? false,
);
// Drawer should not be mounted before the click.
expect(document.body.querySelector('[data-testid="remit-drawer"]')).toBeNull();
// Find the <li role="button"> row and click it. ActivityFeed renders
// each row as a button-role <li> when `onItemClick` is provided, with
// an aria-label like "View remit received: <message>".
const row = document.body.querySelector('[aria-label^="View remit received"]') as
| HTMLLIElement
| null;
expect(row).not.toBeNull();
await act(async () => {
row!.click();
});
// After click, the RemitDrawer should be mounted (with the skeleton,
// since the never-resolving getRemittance keeps it in loading state).
await settle(
() => document.body.querySelector('[data-testid="remit-drawer"]') !== null,
);
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).not.toBeNull();
unmount();
});
it("deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
(api.listActivity as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: "A-1",
kind: "remit_received",
message: "Remit PCN-1 received",
timestamp: "2026-06-20T10:00:00Z",
remittanceId: "REM-7",
},
],
total: 1,
returned: 1,
has_more: false,
});
// The hook reads `?remit=` from `window.location.search`, so set
// BOTH the MemoryRouter initial entry (for the page's URL display)
// AND `window.happyDOM.setURL` (for the hook).
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
.happyDOM.setURL("http://localhost/activity?remit=REM-7");
const { unmount } = renderActivity({
initialEntries: ["/activity?remit=REM-7"],
});
await settle(
() => document.body.querySelector('[data-testid="remit-drawer"]') !== null,
);
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).not.toBeNull();
unmount();
// Reset URL so any subsequent tests see a clean /activity URL.
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
.happyDOM.setURL("http://localhost/activity");
});
});
+52 -2
View File
@@ -1,12 +1,16 @@
import { useCallback, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { useSearchParams, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { useActivity } from "@/hooks/useActivity";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { eventKindToUrl } from "@/lib/event-routing";
import { TailStatusPill } from "@/components/TailStatusPill";
import { PageHeader } from "@/components/PageHeader";
import { ActivityFeed } from "@/components/ActivityFeed";
import { ActivityFilters, type SinceValue } from "@/components/ActivityFilters";
import { RemitDrawer } from "@/components/RemitDrawer";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
@@ -35,6 +39,13 @@ function useSinceIso(since: SinceValue): string | undefined {
export function ActivityLog() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
// SP21 Phase 4 Task 4.7: `remit_received` events with a
// remittanceId drill into the RemitDrawer. Calling `open(id)`
// pushes `?remit=ID` onto the current URL (no navigation away
// from `/activity`), so the activity feed stays visible behind
// the drawer and the drawer portals in over it.
const { remitId, open, close } = useRemitDrawerUrlState();
const selectedKinds = useMemo<ActivityKind[]>(
() =>
@@ -163,9 +174,48 @@ export function ActivityLog() {
}
/>
) : (
<ActivityFeed items={items} emptyMessage="No activity recorded yet." />
<ActivityFeed
items={items}
emptyMessage="No activity recorded yet."
onItemClick={(evt) => {
// SP21 Phase 4 Task 4.7: drill into the right surface
// based on event kind. `claim_*` and `provider_added`
// navigate away via `eventKindToUrl` (the Dashboard uses
// the same helper). `remit_received` events stay on
// `/activity` and open the RemitDrawer via `open(id)`
// — `eventKindToUrl` still returns `null` for that kind
// because cross-page navigation isn't the right UX here
// (we want to keep the activity feed as context behind
// the drawer). Anything else falls back to the
// "coming soon" toast so the click still gives feedback.
const url = eventKindToUrl(evt);
if (url) navigate(url);
else if (evt.kind === "remit_received" && evt.remittanceId) {
open(evt.remittanceId);
} else {
toast.info(
`Drill for ${evt.kind.replace(/_/g, " ")} coming in a later phase.`,
);
}
}}
/>
)}
</div>
{/* SP21 Phase 4 Task 4.7: RemitDrawer mount. The activity
feed's `remit_received` rows drill into the drawer via
`open()`. `remits` is empty (the activity feed doesn't
keep a flat list of remits around), so j/k is a no-op
here closing reverts the URL via `close()`. */}
<RemitDrawer
remitId={remitId}
remits={[]}
onClose={close}
onNavigate={open}
onToggleHelp={() => {
// ActivityLog has no cheatsheet; `?` is a no-op here.
}}
/>
</div>
);
}
+220
View File
@@ -29,6 +29,7 @@ vi.mock("@/lib/api", () => ({
isConfigured: true,
listBatches: vi.fn(),
getBatchDiff: vi.fn(),
getRemittance: vi.fn(),
},
ApiError: class ApiError extends Error {
constructor(public status: number, message: string) {
@@ -180,6 +181,13 @@ function makeDiffPayload(): BatchDiffResponse {
describe("BatchDiff page", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default for the per-remit detail fetch — the drawer fetches
// this whenever `?remit=` is in the URL. Return a never-resolving
// promise so the drawer stays in the loading state; the smoke
// test only asserts the drawer mounts, not the loaded data.
(
api.getRemittance as unknown as ReturnType<typeof vi.fn>
).mockReturnValue(new Promise(() => {}));
});
afterEach(() => {
@@ -189,6 +197,42 @@ describe("BatchDiff page", () => {
.happyDOM.setURL("http://localhost/batch-diff");
});
it("SP21 Task 4.5: deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
// Pre-set the URL with `?remit=`. The page doesn't surface any
// per-remit rows (the diff payload is claim-level), but the
// drawer must still mount so the URL contract is honored.
//
// `useRemitDrawerUrlState` reads `window.location.search`
// (NOT React Router's search params) so we have to set the
// global window URL via happyDOM AND give MemoryRouter an
// initial entry — both stay in lockstep.
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
.happyDOM.setURL("http://localhost/batch-diff?remit=REM-7");
(
api.listBatches as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue([BATCH_A, BATCH_B]);
const { unmount } = renderIntoContainer(
<BatchDiff />,
["/batch-diff?remit=REM-7"],
);
// Page header must be present + drawer must be open.
await waitFor(
() => !!document.querySelector('[data-testid="batch-diff-page"]'),
"page header mounted",
);
await waitFor(
() => !!document.querySelector('[data-testid="remit-drawer"]'),
"remit drawer mounted via deep link",
);
expect(
document.querySelector('[data-testid="remit-drawer"]'),
).not.toBeNull();
unmount();
});
it("renders the awaiting-picks empty state when no batches are selected", async () => {
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
@@ -437,8 +481,184 @@ describe("BatchDiff page", () => {
).toContain("pick a batch");
unmount();
});
it("SP21 Task 5.6: clicking an added-claim id navigates to /claims?claim=ID", async () => {
// Each ClaimIdCell wraps its id text with DrillableCell, whose
// onClick navigates to /claims?claim=ID. The MemoryRouter gives
// us a router context so the navigation completes; we observe
// it via the rendered pathname on a LocationTracker spy.
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const captured: { pathname: string; search: string } = {
pathname: "/batch-diff",
search: "",
};
const Tracker = () => {
const loc = useLocationSafe();
React.useEffect(() => {
captured.pathname = loc.pathname;
captured.search = loc.search;
}, [loc.pathname, loc.search]);
return null;
};
const { unmount } = renderIntoContainer(
<>
<Tracker />
<BatchDiff />
</>,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => !!document.querySelector('[data-testid="diff-added-row-CLM-3"]'),
"added row rendered",
);
const cell = document.querySelector(
'[data-testid="diff-added-row-CLM-3"] [data-testid="diff-claim-id"]',
);
expect(cell).not.toBeNull();
// DrillableCell wraps the id text in its own <button>; click the
// nearest button ancestor so the drillable onClick fires.
const drillBtn =
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
expect(drillBtn).not.toBeNull();
await act(async () => {
drillBtn!.click();
await Promise.resolve();
});
await waitFor(
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-3",
"navigation to /claims?claim=CLM-3",
);
unmount();
});
it("SP21 Task 5.6: clicking a removed-claim id still navigates (ClaimDrawer handles 404)", async () => {
// The diff can list claim ids that no longer exist in the DB
// (Removed from A means "was in A, not in B" — the claim may
// still exist or may have been purged). Either way, clicking
// the id drills to /claims?claim=ID; the ClaimDrawer's 404
// state (Phase 2) handles the missing-claim case. This test
// only verifies the click path, not the drawer's 404 surface.
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const captured: { pathname: string; search: string } = {
pathname: "/batch-diff",
search: "",
};
const Tracker = () => {
const loc = useLocationSafe();
React.useEffect(() => {
captured.pathname = loc.pathname;
captured.search = loc.search;
}, [loc.pathname, loc.search]);
return null;
};
const { unmount } = renderIntoContainer(
<>
<Tracker />
<BatchDiff />
</>,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => !!document.querySelector('[data-testid="diff-removed-row-CLM-2"]'),
"removed row rendered",
);
const cell = document.querySelector(
'[data-testid="diff-removed-row-CLM-2"] [data-testid="diff-claim-id"]',
);
const drillBtn =
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
expect(drillBtn).not.toBeNull();
await act(async () => {
drillBtn!.click();
await Promise.resolve();
});
await waitFor(
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-2",
"navigation to /claims?claim=CLM-2",
);
unmount();
});
it("SP21 Task 5.6: clicking a changed-claim id navigates to /claims?claim=ID", async () => {
// The Changed row uses the A-side id (per existing test
// assertions). Drill should fire on that id.
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
BATCH_A, BATCH_B,
]);
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
makeDiffPayload(),
);
const captured: { pathname: string; search: string } = {
pathname: "/batch-diff",
search: "",
};
const Tracker = () => {
const loc = useLocationSafe();
React.useEffect(() => {
captured.pathname = loc.pathname;
captured.search = loc.search;
}, [loc.pathname, loc.search]);
return null;
};
const { unmount } = renderIntoContainer(
<>
<Tracker />
<BatchDiff />
</>,
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
);
await waitFor(
() => !!document.querySelector('[data-testid="diff-changed-row-CLM-1"]'),
"changed row rendered",
);
const cell = document.querySelector(
'[data-testid="diff-changed-row-CLM-1"] [data-testid="diff-claim-id"]',
);
const drillBtn =
(cell?.closest("button") as HTMLButtonElement | null) ?? null;
expect(drillBtn).not.toBeNull();
await act(async () => {
drillBtn!.click();
await Promise.resolve();
});
await waitFor(
() => captured.pathname === "/claims" && captured.search === "?claim=CLM-1",
"navigation to /claims?claim=CLM-1",
);
unmount();
});
});
// SP21 Phase 5 Task 5.6: react-router-dom's `useLocation` hook, used
// by the LocationTracker test helper below. Aliased to keep the import
// block tidy alongside React + the page import.
import { useLocation as useLocationSafe } from "react-router-dom";
// Keep `ApiError` referenced so the import isn't tree-shaken by
// vitest's transformer when the mock factory above is hoisted.
void ApiError;
+378 -573
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 { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
import { KpiCard } from "@/components/KpiCard";
import { PageHeader } from "@/components/PageHeader";
import { BatchesList, BatchesListSkeleton } from "@/components/BatchesList";
import {
BatchDetailContent,
@@ -16,6 +18,7 @@ import { useBatches } from "@/hooks/useBatches";
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
import { api, ApiError, type BatchSummary } from "@/lib/api";
import { cn } from "@/lib/utils";
import { fmt } from "@/lib/format";
import type { ParseResult837, ParseResult835 } from "@/types";
function readBatchId(): string | null {
@@ -120,12 +123,16 @@ function useBatchDetail(id: string | null): {
const HELP_NOOP = () => {};
/**
* Batches page hybrid Magazine Spread treatment.
* Batches page dark instrument treatment, in the same chrome
* language as Dashboard / Upload / Reconciliation / Acks.
*
* Dark editorial hero torn-page fold cream paper plane with the
* batch register (KPI strip + tabular list). Click a row to open the
* detail drawer (rendered above the page, dimmed via the body
* opacity).
* PageHeader hero (eyebrow + display title + status pill + ghost
* watermark) KpiCard strip (4 tiles) single Card wrapping the
* tabular register quiet hairline footer.
*
* Click a row to open the detail drawer (rendered above the page,
* dimmed via the body opacity). Keyboard: / step through
* batches while the drawer is open; Esc closes.
*/
export function Batches() {
const { data, isLoading, isError, error, refetch } = useBatches(100);
@@ -166,7 +173,7 @@ export function Batches() {
const errKind = batchErrorKind(detailError);
// -----------------------------------------------------------------
// Aggregate metrics for the § 01 KPI strip
// Aggregate metrics for the KPI strip + hero copy.
// -----------------------------------------------------------------
const totals = items.reduce(
(acc, b) => ({
@@ -178,14 +185,20 @@ export function Batches() {
{ count: 0, p837: 0, p835: 0, claims: 0 },
);
const lastParsed = items[0]?.parsedAt;
// The ghost watermark carries the on-file count, scaled like the
// other pages (clamp 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 foldDelay = 220;
const titleDelay = 320;
const kpiDelay = 460;
const tableDelay = 600;
const kpiDelay = 140;
const tableDelay = 240;
const footerDelay = 320;
return (
<>
@@ -219,541 +232,196 @@ export function Batches() {
<div
data-testid="batches-page-body"
className={cn(
"space-y-0 animate-fade-in transition-opacity",
"space-y-6 lg:space-y-10 animate-fade-in transition-opacity",
dimBackground && "pointer-events-none opacity-60",
)}
>
{/* =================================================================
HERO DARK EDITORIAL HEADER
HERO dark editorial header
================================================================= */}
<section
className="relative pt-6 pb-8 lg:pt-9 lg:pb-10"
className="relative animate-fade-in overflow-hidden"
style={{ animationDelay: `${heroDelay}ms` }}
>
{/* Ghost "PARSED" watermark — a print-shop stamp behind the title. */}
<div
aria-hidden="true"
className="pointer-events-none select-none absolute inset-x-0 top-[58%] -translate-y-1/2 whitespace-nowrap display text-center"
className="pointer-events-none select-none absolute -right-4 top-1/2 -translate-y-1/2 whitespace-nowrap display text-foreground"
style={{
fontSize: "clamp(160px, 20vw, 300px)",
letterSpacing: "-0.05em",
opacity: 0.05,
fontSize: "clamp(72px, 12vw, 140px)",
letterSpacing: "-0.04em",
opacity: 0.045,
lineHeight: 1,
color: "hsl(var(--surface-ink))",
}}
>
PARSED
{watermark}
</div>
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-8 items-end mb-7">
<div className="min-w-0 max-w-3xl">
<div className="flex items-center gap-3 mb-5">
<div className="h-px w-14 bg-foreground/25" />
<span className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground">
Batches · Register
</span>
<span
className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground/60 hidden sm:inline"
aria-hidden
>
· {totals.count} on file
</span>
</div>
<h1 className="display text-[48px] sm:text-[64px] lg:text-[80px] leading-[0.92] text-foreground tracking-[-0.04em]">
Parsed{" "}
<span className="italic text-muted-foreground/85">batches.</span>
</h1>
<div className="relative z-10 flex items-end justify-between gap-4 sm:gap-6 flex-wrap">
<div className="min-w-0">
<PageHeader
eyebrow={`Batches · Register · ${totals.count} on file`}
title={
<>
Parsed{" "}
<span className="italic text-muted-foreground/85">
batches.
</span>
</>
}
subtitle={
<>
Every 837P and 835 file the backend has ingested. Click a
row for envelope, summary, and a peek at the contained
claims.
</>
}
/>
</div>
<div className="flex flex-col items-start lg:items-end gap-3">
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3.5 py-2 text-[11.5px] mono uppercase tracking-[0.14em] text-muted-foreground backdrop-blur">
<Files
className="h-3.5 w-3.5"
strokeWidth={1.75}
style={{ color: "hsl(var(--accent))" }}
/>
837P · 835 · envelope & summary
</div>
<kbd
className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70"
>
Tap a row to open the drawer
</kbd>
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3 py-1.5 text-[11px] mono uppercase tracking-[0.12em] text-muted-foreground backdrop-blur">
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[hsl(var(--accent))]">
<span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--accent))] opacity-60 animate-ping" />
</span>
<Files className="h-3.5 w-3.5" strokeWidth={1.75} />
837P · 835 · envelope &amp; summary
</div>
</div>
<div className="relative z-10 max-w-2xl">
<p className="text-[14px] text-muted-foreground leading-relaxed">
Every 837P and 835 file the backend has ingested. Click a row
for envelope, summary, and a peek at the contained claims.
</p>
</div>
</section>
{/* =================================================================
FOLD TORN PAGE
KPI STRIP four standard KpiCards, accent tokens instead of
paper-tinted custom tiles.
================================================================= */}
<div
aria-hidden
className="relative h-14"
style={{ animationDelay: `${foldDelay}ms` }}
<section
aria-label="Batch register metrics"
className="grid gap-3.5 grid-cols-2 md:grid-cols-4 animate-fade-in-up"
style={{ animationDelay: `${kpiDelay}ms` }}
>
<div
className="absolute inset-x-0 top-0 h-1/2"
style={{
background:
"linear-gradient(to bottom, hsl(0 0% 0% / 0.55), transparent)",
}}
<KpiCard
label="On file"
value={fmt.num(totals.count)}
icon={Layers}
accent="default"
hint={totals.count === 1 ? "batch" : "batches"}
/>
<div
className="absolute inset-x-0 bottom-0 h-1/2"
style={{
background:
"linear-gradient(to top, hsl(30 14% 14% / 0.18), transparent)",
}}
<KpiCard
label="837P"
value={fmt.num(totals.p837)}
icon={GitBranch}
accent="accent"
hint="professional claims"
/>
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
style={{
background:
"linear-gradient(to right, transparent, hsl(30 14% 14% / 0.5) 12%, hsl(30 14% 14% / 0.7) 50%, hsl(30 14% 14% / 0.5) 88%, transparent)",
}}
<KpiCard
label="835"
value={fmt.num(totals.p835)}
icon={Receipt}
accent="warning"
hint="remittance advice"
/>
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-2"
style={{ pointerEvents: "none" }}
>
{Array.from({ length: 48 }, (_, i) => (
<span
key={i}
className="block h-[3px] w-[3px] rounded-full"
style={{ backgroundColor: "hsl(30 14% 14% / 0.22)" }}
/>
))}
</div>
<div className="relative z-10 mx-auto h-full flex items-center justify-center gap-3 bg-background px-4 w-fit">
<span
className="display italic"
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
>
</span>
<span
className="mono uppercase tracking-[0.24em]"
style={{
color: "hsl(var(--muted-foreground))",
fontSize: 11,
fontWeight: 500,
}}
>
The register
</span>
<span
className="display italic"
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
>
</span>
</div>
</div>
<KpiCard
label="Claims"
value={fmt.num(totals.claims)}
accent="success"
hint="total in register"
/>
</section>
{/* =================================================================
PAPER PLANE
REGISTER single Card wrapping the tabular list. Same
chrome language as the rest of the app: eyebrow + display
title + sidecar hint, dark-tone table.
================================================================= */}
<div
className="relative max-w-[1280px] mx-auto"
style={{
animationDelay: `${titleDelay}ms`,
backgroundColor: "hsl(var(--surface))",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 30px 80px -24px hsl(0 0% 0% / 0.45)",
}}
<section
aria-label="Batch register"
className="animate-fade-in-up"
style={{ animationDelay: `${tableDelay}ms` }}
>
{/* Paper grain */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.08 0 0 0 0 0.05 0 0 0 0.9 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\")",
backgroundSize: "160px 160px",
mixBlendMode: "multiply",
}}
/>
{/* Title block */}
<div
className="relative px-8 lg:px-14 pt-12 pb-9 border-b animate-fade-in-up"
style={{
animationDelay: `${titleDelay}ms`,
borderColor: "hsl(30 14% 14% / 0.12)",
}}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-0 bottom-0 w-px"
style={{ backgroundColor: "hsl(30 14% 14% / 0.14)" }}
/>
<div className="flex items-end justify-between gap-8 flex-wrap">
<div>
<div
className="mono text-[12px] uppercase tracking-[0.24em] mb-4 font-medium"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Register · Parsed batches
</div>
<h2
className="display leading-[0.92] tracking-[-0.04em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(48px, 7vw, 96px)",
fontWeight: 400,
}}
>
The register.
</h2>
<div
className="mt-4 display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: "clamp(15px, 1.3vw, 18px)",
lineHeight: 1.4,
maxWidth: "32ch",
}}
>
One row per ingested file the kind, the input filename,
how many claims it carried, and the day it was parsed.
</div>
</div>
<div className="text-right shrink-0">
<div
className="mono text-[11px] uppercase tracking-[0.24em] mb-1.5 font-medium"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
On file
</div>
<div
className="display tabular-nums"
style={{
color: "hsl(var(--surface-ink))",
fontSize: 26,
lineHeight: 1.1,
}}
>
{totals.count}
</div>
<div
className="mono text-[11px] mt-1"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
batch{totals.count === 1 ? "" : "es"} · {totals.claims}{" "}
claim{totals.claims === 1 ? "" : "s"}
</div>
</div>
</div>
</div>
{/* KPI strip — § 01 Vital signs */}
<section
aria-label="Batch register metrics"
className="relative px-8 lg:px-14 py-7 animate-fade-in-up"
style={{ animationDelay: `${kpiDelay}ms` }}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-7 flex flex-col items-center gap-1"
>
<span
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
§ 01
</span>
<div
className="w-px h-10"
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
/>
<span
className="display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: 11,
writingMode: "vertical-rl",
transform: "rotate(180deg)",
letterSpacing: "0.16em",
}}
>
Vital signs
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<BatchesKpiTile
label="On file"
value={totals.count}
icon={<Layers className="h-3 w-3" strokeWidth={1.75} />}
tone="ink"
/>
<BatchesKpiTile
label="837P"
value={totals.p837}
icon={<GitBranch className="h-3 w-3" strokeWidth={1.75} />}
tone="blue"
/>
<BatchesKpiTile
label="835"
value={totals.p835}
icon={<Receipt className="h-3 w-3" strokeWidth={1.75} />}
tone="amber"
/>
<BatchesKpiTile
label="Claims"
value={totals.claims}
tone="success"
/>
</div>
</section>
{/* Table — § 02 The register */}
<section
aria-label="Batch register"
className="relative px-8 lg:px-14 pb-8 lg:pb-10 animate-fade-in-up"
style={{ animationDelay: `${tableDelay}ms` }}
>
<div
aria-hidden
className="absolute left-7 lg:left-12 top-9 flex flex-col items-center gap-1"
>
<span
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
§ 02
</span>
<div
className="w-px h-12"
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
/>
<span
className="display italic"
style={{
color: "hsl(var(--surface-ink-2))",
fontSize: 11,
writingMode: "vertical-rl",
transform: "rotate(180deg)",
letterSpacing: "0.16em",
}}
>
The register
</span>
</div>
<div
className="pt-6 border-t"
style={{
borderTopStyle: "double",
borderTopWidth: 3,
borderColor: "hsl(30 14% 14% / 0.12)",
}}
>
<div className="flex items-end justify-between gap-6 flex-wrap mb-5">
<div>
<div
className="mono text-[11.5px] uppercase tracking-[0.24em] font-semibold mb-2"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
<Card>
<CardContent className="p-6 lg:p-7 space-y-5">
<div className="flex items-end justify-between gap-6 flex-wrap">
<div className="min-w-0">
<div className="eyebrow flex items-center gap-2 mb-2">
<span className="inline-block h-px w-6 bg-foreground/20" />
The register
</div>
<h3
className="display leading-[0.98] tracking-[-0.03em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(24px, 2.6vw, 32px)",
fontWeight: 400,
}}
>
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
All <span className="italic">ingests</span>, newest first.
</h3>
</h2>
</div>
<div
className="text-[12.5px] display italic"
style={{ color: "hsl(var(--surface-ink-2))", maxWidth: 380 }}
>
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
Tap a row to open the envelope drawer. Use{" "}
<kbd
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
style={{
borderColor: "hsl(30 14% 14% / 0.20)",
color: "hsl(var(--surface-ink))",
}}
>
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80 mono text-[10.5px] uppercase tracking-[0.14em]">
</kbd>{" "}
/{" "}
<kbd
className="mono not-italic text-[10.5px] uppercase tracking-[0.14em] px-1 py-0.5 rounded-sm border"
style={{
borderColor: "hsl(30 14% 14% / 0.20)",
color: "hsl(var(--surface-ink))",
}}
>
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80 mono text-[10.5px] uppercase tracking-[0.14em]">
</kbd>{" "}
inside the drawer to step.
</div>
</p>
</div>
{isError ? (
<div
className="rounded-md border p-5"
style={{
borderColor: "hsl(30 14% 14% / 0.16)",
backgroundColor: "hsl(36 22% 96%)",
}}
>
<div className="pt-5 border-t border-border/40">
{isError ? (
<ErrorState
message="Couldn't load batches from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
</div>
) : isLoading ? (
<div
className="rounded-md border p-4 space-y-2"
style={{
borderColor: "hsl(30 14% 14% / 0.10)",
backgroundColor: "hsl(36 22% 96%)",
}}
>
) : isLoading ? (
<BatchesListSkeleton />
</div>
) : items.length === 0 ? (
<div
className="rounded-md border p-10 text-center"
style={{
borderColor: "hsl(30 14% 14% / 0.16)",
borderStyle: "dashed",
backgroundColor: "hsl(36 22% 96%)",
}}
>
<EmptyState
eyebrow="Batches · awaiting first ingest"
message="Upload an 837P or 835 file on the Upload page to populate this list."
/>
</div>
) : (
<div
className="rounded-md border overflow-hidden"
style={{
borderColor: "hsl(30 14% 14% / 0.10)",
backgroundColor: "hsl(36 22% 98%)",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
}}
>
<BatchesList
items={items}
openId={batchId}
onOpen={open}
tone="paper"
/>
</div>
)}
</div>
</section>
) : items.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 bg-card/40">
<EmptyState
eyebrow="Batches · awaiting first ingest"
message="Upload an 837P or 835 file on the Upload page to populate this list."
/>
</div>
) : (
<div className="rounded-md border border-border/60 overflow-hidden bg-card/40">
<BatchesList
items={items}
openId={batchId}
onOpen={open}
/>
</div>
)}
</div>
</CardContent>
</Card>
</section>
{/* Footer */}
<div
className="relative px-8 lg:px-14 py-5 border-t flex items-center justify-between mono text-[10.5px] uppercase tracking-[0.18em]"
style={{
borderColor: "hsl(30 14% 14% / 0.12)",
color: "hsl(var(--surface-ink-3))",
}}
>
<span>End of register</span>
<span>Cyclone · Batches</span>
{/* =================================================================
FOOTER single hairline-separated status row. Replaces the
warm-paper "End of register" treatment with a quiet
instrument-style status line in the same rhythm as the
other pages.
================================================================= */}
<section
aria-label="Register status"
className="animate-fade-in-up flex items-center justify-between gap-4 flex-wrap pt-5 border-t border-border/40"
style={{ animationDelay: `${footerDelay}ms` }}
>
<div className="flex items-center gap-2 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--accent))]" />
Cyclone · Batches
</div>
<div className="flex items-center gap-3 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span>
{totals.count} {totals.count === 1 ? "row" : "rows"}
</span>
<span className="text-muted-foreground/30" aria-hidden>
·
</span>
<span>last parsed {lastParsed ? fmt.dateShort(lastParsed) : "—"}</span>
</div>
</div>
<div className="flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span>open drawer</span>
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80">
</kbd>
</div>
</section>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// BatchesKpiTile — paper-toned metric for the batch register.
// Mirrors AckKpiTile / KpiTile in the other hybrid pages.
// ---------------------------------------------------------------------------
function BatchesKpiTile({
label,
value,
tone,
icon,
}: {
label: string;
value: number;
tone: "blue" | "amber" | "ink" | "success";
icon?: React.ReactNode;
}) {
const accentMap = {
blue: "hsl(212 100% 45%)",
amber: "hsl(36 92% 50%)",
ink: "hsl(var(--surface-ink))",
success: "hsl(152 64% 38%)",
} as const;
const tintMap = {
blue: "hsl(212 85% 92%)",
amber: "hsl(36 82% 92%)",
ink: "hsl(36 22% 90%)",
success: "hsl(152 50% 88%)",
} as const;
const accent = accentMap[tone];
const tint = tintMap[tone];
return (
<div
className="relative rounded-xl p-5 overflow-hidden border"
style={{
backgroundColor: "hsl(var(--surface))",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06), inset 3px 0 0 0 hsl(0 0% 100% / 0.4)",
borderColor: "hsl(30 14% 14% / 0.10)",
}}
>
<div
aria-hidden
className="absolute left-0 top-5 bottom-5 w-[3px] rounded-r-sm"
style={{ backgroundColor: accent, opacity: 0.85 }}
/>
<div className="flex items-center justify-between mb-2.5">
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{label}
</div>
<div
className="h-5 w-5 rounded-md flex items-center justify-center"
style={{ backgroundColor: tint, color: accent }}
>
{icon ?? (
<span
className="block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: accent }}
/>
)}
</div>
</div>
<div
className="display tabular-nums tracking-[-0.04em]"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(28px, 3vw, 40px)",
lineHeight: 1,
fontWeight: 400,
}}
>
{value}
</div>
</div>
);
}
+32 -15
View File
@@ -10,6 +10,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { Claims } from "./Claims";
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
import { api } from "@/lib/api";
import { useTailStore } from "@/store/tail-store";
import type { Claim, ClaimDetail } from "@/types";
@@ -194,7 +195,16 @@ function renderClaimsAt(initialEntries: string[]): {
React.createElement(
MemoryRouter,
{ initialEntries },
React.createElement(Claims),
// SP21 Phase 5 Task 5.8/5.9: the ClaimDrawer mounted by
// Claims now uses useDrillStack() in PartiesGrid +
// ValidationPanel. Wrap in a DrillStackProvider so the
// hook has a context (the provider is also mounted at
// the App root in production).
React.createElement(
DrillStackProvider,
null,
React.createElement(Claims),
),
),
),
);
@@ -313,17 +323,19 @@ describe("Claims page drawer wiring", () => {
document.body.querySelector('[data-testid="claim-drawer"]')
).not.toBeNull();
// Header shows the claim id — proves the id propagated end-to-end
// (URL → useDrawerUrlState → ClaimDrawer prop → useClaimDetail →
// header render), not just that some drawer is mounted.
await settle(
() =>
document.body.querySelector('[data-testid="header-id"]')?.textContent ===
"CLM-1"
);
expect(
document.body.querySelector('[data-testid="header-id"]')?.textContent
).toBe("CLM-1");
// SP21 Phase 5 Task 5.10: the claim id is now the title of the
// shared DrillDrawerHeader (rendered as an <h2>). Find the h2
// inside the drawer and assert its text matches the claim id —
// proves the id propagated end-to-end (URL → useDrawerUrlState
// → ClaimDrawer prop → useClaimDetail → header render), not
// just that some drawer is mounted.
await settle(() => {
const drawer = document.body.querySelector('[data-testid="claim-drawer"]');
const h2 = drawer?.querySelector("h2");
return h2?.textContent === "CLM-1";
});
const drawer = document.body.querySelector('[data-testid="claim-drawer"]');
expect(drawer?.querySelector("h2")?.textContent).toBe("CLM-1");
});
it("test_escape_closes_the_drawer", async () => {
@@ -360,9 +372,14 @@ describe("Claims page drawer wiring", () => {
// Wait for the header to render — the close button only mounts once
// `useClaimDetail` resolves with a real ClaimDetail (the skeleton /
// error states don't render the header).
// error states don't render the header). SP21 Phase 5 Task 5.10:
// the close button is now inside the shared DrillDrawerHeader;
// find it via its accessible name.
await settle(
() => document.body.querySelector('[data-testid="header-close"]') !== null
() =>
document.body.querySelector(
'[data-testid="claim-drawer"] button[aria-label="Close drawer"]'
) !== null
);
// URL currently carries the claim.
@@ -372,7 +389,7 @@ describe("Claims page drawer wiring", () => {
// onClose → useDrawerUrlState.close(), which pushState's a URL with
// the ?claim= param stripped.
const closeBtn = document.body.querySelector(
'[data-testid="header-close"]'
'[data-testid="claim-drawer"] button[aria-label="Close drawer"]'
) as HTMLButtonElement | null;
expect(closeBtn).not.toBeNull();
await act(async () => {
+301 -4
View File
@@ -1,10 +1,60 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { useEffect, useState } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Inbox from "./Inbox";
import * as inboxApi from "@/lib/inbox-api";
import * as downloadModule from "@/lib/download";
// SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched
// claim drilldown to /claims?claim=ID), so the render harness needs
// a Router context. We use a fresh QueryClient per test so the
// drawer's per-remit query doesn't leak cache between cases, and a
// MemoryRouter so `useNavigate` has a router to push into.
//
// Phase 5 Task 5.4: MemoryRouter doesn't update window.location, so
// the navigation assertion uses a small LocationTracker component
// mounted under the same router that records the current pathname +
// search after each render. Tests then read `tracker.last` instead
// of `window.location`.
function LocationTracker({ on }: { on: (pathname: string, search: string) => void }) {
const loc = useLocation();
useEffect(() => {
on(loc.pathname, loc.search);
}, [loc.pathname, loc.search, on]);
return null;
}
function renderInbox() {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
});
// Capture the last location seen so tests can assert on navigation
// without poking at window.location (MemoryRouter doesn't touch it).
const captured = { pathname: "/inbox", search: "" };
const view = render(
<MemoryRouter initialEntries={["/inbox"]}>
<QueryClientProvider client={qc}>
<LocationTracker
on={(pathname, search) => {
captured.pathname = pathname;
captured.search = search;
}}
/>
<Inbox />
</QueryClientProvider>
</MemoryRouter>,
);
// Attach the tracker so individual tests can read it. Note: the
// captured value updates asynchronously after navigation, but since
// LocationTracker runs in the same render pass as the navigate,
// tests should `waitFor` it.
(view as unknown as { tracker: typeof captured }).tracker = captured;
return view as ReturnType<typeof render> & { tracker: typeof captured };
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
@@ -29,7 +79,7 @@ describe("Inbox page", () => {
}),
}),
);
const { container } = render(<Inbox />);
const { container } = renderInbox();
await waitFor(() => {
expect(container.textContent).toContain("REJECTED");
expect(container.textContent).toContain("PAYER REJECTED");
@@ -65,7 +115,7 @@ describe("Inbox page", () => {
}),
}),
);
const { container } = render(<Inbox />);
const { container } = renderInbox();
await waitFor(() => {
expect(container.textContent).toContain("1");
expect(container.textContent).toContain("items need eyes");
@@ -112,7 +162,7 @@ describe("Inbox page", () => {
});
vi.stubGlobal("fetch", fetchMock);
const { container, getByTestId } = render(<Inbox />);
const { container, getByTestId } = renderInbox();
await waitFor(() => {
expect(container.textContent).toContain("PR1");
});
@@ -202,7 +252,7 @@ describe("Inbox page", () => {
.spyOn(downloadModule, "downloadBlob")
.mockImplementation(() => {});
const { container, getByText } = render(<Inbox />);
const { container, getByText } = renderInbox();
await waitFor(() => {
expect(container.textContent).toContain("C1");
expect(container.textContent).toContain("C2");
@@ -245,4 +295,251 @@ describe("Inbox page", () => {
expect(filename).toBe("resubmit-2-claims.zip");
expect(blob).toBeInstanceOf(Blob);
});
it("SP21 Task 4.4: clicking a candidate row opens the RemitDrawer", async () => {
// Candidates are remits (payer_claim_control_number-keyed) — the
// row's `id` is the remit id, so clicking drills into the
// RemitDrawer via `?remit=ID`.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [],
payer_rejected: [],
candidates: [
{
id: "REM-7",
kind: "remit",
payer_claim_control_number: "REM-7",
charge_amount: 200,
payer_id: "P1",
rendering_provider_npi: "1234567890",
service_date: "2026-06-19",
candidates: [],
},
],
unmatched: [],
done_today: [],
}),
}),
);
const { container } = renderInbox();
await waitFor(() => {
expect(container.textContent).toContain("REM-7");
});
// No drawer yet — the URL is `/inbox` with no `?remit=`.
expect(
container.querySelector('[data-testid="remit-drawer"]')
).toBeNull();
// Click the candidate row. The InboxRow renders as a <tr> with
// the remit id in its first cell; clicking that row bubbles to
// the Lane's onRowClick handler we wired in Task 4.4.
const cell = Array.from(container.querySelectorAll("td")).find(
(td) => td.textContent === "REM-7",
);
const row = cell?.closest("tr");
expect(row).toBeTruthy();
await act(async () => {
fireEvent.click(row as HTMLElement);
});
// Drawer portals into document.body — check there, not container.
await waitFor(() => {
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).not.toBeNull();
});
});
it("SP21 Task 4.4: deep-link ?remit=ID opens the drawer on mount", async () => {
// Pre-set the URL so the hook reads REM-7 off `window.location.search`
// during its `useState` initializer — no click needed. The inbox
// already mounts the drawer, so a deep link to /inbox?remit=REM-7
// should land with the drawer open.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
}),
}),
);
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
"http://localhost/inbox?remit=REM-7",
);
renderInbox();
await waitFor(() => {
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).not.toBeNull();
});
});
it("SP21 Task 5.4: clicking a rejected claim row navigates to /claims?claim=ID", async () => {
// Task 5.4 wires the rejected lane's onRowClick to navigate to
// the ClaimDrawer via `?claim=ID` on the /claims route. The
// MemoryRouter (initial /inbox) lets us observe the URL change
// via the LocationTracker helper mounted in the render harness.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [
{
id: "REJ1",
kind: "claim",
payer_claim_control_number: "REJ1",
charge_amount: 175,
payer_id: "P1",
provider_npi: "1234567890",
state: "rejected",
rejection_reason: "999 reject",
service_date: null,
score: null,
},
],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
}),
}),
);
const view = renderInbox();
await waitFor(() => {
expect(view.container.textContent).toContain("REJ1");
});
// Click the rejected row. The InboxRow renders the claim id as
// its primary text cell; clicking that row bubbles up to the
// Lane's onRowClick handler we wired in Task 5.4.
const cell = Array.from(view.container.querySelectorAll("td")).find(
(td) => td.textContent === "REJ1",
);
const row = cell?.closest("tr");
expect(row).toBeTruthy();
await act(async () => {
fireEvent.click(row as HTMLElement);
});
// MemoryRouter navigates to /claims?claim=REJ1 — verify the
// tracker observed the new pathname + search.
await waitFor(() => {
expect(view.tracker.pathname).toBe("/claims");
expect(view.tracker.search).toBe("?claim=REJ1");
});
});
it("SP21 Task 5.4: clicking a done_today claim row navigates to /claims?claim=ID", async () => {
// done_today rows are claim-shaped — same drill pattern as the
// rejected lane. Verifies that the wiring covers the trailing
// "shipped today" lane too, not just the error lanes.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [
{
id: "DONE1",
kind: "claim",
patient_control_number: "DONE1",
charge_amount: 88,
payer_id: "P1",
provider_npi: "1234567890",
state: "submitted",
service_date_from: "2026-06-21",
},
],
}),
}),
);
const view = renderInbox();
await waitFor(() => {
expect(view.container.textContent).toContain("DONE1");
});
const cell = Array.from(view.container.querySelectorAll("td")).find(
(td) => td.textContent === "DONE1",
);
const row = cell?.closest("tr");
expect(row).toBeTruthy();
await act(async () => {
fireEvent.click(row as HTMLElement);
});
await waitFor(() => {
expect(view.tracker.pathname).toBe("/claims");
expect(view.tracker.search).toBe("?claim=DONE1");
});
});
it("SP21 Task 5.4: clicking the row checkbox does not bubble to onRowClick", async () => {
// Per the plan's §self-review #5, the Lane's RowCheckbox already
// calls e.stopPropagation() — verify that's still the case by
// clicking the checkbox and confirming the URL didn't change to
// /claims. (If stopPropagation regressed, the row click handler
// would fire and navigate.)
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [
{
id: "REJ1",
kind: "claim",
payer_claim_control_number: "REJ1",
charge_amount: 175,
payer_id: "P1",
provider_npi: "1234567890",
state: "rejected",
rejection_reason: "999 reject",
service_date: null,
score: null,
},
],
payer_rejected: [],
candidates: [],
unmatched: [],
done_today: [],
}),
}),
);
const view = renderInbox();
await waitFor(() => {
expect(view.container.textContent).toContain("REJ1");
});
const checkbox = view.container.querySelector(
'input[type="checkbox"][aria-label="Select REJ1"]',
) as HTMLInputElement;
expect(checkbox).toBeTruthy();
await act(async () => {
checkbox.click();
});
// URL should still be /inbox — clicking the checkbox selected
// the row but did not drill into the claim drawer.
expect(view.tracker.pathname).toBe("/inbox");
});
});
+205 -111
View File
@@ -13,10 +13,13 @@
// ---------------------------------------------------------------------------
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { cn } from "@/lib/utils";
import { Lane, type LaneRow } from "@/components/inbox/Lane";
import { InboxHeader } from "@/components/inbox/InboxHeader";
import { BulkBar } from "@/components/inbox/BulkBar";
import { RemitDrawer } from "@/components/RemitDrawer";
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { useInboxLanes } from "@/hooks/useInboxLanes";
import {
exportInboxCsvUrl,
@@ -40,6 +43,12 @@ function rowKey(row: LaneRow): string {
export default function Inbox() {
const { lanes, loading, error, refetch } = useInboxLanes();
// SP21 Phase 4 Task 4.4: drill-down from inbox rows. The hook reads
// `?remit=` off `window.location.search` so opening a row from the
// /inbox URL pushes `?remit=ID` onto /inbox itself (it doesn't
// navigate to /remittances). The drawer just opens in-place.
const navigate = useNavigate();
const { remitId, open, close } = useRemitDrawerUrlState();
const [selected, setSelected] = useState<Record<LaneKey, string[]>>({
rejected: [],
payer_rejected: [],
@@ -215,62 +224,65 @@ export default function Inbox() {
return (
<div
className="min-h-screen"
className="min-h-screen relative"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
>
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
{/* Fold a thin amber rule + italic serif annotation that
transitions the editorial header into the lane grid below. */}
{/* Ambient halo a soft, fixed radial that creates a subtle
lighter zone behind the InboxHeader. The inbox has its own
dark ticker-tape background so it doesn't receive the body
wash; this overlay restores the "lit from above" feel that
the main pages get from the body::before softbox, in the
same warm-amber accent family as the rest of the surface.
Pointer-events disabled. */}
<div
aria-hidden
className="relative h-10 flex items-center"
style={{ background: "var(--tt-bg)" }}
>
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
style={{
background:
"linear-gradient(to right, transparent, var(--tt-amber) 12%, var(--tt-amber) 88%, transparent)",
opacity: 0.35,
}}
/>
<div
className="relative z-10 mx-auto px-3 flex items-center gap-2"
style={{ background: "var(--tt-bg)" }}
>
<span
className="display italic"
style={{ color: "var(--tt-amber)", fontSize: 14 }}
>
</span>
<span
className="mono uppercase"
style={{
color: "var(--tt-ink-dim)",
fontSize: 10.5,
letterSpacing: "0.24em",
fontWeight: 500,
}}
>
Five lanes · one queue
</span>
<span
className="display italic"
style={{ color: "var(--tt-amber)", fontSize: 14 }}
>
</span>
</div>
</div>
className="pointer-events-none fixed inset-x-0 top-0 z-0 h-[55vh]"
style={{
background: `
radial-gradient(ellipse 60% 38% at 50% 0%, hsla(36, 60%, 30%, 0.10), transparent 65%),
radial-gradient(ellipse 35% 28% at 88% 8%, hsla(36, 70%, 50%, 0.07), transparent 60%)
`,
}}
/>
{/* SP21 Phase 4 Task 4.4: RemitDrawer mounts here so a row click
in the candidates / unmatched lanes drills into the parent
remit. The drawer portals into document.body (Radix Dialog),
so the surrounding dark surface is decorative the drawer
overlays it when open. The hook reads `?remit=` off the URL,
so deep links restore the open remit on reload. */}
<RemitDrawer
remitId={remitId}
remits={[]}
onClose={close}
onNavigate={open}
onToggleHelp={() => {
// No cheatsheet on the inbox surface — `?` is a no-op
// here, but the prop is required by the drawer's contract.
}}
/>
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
{/* Lane grid transitions directly into the summary strip below.
The previous "fold" divider with italic serif arrows and
"Five lanes · one queue" caption was the page's last warm-
paper print-shop artifact removed so the ticker-tape
aesthetic carries all the way through. */}
<main className="px-6 lg:px-10 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
<Lane
name="REJECTED"
accent="oxblood"
rows={lanes.rejected}
onRowClick={() => {}}
// SP21 Phase 5 Task 5.4: rejected claims drill into the
// ClaimDrawer. All rows here are claims (kind === "claim"),
// so the branch is straightforward — the type union still
// requires the defensive check, but a row click on a claim
// here is always a claim drill.
onRowClick={(row) => {
if (row.kind === "claim") {
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
}
}}
onSelectionChange={(ids) => setLaneSelected("rejected", ids)}
/>
{/*
@@ -283,91 +295,158 @@ export default function Inbox() {
name="PAYER REJECTED"
accent="oxblood"
rows={lanes.payer_rejected}
onRowClick={() => {}}
// SP21 Phase 5 Task 5.4: payer-rejected claims also drill
// into the ClaimDrawer — same shape as the rejected lane.
onRowClick={(row) => {
if (row.kind === "claim") {
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
}
}}
onSelectionChange={(ids) => setLaneSelected("payer_rejected", ids)}
/>
<Lane
name="CANDIDATES"
accent="amber"
rows={lanes.candidates}
onRowClick={() => {}}
// Candidates are remits waiting for a claim match — drill
// straight into the RemitDrawer for the source remit.
onRowClick={(row) => open(row.id)}
onSelectionChange={(ids) => setLaneSelected("candidates", ids)}
/>
<Lane
name="UNMATCHED"
accent="ink-blue"
rows={lanes.unmatched}
onRowClick={() => {}}
// Unmatched is a mixed bucket (kind === "claim" | "remit" per
// the InboxClaimRow union). Defensive branch — today the
// backend only emits "claim" rows here, but the type allows
// both, and the next data-source change shouldn't require a
// page edit.
onRowClick={(row) => {
if (row.kind === "remit") {
open(row.id);
} else if (row.kind === "claim") {
navigate(
`/claims?claim=${encodeURIComponent(row.id)}`,
);
}
}}
onSelectionChange={(ids) => setLaneSelected("unmatched", ids)}
/>
<Lane
name="DONE"
accent="muted"
rows={lanes.done_today}
onRowClick={() => {}}
// SP21 Phase 5 Task 5.4: done_today rows are also claim
// drills — same as rejected / payer_rejected. The drawer
// surfaces the claim's current state + recent history,
// which is exactly what an operator wants when reviewing
// "what got shipped today".
onRowClick={(row) => {
if (row.kind === "claim") {
navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
}
}}
onSelectionChange={(ids) => setLaneSelected("done_today", ids)}
/>
</main>
{/* ----------------------------------------------------------------
PAPER-TONED SUMMARY STRIP
A cream "ledger" panel below the lanes that shows the aggregate
eye-flow across the queue. Mirrors the "Magazine Spread" pattern
used on the Dashboard/Claims/etc pages so the Inbox feels part
of the same document set, not a stand-alone terminal.
TICKER-TAPE QUEUE SUMMARY
A dark terminal panel below the lanes that aggregates the
eye-flow across the queue. Sits on the same --tt-bg-elev
surface as the Lane cards so the page reads as one continuous
instrument instead of mixing a paper-toned "ledger" panel
into the terminal aesthetic.
---------------------------------------------------------------- */}
<section
aria-label="Queue summary"
className="relative mx-6 lg:mx-10 mt-6 mb-4"
className="relative mx-6 lg:mx-10 mt-5 mb-6"
>
<div
className="relative rounded-xl overflow-hidden border"
className="relative rounded-md overflow-hidden"
style={{
backgroundColor: "hsl(var(--surface))",
borderColor: "hsl(30 14% 14% / 0.10)",
// "Lit from above" gradient — same treatment as the lane
// cards and the main .surface panels, so the queue
// summary reads as a slightly lifted, subtly lighter
// surface against the ticker-tape backdrop.
background:
"linear-gradient(180deg, hsl(220 18% 11.5%) 0%, hsl(220 18% 9.5%) 55%, hsl(220 18% 8.5%) 100%)",
border: "1px solid hsl(220 8% 18%)",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 12px 40px -16px hsl(0 0% 0% / 0.45)",
"inset 0 1px 0 0 hsl(0 0% 100% / 0.05), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
}}
>
{/* Title row */}
{/* Header row machine-tag eyebrow + editorial headline +
aggregate Need eyes count, separated from the lane metrics
by a hairline. */}
<div
className="px-5 lg:px-7 py-4 border-b flex items-center justify-between gap-4 flex-wrap"
style={{ borderColor: "hsl(30 14% 14% / 0.10)" }}
className="px-5 lg:px-6 py-3.5 flex items-center justify-between gap-4 flex-wrap"
style={{ borderBottom: "1px solid hsl(220 8% 16%)" }}
>
<div>
<div
className="mono text-[10.5px] uppercase tracking-[0.20em] font-semibold mb-1.5"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Queue ledger
<div className="min-w-0">
<div className="flex items-center gap-2.5 mb-1.5">
<span
aria-hidden
className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
style={{ background: "var(--tt-amber)" }}
/>
<span
className="mono uppercase"
style={{
color: "var(--tt-amber)",
fontSize: 10.5,
letterSpacing: "0.22em",
fontWeight: 600,
}}
>
Eye-flow
</span>
<span
className="mono uppercase"
style={{
color: "var(--tt-muted)",
fontSize: 10,
letterSpacing: "0.18em",
}}
aria-hidden
>
· five lanes, one queue
</span>
</div>
<h2
className="display leading-[0.95] tracking-[-0.03em]"
className="display italic"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(22px, 2.2vw, 28px)",
color: "var(--tt-ink)",
fontSize: "clamp(20px, 1.9vw, 24px)",
lineHeight: 1.1,
fontWeight: 400,
letterSpacing: "-0.02em",
}}
>
The eye-flow, <span className="italic">at a glance.</span>
at a glance.
</h2>
</div>
<div
className="text-right"
aria-label="Aggregate metrics"
>
<div className="text-right" aria-label="Aggregate metrics">
<div
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
className="mono uppercase"
style={{
color: "var(--tt-ink-dim)",
fontSize: 10,
letterSpacing: "0.20em",
fontWeight: 500,
}}
>
Need eyes
</div>
<div
className="display tabular-nums"
style={{
color: "hsl(var(--surface-ink))",
fontSize: 24,
lineHeight: 1.1,
color: "var(--tt-amber)",
fontSize: 28,
lineHeight: 1,
fontWeight: 400,
marginTop: 4,
}}
>
{needEyes}
@@ -375,8 +454,13 @@ export default function Inbox() {
</div>
</div>
{/* Lane metrics row — paper tiles that mirror each lane's accent */}
<div className="grid grid-cols-2 lg:grid-cols-5 divide-x divide-y lg:divide-y-0" style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}>
{/* Lane metrics row each tile carries its own accent dot
and count, so the operator can read the queue breakdown
at a glance without scanning back up to the Lane grid. */}
<div
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
style={{ borderTop: "1px solid hsl(220 8% 16%)" }}
>
<SummaryTile
label="Rejected"
value={lanes.rejected.length}
@@ -565,33 +649,31 @@ export default function Inbox() {
}
// ---------------------------------------------------------------------------
// SummaryTile — paper-toned metric for the queue ledger strip. Mirrors
// the lane accent (oxblood / amber / ink-blue / muted) so the eye-flow
// reads at a glance.
// SummaryTile — ticker-tape metric for the eye-flow strip. Each tile
// carries the lane's accent dot + label + count in the same color
// family the Lane component uses (oxblood / amber / ink-blue / muted),
// so the operator can read the queue breakdown at a glance without
// scanning back up to the lane grid.
// ---------------------------------------------------------------------------
const SUMMARY_ACCENTS: Record<
"oxblood" | "amber" | "ink" | "muted",
{ dot: string; tint: string; text: string }
{ dot: string; value: string }
> = {
oxblood: {
dot: "hsl(358 70% 42%)",
tint: "hsl(358 70% 92%)",
text: "hsl(358 70% 30%)",
dot: "var(--tt-oxblood)",
value: "var(--tt-ink)",
},
amber: {
dot: "hsl(36 92% 50%)",
tint: "hsl(36 82% 88%)",
text: "hsl(36 92% 30%)",
dot: "var(--tt-amber)",
value: "var(--tt-amber)",
},
ink: {
dot: "hsl(212 80% 45%)",
tint: "hsl(212 85% 92%)",
text: "hsl(212 80% 30%)",
dot: "var(--tt-ink-blue)",
value: "var(--tt-ink)",
},
muted: {
dot: "hsl(30 8% 50%)",
tint: "hsl(36 22% 90%)",
text: "hsl(30 8% 30%)",
dot: "var(--tt-muted)",
value: "var(--tt-ink-dim)",
},
};
@@ -608,41 +690,53 @@ function SummaryTile({
return (
<div
className={cn(
"relative px-4 py-4 first:pl-5 lg:first:pl-7 last:pr-5 lg:last:pr-7"
"relative px-4 py-4 first:pl-5 lg:first:pl-6 last:pr-5 lg:last:pr-6"
)}
style={{ borderRight: "1px solid hsl(220 8% 14%)" }}
>
<div className="flex items-center gap-2 mb-2">
<span
aria-hidden
className="block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: a.dot }}
style={{
backgroundColor: a.dot,
boxShadow: `0 0 6px ${a.dot}`,
}}
/>
<span
className="mono text-[10px] uppercase tracking-[0.20em] font-semibold"
style={{ color: "hsl(var(--surface-ink-3))" }}
className="mono uppercase"
style={{
color: "var(--tt-ink-dim)",
fontSize: 10.5,
letterSpacing: "0.18em",
fontWeight: 500,
}}
>
{label}
</span>
</div>
<div
className="display tabular-nums tracking-[-0.03em]"
className="display tabular-nums"
style={{
color: "hsl(var(--surface-ink))",
fontSize: "clamp(24px, 2vw, 30px)",
color: a.value,
fontSize: "clamp(22px, 1.9vw, 28px)",
lineHeight: 1,
fontWeight: 400,
letterSpacing: "-0.02em",
}}
>
{value}
</div>
<div
className="mono text-[10px] uppercase tracking-[0.16em] mt-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm"
className="mono uppercase mt-1.5"
style={{
color: a.text,
backgroundColor: a.tint,
color: "var(--tt-muted)",
fontSize: 9.5,
letterSpacing: "0.16em",
fontWeight: 500,
}}
>
{value === 0 ? "Clear" : value === 1 ? "row" : "rows"}
{value === 0 ? "clear" : value === 1 ? "row" : "rows"}
</div>
</div>
);
+360 -6
View File
@@ -4,9 +4,10 @@
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
import React, { act } from "react";
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { MemoryRouter, useLocation } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReconciliationPage } from "./Reconciliation";
import { api } from "@/lib/api";
@@ -19,6 +20,7 @@ vi.mock("@/lib/api", () => ({
listUnmatched: vi.fn(),
matchRemit: vi.fn(),
unmatchClaim: vi.fn(),
getRemittance: vi.fn(),
},
ApiError: class ApiError extends Error {
constructor(public status: number, message: string) {
@@ -27,6 +29,23 @@ vi.mock("@/lib/api", () => ({
},
}));
/**
* Tracks the current MemoryRouter location so tests can assert on
* navigation without poking at window.location (MemoryRouter doesn't
* touch it). Mounted as a sibling under the same router.
*/
function LocationTracker({
on,
}: {
on: (pathname: string, search: string) => void;
}) {
const loc = useLocation();
useEffect(() => {
on(loc.pathname, loc.search);
}, [loc.pathname, loc.search, on]);
return null;
}
/**
* Minimal `render` helper using react-dom/client + act(). Mirrors the
* `renderHook` helper in `useReconciliation.test.ts` see that file's
@@ -34,22 +53,50 @@ vi.mock("@/lib/api", () => ({
* yet, and adding one just for these tests would inflate the dev-deps
* tree). Returns the rendered container so tests can assert against
* the live DOM via `container.textContent`.
*
* SP21 Phase 5 Task 5.5: optionally wraps with a MemoryRouter so the
* `useNavigate()` call (claim drill to /claims?claim=ID) has a router
* context. Without this, `useNavigate()` would throw in tests that
* trigger a claim-card click. Tests that don't need a router can keep
* using the default (no router) path.
*/
function renderIntoContainer(element: React.ReactElement): {
function renderIntoContainer(
element: React.ReactElement,
options: { withRouter?: boolean; initialEntries?: string[] } = {},
): {
container: HTMLDivElement;
unmount: () => void;
tracker?: { pathname: string; search: string };
} {
const container = document.createElement("div");
document.body.appendChild(container);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
let tracker: { pathname: string; search: string } | undefined;
const track = (pathname: string, search: string) => {
if (!tracker) tracker = { pathname, search };
tracker.pathname = pathname;
tracker.search = search;
};
const inner = options.withRouter
? React.createElement(
MemoryRouter,
{
initialEntries: options.initialEntries ?? ["/reconciliation"],
},
React.createElement(LocationTracker, { on: track }),
element,
)
: element;
const root: Root = createRoot(container);
act(() => {
root.render(
React.createElement(
QueryClientProvider,
{ client: qc },
element
inner
)
);
});
@@ -60,6 +107,7 @@ function renderIntoContainer(element: React.ReactElement): {
act(() => root.unmount());
container.remove();
},
tracker,
};
}
@@ -86,6 +134,101 @@ async function waitForText(
describe("ReconciliationPage", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default for the per-remit detail fetch — the drawer fetches
// this whenever `?remit=` is in the URL. Return a never-resolving
// promise so the drawer stays in the loading state; the smoke
// test only asserts the drawer mounts.
(
api.getRemittance as unknown as ReturnType<typeof vi.fn>
).mockReturnValue(new Promise(() => {}));
// SP21 Phase 5 Task 5.5: reset window.location to a clean
// /reconciliation URL so `useRemitDrawerUrlState`'s mount-time
// read doesn't see a `?remit=REM-DRILL` from the previous
// test's `open()` history push. Without this, a test that
// asserts the drawer isn't open still finds the drawer
// mounted on initial render (driven by history state, not by
// a click).
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
.happyDOM.setURL("http://localhost/reconciliation");
// SP21 Phase 5 Task 5.5: the previous test may have left a Radix
// portal in document.body. Clear them before each test so a
// "drawer not in DOM" assertion isn't polluted by a stale portal.
document.body
.querySelectorAll('[role="dialog"]')
.forEach((node) => node.remove());
});
afterEach(() => {
// SP21 Phase 5 Task 5.5: the RemitDrawer portals into
// document.body. Each test renders + unmounts, but happy-dom's
// document.body persists across tests within the file, so we
// explicitly remove any lingering Radix portals here. Without
// this, a test that asserts `drawer not in DOM` would still
// find a leftover portal from the previous test.
document.body
.querySelectorAll('[role="dialog"]')
.forEach((node) => node.remove());
});
it("SP21 Task 4.6: deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
// Non-empty unmatched payload so the page renders the two-column
// matching surface (the "Pair them." branch). The pre-existing
// empty-state branch has a flaky happy-dom/race that's unrelated
// to Phase 4 — using non-empty data sidesteps that bug and still
// proves the deep-link → drawer mount works. We pre-set
// `window.location` (which `useRemitDrawerUrlState` reads on
// mount) so `?remit=REM-7` resolves to a truthy `remitId`.
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [
{
id: "CLM-1",
patientName: "Patient A",
billedAmount: 100,
providerNpi: "1234567890",
serviceDate: "2026-06-01",
payerId: "P1",
state: "submitted",
},
],
remittances: [
{
id: "REM-7",
payerClaimControlNumber: "PCN-A",
status: "received",
paidAmount: 100,
adjustmentAmount: 0,
receivedDate: "2026-06-01",
isReversal: false,
totalCharge: 100,
serviceDate: "2026-06-01",
batchId: "b1",
},
],
});
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
.happyDOM.setURL("http://localhost/reconciliation?remit=REM-7");
const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true },
);
// Wait for the loaded two-column view (the "Pair them." headline
// is the clearest signal that listUnmatched has resolved and the
// page is past the loading + empty branches), then assert the
// drawer is mounted.
await waitForText("Pair them.");
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).not.toBeNull();
unmount();
// Reset URL so the next test sees a clean /reconciliation URL.
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
.happyDOM.setURL("http://localhost/reconciliation");
});
it("renders both columns with unmatched rows when api returns data", async () => {
@@ -120,7 +263,8 @@ describe("ReconciliationPage", () => {
});
const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage)
React.createElement(ReconciliationPage),
{ withRouter: true },
);
await waitForText("CLM-1");
@@ -138,7 +282,8 @@ describe("ReconciliationPage", () => {
).mockResolvedValue({ claims: [], remittances: [] });
const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage)
React.createElement(ReconciliationPage),
{ withRouter: true },
);
await waitForText("nothing pending");
@@ -148,4 +293,213 @@ describe("ReconciliationPage", () => {
expect(document.body.textContent).not.toContain("Unmatched claims (");
unmount();
});
it("SP21 Task 5.5: clicking the claim card body drills to /claims?claim=ID", async () => {
// Task 5.5 splits the gesture: clicking the card body drills into
// the ClaimDrawer via /claims?claim=ID, while a separate
// "Select for match" button toggles the row's selection. The
// card is no longer a single <button>; the body region is a
// button with its own onClick that calls navigate().
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [
{
id: "CLM-DRILL",
patientName: "Patient Drill",
billedAmount: 250,
providerNpi: "1234567890",
serviceDate: "2026-06-19",
payerId: "P1",
state: "submitted",
},
],
remittances: [],
});
const { unmount, tracker } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true, initialEntries: ["/reconciliation"] },
);
await waitForText("CLM-DRILL");
// Click the claim card body (the body region is the inner <button>
// that contains the id). It's not the "Select for match" toggle
// — the body region has aria-label="View claim CLM-DRILL in detail".
const bodyBtn = document.body.querySelector(
'button[aria-label="View claim CLM-DRILL in detail"]',
) as HTMLButtonElement | null;
expect(bodyBtn).not.toBeNull();
await act(async () => {
bodyBtn!.click();
await Promise.resolve();
});
expect(tracker?.pathname).toBe("/claims");
expect(tracker?.search).toBe("?claim=CLM-DRILL");
unmount();
});
it("SP21 Task 5.5: clicking the 'Select for match' button toggles selection without drilling", async () => {
// The select button calls e.stopPropagation() before setSelectedClaim,
// so the row body click handler doesn't fire. Verify the URL stays
// on /reconciliation and the "Match selected" button becomes enabled
// (or at least that selection state advances).
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [
{
id: "CLM-SEL",
patientName: "Patient Select",
billedAmount: 175,
providerNpi: "1234567890",
serviceDate: "2026-06-19",
payerId: "P1",
state: "submitted",
},
],
remittances: [
{
id: "REM-SEL",
payerClaimControlNumber: "PCN-SEL",
status: "received",
paidAmount: 175,
adjustmentAmount: 0,
receivedDate: "2026-06-19",
isReversal: false,
totalCharge: 175,
serviceDate: "2026-06-19",
batchId: "b1",
},
],
});
const { unmount, tracker } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true },
);
await waitForText("CLM-SEL");
// Click the claim "Select for match" toggle. It has
// data-testid="recon-claim-select-CLM-SEL" and should NOT drill.
const selectBtn = document.body.querySelector(
'[data-testid="recon-claim-select-CLM-SEL"]',
) as HTMLButtonElement | null;
expect(selectBtn).not.toBeNull();
expect(selectBtn!.textContent).toContain("Select for match");
await act(async () => {
selectBtn!.click();
await Promise.resolve();
});
// URL stays on /reconciliation — selection didn't drill.
expect(tracker?.pathname).toBe("/reconciliation");
// The toggle button now reads "Unselect" (clicked once).
const updatedBtn = document.body.querySelector(
'[data-testid="recon-claim-select-CLM-SEL"]',
) as HTMLButtonElement | null;
expect(updatedBtn?.textContent).toContain("Unselect");
unmount();
});
it("SP21 Task 5.5: clicking the remit card body opens the RemitDrawer", async () => {
// Task 5.5 also restructures the remits column — the body is no
// longer role="button" with a select-onClick handler. The body
// drills into the RemitDrawer via open(r.id); selection moves to
// a dedicated "Select for match" toggle.
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [],
remittances: [
{
id: "REM-DRILL",
payerClaimControlNumber: "PCN-DRILL",
status: "received",
paidAmount: 100,
adjustmentAmount: 0,
receivedDate: "2026-06-19",
isReversal: false,
totalCharge: 100,
serviceDate: "2026-06-19",
batchId: "b1",
},
],
});
const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true },
);
await waitForText("PCN-DRILL");
// Click the remit card body (inner <button> with aria-label).
const bodyBtn = document.body.querySelector(
'button[aria-label="View remittance PCN-DRILL in detail"]',
) as HTMLButtonElement | null;
expect(bodyBtn).not.toBeNull();
await act(async () => {
bodyBtn!.click();
await Promise.resolve();
});
// RemitDrawer should be mounted in document.body.
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).not.toBeNull();
unmount();
});
it("SP21 Task 5.5: clicking the remit 'Select for match' button toggles selection without opening drawer", async () => {
// The remit select toggle has e.stopPropagation() — clicking it
// shouldn't open the RemitDrawer (which used to be a side effect
// when the outer card was role="button" with onClick=select).
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [],
remittances: [
{
id: "REM-SEL",
payerClaimControlNumber: "PCN-SEL2",
status: "received",
paidAmount: 100,
adjustmentAmount: 0,
receivedDate: "2026-06-19",
isReversal: false,
totalCharge: 100,
serviceDate: "2026-06-19",
batchId: "b1",
},
],
});
const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true },
);
await waitForText("PCN-SEL2");
const selectBtn = document.body.querySelector(
'[data-testid="recon-remit-select-REM-SEL"]',
) as HTMLButtonElement | null;
expect(selectBtn).not.toBeNull();
expect(selectBtn!.textContent).toContain("Select for match");
await act(async () => {
selectBtn!.click();
await Promise.resolve();
});
// No drawer.
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).toBeNull();
// Toggle flipped.
const updated = document.body.querySelector(
'[data-testid="recon-remit-select-REM-SEL"]',
) as HTMLButtonElement | null;
expect(updated?.textContent).toContain("Unselect");
unmount();
});
});
File diff suppressed because it is too large Load Diff
+63 -14
View File
@@ -22,6 +22,11 @@ vi.mock("@/lib/api", () => ({
listRemittances: vi.fn(),
getRemittance: vi.fn(),
},
ApiError: class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
},
}));
// Mock the live-tail hook so the page renders the pill in the settled
@@ -128,6 +133,17 @@ function rowAt(idx: number): HTMLTableRowElement | null {
) as HTMLTableRowElement | null;
}
/**
* Point happy-dom's URL at a known value. happy-dom v20 doesn't expose a
* writable `window.location.search`, but `window.happyDOM.setURL` updates
* the URL the window reports without triggering a navigation exactly
* what we want for mounting the page at `/remittances?remit=PCN-1` etc.
* Same helper used by Claims.test.tsx and useRemitDrawerUrlState.test.ts.
*/
function setLocation(url: string): void {
(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url);
}
/** True iff exactly one row carries `data-state="selected"`. */
function hasExactlyOneSelectedRow(): boolean {
const selected = document.querySelectorAll(
@@ -191,6 +207,11 @@ const SAMPLE_REMITS = [
describe("Remittances", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset URL to the bare remittances page between tests so a
// `?remit=` leaked from a prior test (via pushState) doesn't
// bleed into the next. happy-dom's URL survives across tests in
// the same file unless explicitly reset.
setLocation("http://localhost/remittances");
// Singleton tail-store: clear the remittances slice between tests
// so a tail-arrival case (if added later) doesn't see rows from a
// previous test.
@@ -203,18 +224,26 @@ describe("Remittances", () => {
returned: SAMPLE_REMITS.length,
has_more: false,
});
// Default for the per-remit detail fetch — the drawer fetches
// this whenever `?remit=` is in the URL. Return a never-resolving
// promise so the drawer stays in the loading state; the smoke
// tests only assert the drawer mounts, not the loaded data.
(
api.getRemittance as unknown as ReturnType<typeof vi.fn>
).mockReturnValue(new Promise(() => {}));
});
it("renders a CAS adjustment label inside the expanded detail row", async () => {
it("clicking a row opens the RemitDrawer (no more inline expand)", async () => {
// SP21 Phase 4 Task 4.3: the inline CAS expansion is gone — the
// whole row now drills into the RemitDrawer via `?remit=ID`. The
// CAS panel is now inside the drawer, not in a second <tr>.
const { unmount } = renderIntoContainer(React.createElement(Remittances));
await waitForText("PCN-1");
// The chevron + "Adjustments" header should not yet be visible because
// the row hasn't been expanded yet.
expect(document.body.textContent).not.toContain("Adjustments (2)");
// No drawer in the DOM yet — the URL has no `?remit=`.
expect(document.body.querySelector('[data-testid="remit-drawer"]')).toBeNull();
// Expand the row by clicking on the remit ID cell. We click the parent
// row by selecting the cell containing "PCN-1" and bubbling up.
// Click the row containing PCN-1.
const cell = Array.from(document.querySelectorAll("td")).find(
(td) => td.textContent === "PCN-1"
);
@@ -223,16 +252,36 @@ describe("Remittances", () => {
await act(async () => {
(row as HTMLTableRowElement).click();
});
await waitForText("Adjustments (2)");
// Both CAS labels must surface (not the raw codes alone).
expect(document.body.textContent).toContain(
"Charge exceeds fee schedule/maximum allowable"
// The drawer must mount into document.body via Radix's portal.
await settle(
() => document.body.querySelector('[data-testid="remit-drawer"]') !== null
);
expect(document.body.textContent).toContain("Deductible amount");
// Group/reason pills show the CARC code alongside.
expect(document.body.textContent).toContain("CO-45");
expect(document.body.textContent).toContain("PR-1");
expect(
document.body.querySelector('[data-testid="remit-drawer"]')
).not.toBeNull();
// URL must reflect the open remit.
expect(window.location.search).toContain("remit=PCN-1");
unmount();
});
it("deep-link ?remit=ID opens the drawer on mount", async () => {
// Pre-set the URL so the hook reads PCN-1 off `window.location.search`
// during its `useState` initializer — no click needed.
setLocation("http://localhost/remittances?remit=PCN-1");
const { unmount } = renderIntoContainer(React.createElement(Remittances));
await settle(
() => document.body.querySelector('[data-testid="remit-drawer"]') !== null
);
// Drawer should appear immediately, without user interaction.
expect(
document.body.querySelector('[data-testid="remit-drawer"]')
).not.toBeNull();
unmount();
});
+67 -125
View File
@@ -1,5 +1,4 @@
import { Fragment, useCallback, useMemo, useState } from "react";
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import {
Table,
TableBody,
@@ -17,13 +16,15 @@ import { Pagination } from "@/components/ui/pagination";
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
import { PageHeader } from "@/components/PageHeader";
import { TailStatusPill } from "@/components/TailStatusPill";
import { RemitDrawer } from "@/components/RemitDrawer";
import { useRemittances } from "@/hooks/useRemittances";
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { CasAdjustment, Remittance, RemittanceStatus } from "@/types";
import type { Remittance, RemittanceStatus } from "@/types";
const PAGE_SIZE = 25;
@@ -33,39 +34,20 @@ const STATUS_OPTIONS: FilterChipOption[] = [
{ value: "reconciled", label: "Reconciled" },
];
/**
* One persisted CAS row, rendered as a "code — label" pair plus the
* dollar amount. Lives inside the expanded detail row of a remit so
* the operator can see exactly why the payer adjusted the claim.
*/
function AdjustmentRow({ adj }: { adj: CasAdjustment }) {
return (
<div className="flex items-start justify-between gap-4 py-2 border-b border-border/30 last:border-0">
<div className="min-w-0 flex-1">
<div className="mono text-[10.5px] text-muted-foreground">
{adj.group}-{adj.reason}
{adj.quantity !== null ? (
<span className="ml-2 text-muted-foreground/60">
qty {adj.quantity}
</span>
) : null}
</div>
<div className="text-[12.5px] text-foreground/90 truncate">{adj.label}</div>
</div>
<div className="display mono text-[12.5px] tabular-nums whitespace-nowrap text-muted-foreground">
{fmt.usdPrecise(adj.amount)}
</div>
</div>
);
}
export function Remittances() {
const [page, setPage] = useState(1);
const [status, setStatus] = useState<RemittanceStatus | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
// SP21 Phase 4 Task 4.3: row click → RemitDrawer. The drawer is
// URL-driven (`?remit=ID`) so deep links restore the open remit
// on reload — same pattern as the ClaimDrawer on /claims.
// `remits` is the j/k navigation list (the current page of rows).
// `setRemitId` (REPLACE history, not push) is what j/k uses so a
// single keypress doesn't add a history entry.
const { remitId, open, close, setRemitId } = useRemitDrawerUrlState();
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({
sort: "receivedDate",
order: "desc",
@@ -93,15 +75,6 @@ export function Remittances() {
{ paid: 0, adjustments: 0 }
);
const toggleExpand = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const moveNext = useCallback(() => {
setSelectedIndex((i) => {
if (items.length === 0) return null;
@@ -119,19 +92,42 @@ export function Remittances() {
}, [items.length]);
useRowKeyboard({
enabled: !helpOpen && items.length > 0,
// Page-level j/k only fires when the drawer is closed — once
// `?remit=` is set, the drawer's own `useDrawerKeyboard` listener
// owns the j/k keys (with its own wrap-around semantics over
// `remits`). Letting the page-level listener stay active here
// would mean a single `j` keypress both advances the drawer's
// remittance AND bumps the page-level selectedIndex — exactly
// the "double navigation" surprise we want to avoid.
enabled: !helpOpen && items.length > 0 && remitId === null,
onNext: moveNext,
onPrev: movePrev,
onClose: () => setHelpOpen(false),
onToggleHelp: () => setHelpOpen((v) => !v),
});
// j/k navigation through the remits list. The drawer's own keyboard
// handler (useDrawerKeyboard, only attached while the drawer is
// open) uses the same keys with its own wrap-around semantics, so
// page-level nav only fires when the drawer is closed.
const drawerRemits = useMemo(
() => items.map((r) => ({ id: r.id })),
[items],
);
return (
<>
<KeyboardCheatsheet
open={helpOpen}
onClose={() => setHelpOpen(false)}
/>
<RemitDrawer
remitId={remitId}
remits={drawerRemits}
onClose={close}
onNavigate={setRemitId}
onToggleHelp={() => setHelpOpen((v) => !v)}
/>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Remittances"
@@ -204,7 +200,6 @@ export function Remittances() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" aria-label="Expand" />
<TableHead>Remit</TableHead>
<TableHead>Claim</TableHead>
<TableHead>Payer</TableHead>
@@ -216,92 +211,39 @@ export function Remittances() {
</TableHeader>
<TableBody>
{items.map((r, idx) => {
const isOpen = expanded.has(r.id);
const hasAdjustments =
!!r.adjustments && r.adjustments.length > 0;
const isSelected = selectedIndex === idx;
return (
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
<TableRow
data-row-index={idx}
data-state={isSelected ? "selected" : undefined}
aria-selected={isSelected}
className={cn(
"animate-row-flash",
isSelected && [
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
],
)}
onClick={() =>
hasAdjustments ? toggleExpand(r.id) : undefined
}
aria-expanded={hasAdjustments ? isOpen : undefined}
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
>
<TableCell className="text-muted-foreground">
{hasAdjustments ? (
isOpen ? (
<ChevronDown
className="h-3.5 w-3.5"
strokeWidth={1.75}
aria-hidden
/>
) : (
<ChevronRight
className="h-3.5 w-3.5"
strokeWidth={1.75}
aria-hidden
/>
)
) : null}
</TableCell>
<TableCell className="display mono text-[12.5px]">{r.id}</TableCell>
<TableCell className="display mono text-[12.5px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display mono">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display mono text-muted-foreground">
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground mono text-[12px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
{isOpen && hasAdjustments ? (
<TableRow
key={`${r.id}-${dataUpdatedAt}-detail`}
className="bg-muted/20 hover:bg-muted/20"
>
<TableCell />
<TableCell colSpan={7} className="py-3">
<div className="flex items-center gap-2 mb-2">
<Receipt
className="h-3.5 w-3.5 text-muted-foreground"
strokeWidth={1.5}
aria-hidden
/>
<div className="eyebrow">
Adjustments ({r.adjustments!.length})
</div>
</div>
<div className="pl-5">
{r.adjustments!.map((adj, i) => (
<AdjustmentRow
key={`${adj.group}-${adj.reason}-${i}`}
adj={adj}
/>
))}
</div>
</TableCell>
</TableRow>
) : null}
</Fragment>
<TableRow
key={`${r.id}-${dataUpdatedAt}`}
data-row-index={idx}
data-state={isSelected ? "selected" : undefined}
aria-selected={isSelected}
className={cn(
"animate-row-flash cursor-pointer drillable",
isSelected && [
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
],
)}
onClick={() => open(r.id)}
>
<TableCell className="display mono text-[12.5px]">{r.id}</TableCell>
<TableCell className="display mono text-[12.5px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display mono">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display mono text-muted-foreground">
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground mono text-[12px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
);
})}
</TableBody>
+271
View File
@@ -0,0 +1,271 @@
// @vitest-environment happy-dom
// Tell React this is an `act`-aware test environment so react-query's
// internal state updates flush through without noisy console warnings.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
import React, { act, useEffect } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { MemoryRouter, useLocation } from "react-router-dom";
import { createRoot, type Root } from "react-dom/client";
import { ClaimCard837 } from "@/components/ClaimCard837";
import { ClaimCard835 } from "./Upload";
import { useAppStore } from "@/store";
import type { ClaimOutput, ClaimPayment, ParsedBatch } from "@/types";
// Fixtures — kept tiny, just enough to exercise the drill logic.
const CLAIM_837: ClaimOutput = {
claim_id: "CLM-PERSISTED",
subscriber: {
first_name: "Jane",
last_name: "Doe",
member_id: "MEM-1",
},
payer: { name: "Test Payer", id: "P1" },
billing_provider: { npi: "1234567890" },
claim: {
total_charge: 100,
place_of_service: "11",
frequency_code: "1",
prior_auth: null,
},
service_lines: [
{
line_number: 1,
procedure: { qualifier: "HC", code: "99213", modifiers: [] },
charge: "100",
units: "1",
unit_type: "UN",
service_date: "2026-06-01",
},
],
diagnoses: [{ qualifier: "ABK", code: "J20.9" }],
validation: { passed: true, errors: [], warnings: [] },
};
const CLAIM_835: ClaimPayment = {
payer_claim_control_number: "PCN-PERSISTED",
status_code: "1",
status_label: "Processed as Primary",
claim_filing_indicator: "CI",
facility_type: "11",
frequency_code: "1",
total_charge: "100",
total_paid: "80",
patient_responsibility: "20",
service_payments: [
{
line_number: 1,
procedure_qualifier: "HC",
procedure_code: "99213",
modifiers: [],
service_date: "2026-06-01",
units: "1",
unit_type: "UN",
charge: "100",
payment: "80",
adjustments: [],
},
],
original_claim_id: null,
};
// Mount the card inside a MemoryRouter so the useNavigate call has
// a router context (without this, clicking the drill link would
// throw "useNavigate may be used only in a Router").
function renderCard(
element: React.ReactElement,
initialEntries: string[] = ["/upload"],
): {
container: HTMLDivElement;
unmount: () => void;
tracker: { pathname: string; search: string };
} {
const container = document.createElement("div");
document.body.appendChild(container);
const tracker = { pathname: "/upload", search: "" };
const Tracker = () => {
const loc = useLocation();
useEffect(() => {
tracker.pathname = loc.pathname;
tracker.search = loc.search;
}, [loc.pathname, loc.search]);
return null;
};
const root: Root = createRoot(container);
act(() => {
root.render(
React.createElement(
MemoryRouter,
{ initialEntries },
React.createElement(Tracker, null),
element,
),
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
tracker,
};
}
describe("ClaimCard837 / ClaimCard835 drill link (SP21 Phase 5 Task 5.7)", () => {
beforeEach(() => {
// Reset parsedBatches to empty by default — individual tests
// populate it as needed.
useAppStore.setState({ parsedBatches: [] });
});
afterEach(() => {
useAppStore.setState({ parsedBatches: [] });
});
it("ClaimCard837: renders the drill link when the claim_id is in a persisted batch", async () => {
// Pre-populate the store with a parsed batch that contains this
// claim id, then expand the card and verify the link is present.
const persisted: ParsedBatch = {
id: "batch-1",
kind: "837p",
inputFilename: "test.837",
parsedAt: "2026-06-21T12:00:00Z",
claimCount: 1,
passed: 1,
failed: 0,
claimIds: ["CLM-PERSISTED"],
summary: { total_claims: 1, passed: 1, failed: 0 },
};
useAppStore.setState({ parsedBatches: [persisted] });
const { container, unmount } = renderCard(
React.createElement(ClaimCard837, { claim: CLAIM_837 }),
);
// Expand the card.
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
(header as HTMLButtonElement).click();
await Promise.resolve();
});
// The drill link should now be visible.
const link = container.querySelector('[data-testid="upload-claim-drill"]');
expect(link).not.toBeNull();
expect(link?.textContent).toContain("See claim in detail");
unmount();
});
it("ClaimCard837: does NOT render the drill link when the claim_id is not persisted", async () => {
// Streaming-only claim — the parsedBatches slice is empty, so
// the link must be absent (clicking would 404 ClaimDrawer).
const { container, unmount } = renderCard(
React.createElement(ClaimCard837, { claim: CLAIM_837 }),
);
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
(header as HTMLButtonElement).click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="upload-claim-drill"]'),
).toBeNull();
unmount();
});
it("ClaimCard837: clicking the drill link navigates to /claims?claim=ID", async () => {
const persisted: ParsedBatch = {
id: "batch-1",
kind: "837p",
inputFilename: "test.837",
parsedAt: "2026-06-21T12:00:00Z",
claimCount: 1,
passed: 1,
failed: 0,
claimIds: ["CLM-PERSISTED"],
summary: { total_claims: 1, passed: 1, failed: 0 },
};
useAppStore.setState({ parsedBatches: [persisted] });
const { container, unmount, tracker } = renderCard(
React.createElement(ClaimCard837, { claim: CLAIM_837 }),
);
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
(header as HTMLButtonElement).click();
await Promise.resolve();
});
const link = container.querySelector(
'[data-testid="upload-claim-drill"]',
) as HTMLButtonElement;
await act(async () => {
link.click();
await Promise.resolve();
});
expect(tracker.pathname).toBe("/claims");
expect(tracker.search).toBe("?claim=CLM-PERSISTED");
unmount();
});
it("ClaimCard835: renders the drill link when the PCN is persisted", async () => {
const persisted: ParsedBatch = {
id: "batch-1",
kind: "835",
inputFilename: "test.835",
parsedAt: "2026-06-21T12:00:00Z",
claimCount: 1,
passed: 1,
failed: 0,
claimIds: ["PCN-PERSISTED"],
summary: { total_claims: 1, passed: 1, failed: 0 },
};
useAppStore.setState({ parsedBatches: [persisted] });
const { container, unmount, tracker } = renderCard(
React.createElement(ClaimCard835, { claim: CLAIM_835 }),
);
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
(header as HTMLButtonElement).click();
await Promise.resolve();
});
const link = container.querySelector('[data-testid="upload-remit-drill"]');
expect(link).not.toBeNull();
await act(async () => {
(link as HTMLButtonElement).click();
await Promise.resolve();
});
// 835 cards drill to /remittances?remit=PCN (not /claims), since
// the RemitDrawer is the right surface for the payment side.
expect(tracker.pathname).toBe("/remittances");
expect(tracker.search).toBe("?remit=PCN-PERSISTED");
unmount();
});
it("ClaimCard835: does NOT render the drill link when the PCN is not persisted", async () => {
const { container, unmount } = renderCard(
React.createElement(ClaimCard835, { claim: CLAIM_835 }),
);
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
(header as HTMLButtonElement).click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="upload-remit-drill"]'),
).toBeNull();
unmount();
});
});
+573 -983
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;
claims: ClaimOutput[];
summary: BatchSummary;
/**
* Server-side batch id (UUID). Present on the JSON response from
* POST /api/parse-837 null/missing on the NDJSON-streaming path
* (use the final `summary.batch_id` from the NDJSON stream instead).
* Required by /api/batches/{batch_id}/export-837.
*/
batch_id?: string | null;
}
// --- 835 ERA ---------------------------------------------------------------
@@ -379,6 +386,8 @@ export interface ParseResult835 {
claims: ClaimPayment[];
summary: BatchSummary;
validation?: ValidationReport | null;
/** See ParseResult837.batch_id. */
batch_id?: string | null;
}
// --- NDJSON streaming ------------------------------------------------------