cb74bc9307
- inbox-api.test.ts: pin ?download=true POST contract; blob + filename + X-Cyclone-Serialize-Errors parsing; non-2xx error surfacing. - Inbox.test.tsx: end-to-end multi-select → Resubmit + Download path verifies api call args and downloadBlob wiring. - inbox-api.ts: drop redundant isConfigured short-circuit in download variant (backend has its own auth gate) and switch error reading to res.text() to match Blob response shape.
180 lines
5.3 KiB
TypeScript
180 lines
5.3 KiB
TypeScript
// ---------------------------------------------------------------------------
|
|
// Inbox API client (sub-project 6).
|
|
//
|
|
// Mirrors the four-lane working surface: rejected / candidates / unmatched /
|
|
// done_today. Plus the actions: manual match, dismiss, resubmit, export.
|
|
// All non-2xx responses throw (so callers can branch on status).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type ScoreBreakdown = {
|
|
patient: number;
|
|
date: number;
|
|
amount: number;
|
|
provider: number;
|
|
};
|
|
|
|
export type ScoreTier = "strong" | "weak" | "hidden";
|
|
|
|
export type InboxClaimRow = {
|
|
id: string;
|
|
kind: "claim";
|
|
patient_control_number: string;
|
|
charge_amount: number | null;
|
|
payer_id: string | null;
|
|
provider_npi: string | null;
|
|
state: string;
|
|
rejection_reason: string | null;
|
|
rejected_at: string | null;
|
|
service_date_from: string | null;
|
|
score?: number | null;
|
|
score_tier?: ScoreTier | null;
|
|
score_breakdown?: ScoreBreakdown | null;
|
|
/**
|
|
* SP7: populated on `rejected` / `done_today` claim rows when the claim
|
|
* has a matched remittance. Carries the line-count badge the
|
|
* `MatchedRemitCard` displays (``3/4 matched lines``).
|
|
*/
|
|
matched_remittance?: {
|
|
id: string;
|
|
matched_lines: number;
|
|
total_lines: number;
|
|
} | null;
|
|
};
|
|
|
|
export type InboxCandidateRow = {
|
|
id: string;
|
|
kind: "remit";
|
|
payer_claim_control_number: string;
|
|
charge_amount: number | null;
|
|
payer_id: string | null;
|
|
rendering_provider_npi: string | null;
|
|
service_date: string | null;
|
|
candidates: Array<{
|
|
claim_id: string;
|
|
score: number;
|
|
tier: ScoreTier;
|
|
breakdown: ScoreBreakdown;
|
|
}>;
|
|
};
|
|
|
|
export type InboxLanes = {
|
|
rejected: InboxClaimRow[];
|
|
candidates: InboxCandidateRow[];
|
|
unmatched: InboxClaimRow[];
|
|
done_today: InboxClaimRow[];
|
|
};
|
|
|
|
const BASE_URL =
|
|
(import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
|
|
|
function joinUrl(path: string): string {
|
|
return `${BASE_URL.replace(/\/$/, "")}${path}`;
|
|
}
|
|
|
|
async function getJson<T>(path: string): Promise<T> {
|
|
const r = await fetch(joinUrl(path));
|
|
if (!r.ok) {
|
|
throw new Error(`GET ${path} failed: ${r.status}`);
|
|
}
|
|
return (await r.json()) as T;
|
|
}
|
|
|
|
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
|
const r = await fetch(joinUrl(path), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!r.ok) {
|
|
throw new Error(`POST ${path} failed: ${r.status}`);
|
|
}
|
|
return (await r.json()) as T;
|
|
}
|
|
|
|
export async function fetchInboxLanes(): Promise<InboxLanes> {
|
|
return getJson<InboxLanes>("/api/inbox/lanes");
|
|
}
|
|
|
|
export async function matchCandidate(
|
|
remitId: string,
|
|
claimId: string,
|
|
): Promise<void> {
|
|
await postJson(`/api/inbox/candidates/${encodeURIComponent(remitId)}/match`, {
|
|
claim_id: claimId,
|
|
});
|
|
}
|
|
|
|
export async function dismissCandidates(
|
|
pairs: Array<{ claim_id: string; remit_id: string }>,
|
|
): Promise<void> {
|
|
await postJson("/api/inbox/candidates/dismiss", { pairs });
|
|
}
|
|
|
|
export type ResubmitResult = {
|
|
ok: boolean;
|
|
resubmitted: string[];
|
|
conflicts: Array<{ claim_id: string; current_state: string }>;
|
|
};
|
|
|
|
export async function resubmitRejected(
|
|
claimIds: string[],
|
|
): Promise<ResubmitResult> {
|
|
return postJson<ResubmitResult>("/api/inbox/rejected/resubmit", {
|
|
claim_ids: claimIds,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resubmit rejected claims AND ask the backend for a downloadable bundle
|
|
* of regenerated 837 files (one .x12 per successfully resubmitted claim).
|
|
*
|
|
* Returns the raw `Blob` plus the filename the backend suggested in its
|
|
* `Content-Disposition` header (e.g. `resubmit-3-claims.zip`). Callers
|
|
* usually pipe both into `downloadTextFile`'s sibling helper for binary
|
|
* downloads — see `downloadBlob` in `@/lib/download`.
|
|
*
|
|
* The endpoint never fails because of a partial bundle — per-claim
|
|
* serialization errors are surfaced via the `X-Cyclone-Serialize-Errors`
|
|
* response header (JSON-encoded array) so the UI can show "10
|
|
* resubmitted, 2 couldn't be regenerated" without parsing the zip. The
|
|
* returned blob still contains the claims that did serialize.
|
|
*/
|
|
export async function resubmitRejectedWithDownload(
|
|
claimIds: string[],
|
|
): Promise<{ blob: Blob; filename: string; serializeErrors: Array<{ claim_id: string; reason: string }> }> {
|
|
const res = await fetch(
|
|
joinUrl("/api/inbox/rejected/resubmit?download=true"),
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ claim_ids: claimIds }),
|
|
},
|
|
);
|
|
if (!res.ok) {
|
|
const detail = await res.text().catch(() => "");
|
|
throw new Error(
|
|
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`,
|
|
);
|
|
}
|
|
const blob = await res.blob();
|
|
const cd = res.headers.get("content-disposition") ?? "";
|
|
const match = /filename="?([^";]+)"?/i.exec(cd);
|
|
const filename = match?.[1] ?? "resubmit-bundle.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 {
|
|
serializeErrors = [];
|
|
}
|
|
}
|
|
return { blob, filename, serializeErrors };
|
|
}
|
|
|
|
export function exportInboxCsvUrl(
|
|
lane: "rejected" | "candidates" | "unmatched" | "done_today",
|
|
): string {
|
|
return joinUrl(`/api/inbox/export.csv?lane=${lane}`);
|
|
}
|