// --------------------------------------------------------------------------- // 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(path: string): Promise { 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(path: string, body: unknown): Promise { 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 { return getJson("/api/inbox/lanes"); } export async function matchCandidate( remitId: string, claimId: string, ): Promise { await postJson(`/api/inbox/candidates/${encodeURIComponent(remitId)}/match`, { claim_id: claimId, }); } export async function dismissCandidates( pairs: Array<{ claim_id: string; remit_id: string }>, ): Promise { 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 { return postJson("/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}`); }