1249 lines
40 KiB
TypeScript
1249 lines
40 KiB
TypeScript
/**
|
|
* Cyclone API client.
|
|
*
|
|
* Talks to the FastAPI backend that lives at `VITE_API_BASE_URL`
|
|
* (defaulting to an empty string → "no backend configured" mode). When the
|
|
* base URL is empty, every method throws `notConfiguredError()`; the
|
|
* higher-level hooks in `src/hooks/` catch that by reading from the
|
|
* in-memory zustand store instead. This keeps the pages agnostic of the
|
|
* switch.
|
|
*
|
|
* Endpoints:
|
|
* - `parse837(file, { onProgress })` POSTs to `/api/parse-837`. With
|
|
* `onProgress` set, streams NDJSON (one `{type,data}` per line) so the
|
|
* UI can render claims incrementally. Without it, asks the backend for
|
|
* a single JSON object via `Accept: application/json`.
|
|
* - `parse835(...)` mirrors the 837 shape for `/api/parse-835`.
|
|
* - `health()` GETs `/api/health` and returns `{ status, version }`.
|
|
* - `listBatches / getBatch / listClaims / listRemittances / listProviders
|
|
* / getProvider / listActivity` are plain JSON GETs against the
|
|
* persistence surface.
|
|
* - `listUnmatched / matchRemit / unmatchClaim` hit the reconciliation
|
|
* surface. POSTs throw `ApiError` so callers can branch on `.status`.
|
|
*/
|
|
|
|
import type {
|
|
Ack,
|
|
BatchDiff,
|
|
ClaimAck,
|
|
ClaimAckKind,
|
|
ClaimDetail,
|
|
ClaimOutput,
|
|
ClaimPayment,
|
|
Envelope,
|
|
FinancialInfo,
|
|
MatchResponse,
|
|
NdjsonEvent,
|
|
ParseResult837,
|
|
ParseResult835,
|
|
Payer,
|
|
Payer835,
|
|
Payee835,
|
|
Provider,
|
|
ReassociationTrace,
|
|
Ta1Ack,
|
|
UnmatchedClaim,
|
|
UnmatchedResponse,
|
|
BatchSummary as ParserBatchSummary,
|
|
} from "@/types";
|
|
import {
|
|
authedFetch,
|
|
authedFetchResponse,
|
|
joinUrl,
|
|
} from "@/auth/api";
|
|
|
|
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
|
|
|
/**
|
|
* Whether the app should hit the live backend.
|
|
*
|
|
* Two paths look identical at runtime:
|
|
*
|
|
* - ``VITE_API_BASE_URL`` unset or empty → ``BASE_URL === ""`` and
|
|
* ``joinUrl()`` produces *relative* URLs (``/api/health`` etc.).
|
|
* Vite's dev proxy (and nginx in the Docker stack) intercept these
|
|
* and forward them to the FastAPI server, so the browser sees a
|
|
* same-origin call.
|
|
* - ``VITE_API_BASE_URL=https://api.example.com`` → absolute URLs
|
|
* that bypass the proxy and hit the backend directly (cross-origin
|
|
* CORS applies).
|
|
*
|
|
* We treat both as "configured" because the hooks above (``useClaims``
|
|
* etc.) only need to know whether to attempt a network request vs.
|
|
* fall back to the in-memory zustand store. The proxy is invisible to
|
|
* the page — it always sees a successful fetch unless the backend is
|
|
* actually down. The only way to opt out is to set the env var to the
|
|
* literal string ``"disabled"`` (a sentinel we treat as "no backend"),
|
|
* which is useful for offline Storybook-style previews.
|
|
*/
|
|
export const isConfigured = BASE_URL !== "disabled";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type NdjsonProgressEvent =
|
|
| { type: "envelope"; data: Envelope | null }
|
|
| { type: "claim"; data: ClaimOutput }
|
|
| { type: "claim_payment"; data: ClaimPayment }
|
|
| { type: "financial_info"; data: FinancialInfo }
|
|
| { type: "trace"; data: ReassociationTrace }
|
|
| { type: "payer"; data: Payer | Payer835 }
|
|
| { type: "payee"; data: Payee835 }
|
|
| { type: "summary"; data: ParserBatchSummary };
|
|
|
|
export type ParseProgress = (event: NdjsonProgressEvent) => void;
|
|
|
|
export interface ParseOptions {
|
|
/** Defaults to "co_medicaid" for 837 / "co_medicaid_835" for 835. */
|
|
payer?: string;
|
|
/** Promote validation warnings to errors on the backend. */
|
|
strict?: boolean;
|
|
/** Include raw EDI segments in the response (default: true). */
|
|
includeRawSegments?: boolean;
|
|
/**
|
|
* Optional progress callback. When provided, the client streams NDJSON from
|
|
* the backend and invokes the callback for every line. When omitted, the
|
|
* client requests a single JSON object via `Accept: application/json`.
|
|
*/
|
|
onProgress?: ParseProgress;
|
|
}
|
|
|
|
export interface HealthResponse {
|
|
status: string;
|
|
version: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// List-endpoint types (GET surfaces, sub-project 1)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ListClaimsParams {
|
|
batch_id?: string;
|
|
status?: string;
|
|
payer?: string;
|
|
provider_npi?: string;
|
|
date_from?: string;
|
|
date_to?: string;
|
|
sort?: string;
|
|
order?: "asc" | "desc";
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
|
|
export interface ListRemittancesParams {
|
|
batch_id?: string;
|
|
payer?: string;
|
|
claim_id?: string;
|
|
date_from?: string;
|
|
date_to?: string;
|
|
sort?: string;
|
|
order?: "asc" | "desc";
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
|
|
export interface ListProvidersParams {
|
|
npi?: string;
|
|
state?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
|
|
export interface ListActivityParams {
|
|
kind?: string;
|
|
since?: string;
|
|
limit?: number;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dashboard KPI types (SP27 Task 13).
|
|
//
|
|
// Returned by ``GET /api/dashboard/kpis`` — server-aggregated over the
|
|
// *entire* claim population. The Dashboard renders these directly; it
|
|
// no longer paginates ``/api/claims`` and reduces client-side (which
|
|
// silently produced wrong numbers with the previous ``limit: 100``
|
|
// cap).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface DashboardTotals {
|
|
count: number;
|
|
billed: number;
|
|
received: number;
|
|
outstandingAr: number;
|
|
denied: number;
|
|
denialRate: number;
|
|
pending: number;
|
|
}
|
|
|
|
export interface DashboardMonthly {
|
|
month: string; // "YYYY-MM"
|
|
label: string; // "Jan"
|
|
count: number;
|
|
billed: number;
|
|
received: number;
|
|
denied: number;
|
|
denialRate: number;
|
|
ar: number;
|
|
}
|
|
|
|
export interface DashboardTopProvider {
|
|
npi: string;
|
|
label: string;
|
|
claimCount: number;
|
|
billed: number;
|
|
denied: number;
|
|
}
|
|
|
|
export interface DashboardTopDenial {
|
|
id: string;
|
|
patientName: string;
|
|
billedAmount: number;
|
|
denialReason: string | null;
|
|
submissionDate: string;
|
|
}
|
|
|
|
export interface DashboardKpis {
|
|
totals: DashboardTotals;
|
|
monthly: DashboardMonthly[];
|
|
topProviders: DashboardTopProvider[];
|
|
topDenials: DashboardTopDenial[];
|
|
}
|
|
|
|
export interface DashboardKpisParams {
|
|
months?: number;
|
|
top_n_providers?: number;
|
|
top_n_denials?: number;
|
|
}
|
|
|
|
export interface PaginatedResponse<T> {
|
|
items: T[];
|
|
total: number;
|
|
returned: number;
|
|
has_more: boolean;
|
|
}
|
|
|
|
/**
|
|
* Server-side aggregates returned alongside `/api/acks` — summed over
|
|
* the *full* persisted row set (not just the visible page). The
|
|
* `accepted` / `rejected` / `received` keys match the in-page `totals`
|
|
* shape so the page can use `data.aggregates` as a drop-in for the
|
|
* page-local fallback accumulator.
|
|
*/
|
|
export interface AckAggregates {
|
|
accepted: number;
|
|
rejected: number;
|
|
received: number;
|
|
}
|
|
|
|
/**
|
|
* Lightweight summary used by the GET /api/batches list endpoint. Distinct
|
|
* from the parser's `BatchSummary` (which lives in `@/types` and carries the
|
|
* full validation + per-rule counts) — this one is what the UI needs to
|
|
* render a list row.
|
|
*/
|
|
export interface BatchSummary {
|
|
id: string;
|
|
kind: "837p" | "835";
|
|
inputFilename: string;
|
|
parsedAt: string;
|
|
claimCount: number;
|
|
/**
|
|
* Per-claim ids for 837P batches. Empty for 835 (no re-export
|
|
* endpoint) — the UI hides the one-click Re-export button when
|
|
* this is empty. Lets the History tab call
|
|
* `POST /api/batches/{id}/export-837` directly with the row's ids
|
|
* instead of an extra round-trip to `/api/batches/{id}`.
|
|
*/
|
|
claimIds: string[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Low-level helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function notConfiguredError(): Error {
|
|
return new Error(
|
|
"API not configured. Set VITE_API_BASE_URL in .env.local to use the backend."
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Error thrown for non-2xx responses. Carries the HTTP `status` so callers can
|
|
* branch on it (e.g. 404 vs 500) without parsing the message string.
|
|
*/
|
|
export class ApiError extends Error {
|
|
constructor(public status: number, message: string) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
async function readErrorBody(res: Response): Promise<string> {
|
|
try {
|
|
const t = await res.text();
|
|
if (!t) return "";
|
|
// 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 {
|
|
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
|
|
}
|
|
return t;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/** Build a query string, skipping undefined/empty params. */
|
|
function qs(params: Record<string, unknown> | undefined): string {
|
|
if (!params) return "";
|
|
const u = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v === undefined || v === null || v === "") continue;
|
|
u.set(k, String(v));
|
|
}
|
|
const s = u.toString();
|
|
return s ? `?${s}` : "";
|
|
}
|
|
|
|
async function parseNdjsonStream(
|
|
res: Response,
|
|
onProgress: ParseProgress
|
|
): Promise<ParserBatchSummary> {
|
|
if (!res.body) {
|
|
throw new Error("Response had no body to stream.");
|
|
}
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder("utf-8");
|
|
let buffer = "";
|
|
let lastSummary: ParserBatchSummary | null = null;
|
|
|
|
// Read the body chunk-by-chunk. A `while (true)` loop is the standard
|
|
// pattern for ReadableStreamDefaultReader; `done` breaks us out.
|
|
// eslint-disable-next-line no-constant-condition
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
// Split on newlines. The backend terminates every line with "\n".
|
|
let nl = buffer.indexOf("\n");
|
|
while (nl !== -1) {
|
|
const line = buffer.slice(0, nl).replace(/\r$/, "");
|
|
buffer = buffer.slice(nl + 1);
|
|
if (line.length > 0) {
|
|
let event: NdjsonEvent;
|
|
try {
|
|
event = JSON.parse(line) as NdjsonEvent;
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Failed to parse NDJSON line: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}\n${line}`
|
|
);
|
|
}
|
|
onProgress(event as NdjsonProgressEvent);
|
|
if (event.type === "summary") {
|
|
lastSummary = event.data as ParserBatchSummary;
|
|
}
|
|
}
|
|
nl = buffer.indexOf("\n");
|
|
}
|
|
}
|
|
|
|
// Flush any trailing partial line (no final newline).
|
|
const tail = buffer.replace(/\r$/, "");
|
|
if (tail.length > 0) {
|
|
const event = JSON.parse(tail) as NdjsonEvent;
|
|
onProgress(event as NdjsonProgressEvent);
|
|
if (event.type === "summary") {
|
|
lastSummary = event.data as ParserBatchSummary;
|
|
}
|
|
}
|
|
|
|
if (!lastSummary) {
|
|
throw new Error("Stream ended without a summary line.");
|
|
}
|
|
return lastSummary;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public surface — parser endpoints
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function parse837(
|
|
file: File,
|
|
options: ParseOptions = {}
|
|
): Promise<ParseResult837 | ParserBatchSummary> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
const { payer = "co_medicaid", strict = false, includeRawSegments = true, onProgress } = options;
|
|
|
|
const params = new URLSearchParams({
|
|
payer,
|
|
strict: String(strict),
|
|
include_raw_segments: String(includeRawSegments),
|
|
});
|
|
|
|
const form = new FormData();
|
|
form.append("file", file, file.name);
|
|
|
|
const accept = onProgress ? "application/x-ndjson" : "application/json";
|
|
// authedFetchResponse still hits fetch() (and still does the
|
|
// 401-redirect for any non-/api/auth path), but hands back the raw
|
|
// Response so we can stream NDJSON line-by-line below. For non-stream
|
|
// callers it returns the parsed JSON.
|
|
const res = await authedFetchResponse(
|
|
`/api/parse-837?${params.toString()}`,
|
|
{
|
|
method: "POST",
|
|
body: form,
|
|
headers: { Accept: accept },
|
|
}
|
|
);
|
|
|
|
if (!res.ok) {
|
|
const detail = await readErrorBody(res);
|
|
throw new Error(
|
|
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
);
|
|
}
|
|
|
|
if (onProgress) {
|
|
return parseNdjsonStream(res, onProgress);
|
|
}
|
|
return (await res.json()) as ParseResult837;
|
|
}
|
|
|
|
async function parse835(
|
|
file: File,
|
|
options: ParseOptions = {}
|
|
): Promise<ParseResult835 | ParserBatchSummary> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
const { payer = "co_medicaid_835", strict = false, includeRawSegments = true, onProgress } = options;
|
|
|
|
const params = new URLSearchParams({
|
|
payer,
|
|
strict: String(strict),
|
|
include_raw_segments: String(includeRawSegments),
|
|
});
|
|
|
|
const form = new FormData();
|
|
form.append("file", file, file.name);
|
|
|
|
const accept = onProgress ? "application/x-ndjson" : "application/json";
|
|
// See parse837 for why this uses authedFetchResponse instead of
|
|
// authedFetch — NDJSON streaming needs the raw Response so we can
|
|
// iterate `res.body` one chunk at a time.
|
|
const res = await authedFetchResponse(
|
|
`/api/parse-835?${params.toString()}`,
|
|
{
|
|
method: "POST",
|
|
body: form,
|
|
headers: { Accept: accept },
|
|
}
|
|
);
|
|
|
|
if (!res.ok) {
|
|
const detail = await readErrorBody(res);
|
|
throw new Error(
|
|
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
);
|
|
}
|
|
|
|
if (onProgress) {
|
|
return parseNdjsonStream(res, onProgress);
|
|
}
|
|
return (await res.json()) as ParseResult835;
|
|
}
|
|
|
|
/**
|
|
* Upload a 999 ACK file for parsing. The backend persists the row
|
|
* and returns both the parsed result and the persisted ack metadata
|
|
* (including the regenerated raw 999 text for download).
|
|
*/
|
|
async function parse999(
|
|
file: File
|
|
): Promise<{ ack: Ack & { raw_999_text: string }; parsed: unknown }> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
const form = new FormData();
|
|
form.append("file", file, file.name);
|
|
const body = await authedFetch<{
|
|
ack: {
|
|
id: number;
|
|
accepted_count: number;
|
|
rejected_count: number;
|
|
received_count: number;
|
|
ack_code: "A" | "E" | "R" | "P";
|
|
source_batch_id: string;
|
|
raw_999_text: string;
|
|
};
|
|
parsed: unknown;
|
|
}>("/api/parse-999", {
|
|
method: "POST",
|
|
body: form,
|
|
// Don't set Content-Type — the browser sets the multipart
|
|
// boundary when the body is FormData. authedFetch only adds a
|
|
// default Accept, which we override here.
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
return {
|
|
ack: {
|
|
id: body.ack.id,
|
|
sourceBatchId: body.ack.source_batch_id,
|
|
acceptedCount: body.ack.accepted_count,
|
|
rejectedCount: body.ack.rejected_count,
|
|
receivedCount: body.ack.received_count,
|
|
ackCode: body.ack.ack_code,
|
|
parsedAt: "",
|
|
// raw_999_text is appended below (not part of the canonical Ack
|
|
// type) so the UI can trigger a download without a second
|
|
// round-trip to /api/acks/{id}.
|
|
...({ raw_999_text: body.ack.raw_999_text } as { raw_999_text: string }),
|
|
} as Ack & { raw_999_text: string },
|
|
parsed: body.parsed,
|
|
};
|
|
}
|
|
|
|
async function health(): Promise<HealthResponse | null> {
|
|
if (!isConfigured) return null;
|
|
// health() is best-effort — used by the side-scan ping. Don't let a
|
|
// 401 from authedFetch redirect the operator; just return null when
|
|
// anything goes wrong.
|
|
try {
|
|
return await authedFetch<HealthResponse>("/api/health");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public surface — GET endpoints (sub-project 1)
|
|
// All throw `notConfiguredError` when no backend is wired. The hooks in
|
|
// `src/hooks/` are responsible for the in-memory zustand fallback.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function listBatches(limit?: number): Promise<BatchSummary[]> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
const params: Record<string, unknown> = {};
|
|
if (limit !== undefined) params.limit = limit;
|
|
const body = await authedFetch<{ items: BatchSummary[] }>(
|
|
`/api/batches${qs(params)}`
|
|
);
|
|
return body.items;
|
|
}
|
|
|
|
async function getBatch(id: string): Promise<ParseResult837 | ParseResult835> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
return authedFetch<ParseResult837 | ParseResult835>(
|
|
`/api/batches/${encodeURIComponent(id)}`
|
|
);
|
|
}
|
|
|
|
async function listClaims<T = unknown>(
|
|
params: ListClaimsParams
|
|
): Promise<PaginatedResponse<T>> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
return authedFetch<PaginatedResponse<T>>(
|
|
`/api/claims${qs(params as Record<string, unknown>)}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch one claim with the full SP4 detail-drawer context (header, state,
|
|
* parties, validation, service lines, diagnoses, raw segments, recent
|
|
* ``stateHistory``, and a populated ``matchedRemittance`` when paired).
|
|
*
|
|
* Drives ``GET /api/claims/{claim_id}``. Throws ``ApiError`` on non-2xx —
|
|
* including 404, which the spec §3.4 calls out as a distinct "claim
|
|
* doesn't exist" state in the drawer (separate from a transient fetch
|
|
* failure). Callers should branch on ``error.status === 404`` to render
|
|
* the not-found state instead of the generic error toast.
|
|
*/
|
|
async function getClaimDetail(id: string): Promise<ClaimDetail> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
try {
|
|
return await authedFetch<ClaimDetail>(
|
|
`/api/claims/${encodeURIComponent(id)}`
|
|
);
|
|
} catch (err) {
|
|
// authedFetch throws the richer auth/api.ts ApiError (carries
|
|
// `status` + `code` + `detail`). The drawer code branches on
|
|
// `.status === 404` for the "claim doesn't exist" state, and
|
|
// existing callers use `instanceof ApiError` (the lib/api.ts one)
|
|
// to detect structured failures. Re-wrap into the lib/api.ts
|
|
// ApiError so both keep working unchanged.
|
|
if (
|
|
err &&
|
|
typeof err === "object" &&
|
|
"status" in err &&
|
|
typeof (err as { status: unknown }).status === "number"
|
|
) {
|
|
const e = err as { status: number; code?: string; detail?: string };
|
|
throw new ApiError(
|
|
e.status,
|
|
e.detail ?? e.code ?? "error"
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serialize a claim back to an X12 837P file via the backend's outbound
|
|
* serializer (SP8).
|
|
*
|
|
* Returns both the regenerated X12 text and the filename the backend
|
|
* suggested (from the `Content-Disposition: attachment; filename=...`
|
|
* header). Callers usually pipe both into `downloadTextFile` so the user
|
|
* gets a byte-faithful file named after the claim id.
|
|
*
|
|
* Throws `ApiError` on non-2xx — 404 (claim missing) and 422 (stored
|
|
* `raw_json` unparseable / serializer rejected the payload) are both
|
|
* reachable, and the caller may want to branch on `.status` to surface a
|
|
* useful message ("claim data is corrupted — open the batch file
|
|
* manually" vs. "this claim no longer exists").
|
|
*/
|
|
async function serializeClaim837(
|
|
id: string,
|
|
): Promise<{ text: string; filename: string }> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
// authedFetchText gives us the body as a string with the same
|
|
// 401-redirect + error-shape behavior as authedFetch. We need the
|
|
// Response object for the Content-Disposition header, so this
|
|
// function delegates to authedFetchResponse and reads text +
|
|
// headers itself. No Accept negotiation — the endpoint always
|
|
// returns text/x12.
|
|
const res = await authedFetchResponse(
|
|
`/api/claims/${encodeURIComponent(id)}/serialize-837`
|
|
);
|
|
if (!res.ok) {
|
|
const detail = await readErrorBody(res);
|
|
throw new ApiError(res.status, detail || res.statusText);
|
|
}
|
|
const text = await res.text();
|
|
// Filename comes from Content-Disposition (set by the backend). Fall back
|
|
// to a sensible default if the header is missing or malformed so the UI
|
|
// always has something to hand to `downloadTextFile`.
|
|
const cd = res.headers.get("content-disposition") ?? "";
|
|
const match = /filename="?([^";]+)"?/i.exec(cd);
|
|
const filename = match?.[1] ?? `claim-${id}.x12`;
|
|
return { text, filename };
|
|
}
|
|
|
|
async function listRemittances<T = unknown>(
|
|
params: ListRemittancesParams
|
|
): Promise<PaginatedResponse<T>> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
return authedFetch<PaginatedResponse<T>>(
|
|
`/api/remittances${qs(params as Record<string, unknown>)}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Server-aggregated KPI tiles for the Remittances page. Returns the
|
|
* full-population ``count``, ``total_paid``, and ``total_adjustments``
|
|
* — NOT a page-limited sample — so the KPI tiles can't silently
|
|
* understate the true DB population the way a page-local
|
|
* ``items.reduce(...)`` would (commits ``59c3275``, ``d81b6ed``).
|
|
*/
|
|
export interface RemittanceSummary {
|
|
count: number;
|
|
total_paid: number;
|
|
total_adjustments: number;
|
|
}
|
|
|
|
async function listRemittanceSummary(
|
|
params: ListRemittancesParams = {}
|
|
): Promise<RemittanceSummary> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
return authedFetch<RemittanceSummary>(
|
|
`/api/remittances/summary${qs(params as Record<string, unknown>)}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch one remittance with its labeled CAS `adjustments` array.
|
|
* Throws `ApiError` on 404 so callers can branch on `.status`.
|
|
*/
|
|
async function getRemittance<T = unknown>(id: string): Promise<T> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
return authedFetch<T>(`/api/remittances/${encodeURIComponent(id)}`);
|
|
}
|
|
|
|
/**
|
|
* Fetch the side-by-side diff between two batches.
|
|
*
|
|
* Drives ``GET /api/batch-diff?a=<batch_id>&b=<batch_id>``. Both ids
|
|
* are required; the endpoint returns 400 when either is missing and
|
|
* 404 when the id is unknown — both surface here as ``ApiError`` so
|
|
* the page can branch on ``.status`` like every other diff / fetch
|
|
* surface in the app.
|
|
*/
|
|
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.");
|
|
}
|
|
return authedFetch<BatchDiff>(`/api/batch-diff?${qs({ a, b })}`);
|
|
}
|
|
|
|
async function listProviders<T = unknown>(
|
|
params: ListProvidersParams = {}
|
|
): Promise<PaginatedResponse<T>> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
return authedFetch<PaginatedResponse<T>>(
|
|
`/api/providers${qs(params as Record<string, unknown>)}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch one configured provider by NPI, used by the provider drill-down
|
|
* drawer (SP21 Task 2.2 / 1.6).
|
|
*
|
|
* Drives ``GET /api/config/providers/{npi}``. Note the ``/api/config/``
|
|
* prefix — this is the config-side route namespace, distinct from the
|
|
* persistence-side ``/api/providers`` used by ``listProviders``. The
|
|
* response is the full ``Provider`` shape (including the optional
|
|
* ``recent_claims`` and ``recent_activity`` arrays populated by Task
|
|
* 1.6 when the backend can serve them).
|
|
*
|
|
* Throws ``ApiError`` on non-2xx — including 404, which the drawer may
|
|
* want to branch on for a "provider no longer configured" state.
|
|
*/
|
|
async function getProvider(npi: string): Promise<Provider> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
const res = await fetch(
|
|
joinUrl(`/api/config/providers/${encodeURIComponent(npi)}`),
|
|
{ headers: { Accept: "application/json" } }
|
|
);
|
|
if (!res.ok) {
|
|
const detail = await readErrorBody(res);
|
|
throw new ApiError(res.status, detail || res.statusText);
|
|
}
|
|
return (await res.json()) as Provider;
|
|
}
|
|
|
|
/**
|
|
* Aggregate stats for one payer, used by the peek modal on Dashboard /
|
|
* Claims tables (SP21 universal drill-down). Drives
|
|
* `GET /api/payers/{payer_id}/summary`.
|
|
*
|
|
* `denial_rate` is a fraction in `[0, 1]` (the API does NOT pre-multiply).
|
|
* `top_providers` is the top 3 (or fewer) by claim count, ordered
|
|
* server-side.
|
|
*/
|
|
export interface PayerSummary {
|
|
payer_id: string;
|
|
name: string;
|
|
claim_count: number;
|
|
billed_total: number;
|
|
received_total: number;
|
|
denial_rate: number;
|
|
top_providers: Array<{ npi: string; count: number }>;
|
|
}
|
|
|
|
async function getPayerSummary(payerId: string): Promise<PayerSummary> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
const res = await fetch(
|
|
joinUrl(`/api/payers/${encodeURIComponent(payerId)}/summary`)
|
|
);
|
|
if (!res.ok) {
|
|
const detail = await readErrorBody(res);
|
|
throw new Error(
|
|
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
|
);
|
|
}
|
|
return (await res.json()) as PayerSummary;
|
|
}
|
|
|
|
async function listActivity<T = unknown>(
|
|
params: ListActivityParams = {}
|
|
): Promise<PaginatedResponse<T>> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
return authedFetch<PaginatedResponse<T>>(
|
|
`/api/activity${qs(params as Record<string, unknown>)}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch server-aggregated Dashboard KPIs.
|
|
*
|
|
* Drives ``GET /api/dashboard/kpis``. Computes billed / received /
|
|
* denial rate / pending AR / top providers / top denials server-side
|
|
* over the *full* claim population so the Dashboard's numbers are
|
|
* always correct regardless of dataset size. With 60k+ claims in
|
|
* production, fetching ``/api/claims?limit=100`` and reducing
|
|
* client-side silently produced wrong KPIs — this endpoint replaces
|
|
* that pattern.
|
|
*/
|
|
async function getDashboardKpis(
|
|
params: DashboardKpisParams = {}
|
|
): Promise<DashboardKpis> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
return authedFetch<DashboardKpis>(
|
|
`/api/dashboard/kpis${qs(params as Record<string, unknown>)}`
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public surface — reconciliation endpoints (sub-project 2)
|
|
// POSTs throw `ApiError` so callers can inspect `.status`; the GET is shaped
|
|
// like the other list endpoints above.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function listUnmatched(): Promise<UnmatchedResponse> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
return authedFetch<UnmatchedResponse>(`/api/reconciliation/unmatched`);
|
|
}
|
|
|
|
async function matchRemit(
|
|
claimId: string,
|
|
remitId: string
|
|
): Promise<MatchResponse> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
return authedFetch<MatchResponse>(`/api/reconciliation/match`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ claim_id: claimId, remit_id: remitId }),
|
|
});
|
|
}
|
|
|
|
async function unmatchClaim(claimId: string): Promise<{ claim: UnmatchedClaim }> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
return authedFetch<{ claim: UnmatchedClaim }>(`/api/reconciliation/unmatch`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ claim_id: claimId }),
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public surface — 999 ACKs (SP3 P3 T16)
|
|
// Re-shapes the snake_case backend response into the camelCase `Ack` shape
|
|
// used throughout the UI.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Raw snake_case row from `GET /api/acks`. Re-shaped by `mapAck`
|
|
* before the UI sees it.
|
|
*/
|
|
interface RawAckRow {
|
|
id: number;
|
|
source_batch_id: string;
|
|
accepted_count: number;
|
|
rejected_count: number;
|
|
received_count: number;
|
|
ack_code: "A" | "E" | "R" | "P";
|
|
parsed_at: string;
|
|
/**
|
|
* SP28: distinct claim ids this 999 ack is linked to. Empty
|
|
* array when the auto-linker couldn't resolve (orphan).
|
|
*/
|
|
linked_claim_ids?: string[];
|
|
}
|
|
|
|
function mapAck(row: RawAckRow): Ack {
|
|
return {
|
|
id: row.id,
|
|
sourceBatchId: row.source_batch_id,
|
|
acceptedCount: row.accepted_count,
|
|
rejectedCount: row.rejected_count,
|
|
receivedCount: row.received_count,
|
|
ackCode: row.ack_code,
|
|
parsedAt: row.parsed_at,
|
|
linkedClaimIds: row.linked_claim_ids ?? [],
|
|
};
|
|
}
|
|
|
|
async function listAcks(
|
|
params: { limit?: number; offset?: number } = {},
|
|
): Promise<
|
|
PaginatedResponse<Ack> & {
|
|
aggregates: AckAggregates;
|
|
}
|
|
> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
const query: Record<string, unknown> = {};
|
|
if (params.limit !== undefined) query.limit = params.limit;
|
|
if (params.offset !== undefined) query.offset = params.offset;
|
|
const body = await authedFetch<{
|
|
items: RawAckRow[];
|
|
total: number;
|
|
returned: number;
|
|
has_more: boolean;
|
|
aggregates: { accepted_count: number; rejected_count: number; received_count: number };
|
|
}>(`/api/acks${qs(query)}`);
|
|
return {
|
|
items: body.items.map(mapAck),
|
|
total: body.total,
|
|
returned: body.returned,
|
|
has_more: body.has_more,
|
|
// Adapt the wire-format `*_count` keys to the in-page `totals` shape
|
|
// so the page can treat server-side and client-side-fallback objects
|
|
// interchangeably. See useAcks.ts for the matching AckAggregates type.
|
|
aggregates: {
|
|
accepted: body.aggregates.accepted_count,
|
|
rejected: body.aggregates.rejected_count,
|
|
received: body.aggregates.received_count,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
const row = await authedFetch<RawAckRow & { raw_json: unknown }>(
|
|
`/api/acks/${encodeURIComponent(String(id))}`
|
|
);
|
|
return { ...mapAck(row), rawJson: row.raw_json };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public surface — TA1 ACKs
|
|
// The TA1 is the lowest-level X12 envelope ack (one per inbound
|
|
// ISA/IEA interchange), distinct from the per-batch 999. Colorado
|
|
// Medicaid's Gainwell MFT only ships 999s today, but historically
|
|
// they've sent TA1s, so the UI shows them whenever one is on file.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface RawTa1Row {
|
|
id: number;
|
|
control_number: string;
|
|
ack_code: "A" | "E" | "R";
|
|
note_code: string | null;
|
|
interchange_date: string | null;
|
|
interchange_time: string | null;
|
|
sender_id: string | null;
|
|
receiver_id: string | null;
|
|
source_batch_id: string;
|
|
parsed_at: string | null;
|
|
/**
|
|
* SP28: originating `Batch` ids this TA1 envelope is linked to.
|
|
* Empty array when the auto-linker couldn't resolve (orphan).
|
|
*/
|
|
linked_claim_ids?: string[];
|
|
}
|
|
|
|
function mapTa1Ack(row: RawTa1Row): Ta1Ack {
|
|
return {
|
|
id: row.id,
|
|
controlNumber: row.control_number,
|
|
ackCode: row.ack_code,
|
|
noteCode: row.note_code,
|
|
interchangeDate: row.interchange_date,
|
|
interchangeTime: row.interchange_time,
|
|
senderId: row.sender_id,
|
|
receiverId: row.receiver_id,
|
|
sourceBatchId: row.source_batch_id,
|
|
parsedAt: row.parsed_at,
|
|
linkedClaimIds: row.linked_claim_ids ?? [],
|
|
};
|
|
}
|
|
|
|
async function listTa1Acks(
|
|
params: { limit?: number } = {},
|
|
): Promise<PaginatedResponse<Ta1Ack>> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
const query: Record<string, unknown> = {};
|
|
if (params.limit !== undefined) query.limit = params.limit;
|
|
const body = await authedFetch<{
|
|
items: RawTa1Row[];
|
|
total: number;
|
|
}>(`/api/ta1-acks${qs(query)}`);
|
|
// The TA1 list endpoint returns `{ total, items }` (no `has_more` /
|
|
// `returned` — it's a simple cap-based list, not a paginated one).
|
|
// Synthesize the `PaginatedResponse` shape so the UI can share the
|
|
// same hook contract as `listAcks`.
|
|
return {
|
|
items: body.items.map(mapTa1Ack),
|
|
total: body.total,
|
|
returned: body.items.length,
|
|
has_more: body.items.length < body.total,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SP28: claim↔ack link rows.
|
|
// The `claim_acks` table is the durable record of which 999 / 277CA / TA1
|
|
// acknowledged which claim (or, for TA1, which originating 837 `Batch`).
|
|
// Three read endpoints (`listClaimAcks`, `listAckClaims`, `listAckOrphans`)
|
|
// plus the manual-match + unmatch write endpoints. The wire shape mirrors
|
|
// `to_ui_claim_ack` (backend/src/cyclone/store/ui.py) — camelCase keys,
|
|
// ISO Z timestamps, numeric ids from the database row.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface RawClaimAckRow {
|
|
id: number;
|
|
claim_id: string | null;
|
|
batch_id: string | null;
|
|
ack_id: number;
|
|
ack_kind: ClaimAckKind;
|
|
ak2_index: number | null;
|
|
set_control_number: string | null;
|
|
set_accept_reject_code: string | null;
|
|
linked_at: string;
|
|
linked_by: "auto" | "manual";
|
|
claim_state?: string;
|
|
}
|
|
|
|
function mapClaimAck(row: RawClaimAckRow): ClaimAck {
|
|
return {
|
|
id: row.id,
|
|
claimId: row.claim_id,
|
|
batchId: row.batch_id,
|
|
ackId: row.ack_id,
|
|
ackKind: row.ack_kind,
|
|
ak2Index: row.ak2_index,
|
|
setControlNumber: row.set_control_number,
|
|
setAcceptRejectCode: row.set_accept_reject_code,
|
|
linkedAt: row.linked_at,
|
|
linkedBy: row.linked_by,
|
|
claimState: row.claim_state,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* `GET /api/claims/{claim_id}/acks` — list every `claim_acks` row
|
|
* for the given claim (per-claim only; TA1 batch-level rows with
|
|
* `claim_id IS NULL` are filtered out by the backend).
|
|
*
|
|
* Drives `ClaimDrawer`'s new `<Acknowledgments />` panel. Returns
|
|
* an array even on the empty case — the panel renders
|
|
* `<EmptyState />` when the list is empty (no live-tail data).
|
|
*/
|
|
async function listClaimAcks(claimId: string): Promise<ClaimAck[]> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
const body = await authedFetch<{ items: RawClaimAckRow[] }>(
|
|
`/api/claims/${encodeURIComponent(claimId)}/acks`,
|
|
);
|
|
return body.items.map(mapClaimAck);
|
|
}
|
|
|
|
/**
|
|
* `GET /api/acks/{kind}/{ack_id}/claims` — list every `claim_acks`
|
|
* row for the given ack. For 999/277CA, one entry per AK2 /
|
|
* ClaimStatus (D1 per-AK2 granularity). For TA1, one entry per
|
|
* envelope linked to the originating `Batch`.
|
|
*
|
|
* Drives `AckDrawer`'s new `<MatchedClaim />` panel. Returns an
|
|
* array; the panel renders the "Link to claim…" dropdown when the
|
|
* list is empty (no auto-link resolved).
|
|
*/
|
|
async function listAckClaims(
|
|
kind: ClaimAckKind,
|
|
ackId: number,
|
|
): Promise<ClaimAck[]> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
const body = await authedFetch<{ items: RawClaimAckRow[] }>(
|
|
`/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/claims`,
|
|
);
|
|
return body.items.map(mapClaimAck);
|
|
}
|
|
|
|
/**
|
|
* `POST /api/acks/{kind}/{ack_id}/match-claim` — manual-match fallback
|
|
* (D5). Any logged-in user can run this (D9 differs explicitly from
|
|
* the admin-only remit-orphans posture — the metadata-only nature of
|
|
* an ack link doesn't warrant admin gating).
|
|
*
|
|
* Idempotent on the server: re-running with the same `claim_id`
|
|
* returns the existing row with HTTP 200. Returns 409 if the target
|
|
* claim is in a terminal state (REVERSED).
|
|
*/
|
|
async function matchAckToClaim(
|
|
kind: ClaimAckKind,
|
|
ackId: number,
|
|
claimId: string,
|
|
): Promise<ClaimAck> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
const body = await authedFetch<RawClaimAckRow>(
|
|
`/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/match-claim`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ claim_id: claimId }),
|
|
},
|
|
);
|
|
return mapClaimAck(body);
|
|
}
|
|
|
|
/**
|
|
* `DELETE /api/acks/{kind}/{ack_id}/match-claim/{claim_id}` — unlink.
|
|
* Removes the `claim_acks` row and publishes a `claim_ack_dropped`
|
|
* event; does NOT revert any `Claim.state` mutation the ack may
|
|
* have triggered (e.g. an `apply_999_rejections` flip to REJECTED
|
|
* stays — unlinking is metadata-only, per spec §D5/§D6).
|
|
*/
|
|
async function unmatchAck(
|
|
kind: ClaimAckKind,
|
|
ackId: number,
|
|
claimId: string,
|
|
): Promise<void> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
await authedFetch<void>(
|
|
`/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/match-claim/${encodeURIComponent(claimId)}`,
|
|
{ method: "DELETE" },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* One row in the Inbox "Ack orphans" lane (D7). Mirrors the wire
|
|
* shape of `GET /api/inbox/ack-orphans?kind=…`. ``candidates`` is a
|
|
* top-3 list of relaxed PCN matches so the operator can decide
|
|
* whether to dismiss or manually link.
|
|
*/
|
|
export interface AckOrphanRow {
|
|
id: number;
|
|
kind: ClaimAckKind;
|
|
pcn: string;
|
|
sourceBatchId: string;
|
|
parsedAt: string;
|
|
candidates: Array<{
|
|
claimId: string;
|
|
score: number;
|
|
tier: "strong" | "weak" | "hidden";
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* `GET /api/inbox/ack-orphans?kind=…` — list acks that the
|
|
* auto-linker couldn't resolve to a claim. Powers the Inbox
|
|
* "Ack orphans" lane (mirrors the existing "Payer-rejected" lane
|
|
* shape; D7).
|
|
*/
|
|
async function listAckOrphans(kind: ClaimAckKind): Promise<AckOrphanRow[]> {
|
|
if (!isConfigured) throw notConfiguredError();
|
|
const body = await authedFetch<{
|
|
items: AckOrphanRow[];
|
|
total: number;
|
|
}>(`/api/inbox/ack-orphans${qs({ kind })}`);
|
|
// Defensive: the test harness can stub fetch with a different
|
|
// response shape. Treat a missing `items` as an empty list so a
|
|
// broken mock doesn't crash the whole inbox.
|
|
return Array.isArray(body.items) ? body.items : [];
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
health,
|
|
parse837,
|
|
parse835,
|
|
parse999,
|
|
listBatches,
|
|
getBatch,
|
|
getBatchDiff,
|
|
listClaims,
|
|
getClaimDetail,
|
|
serializeClaim837,
|
|
exportBatch837,
|
|
listRemittances,
|
|
listRemittanceSummary,
|
|
getRemittance,
|
|
listProviders,
|
|
getProvider,
|
|
getPayerSummary,
|
|
listActivity,
|
|
listUnmatched,
|
|
matchRemit,
|
|
unmatchClaim,
|
|
listAcks,
|
|
getAck,
|
|
listTa1Acks,
|
|
listClaimAcks,
|
|
listAckClaims,
|
|
matchAckToClaim,
|
|
unmatchAck,
|
|
listAckOrphans,
|
|
getDashboardKpis,
|
|
};
|