feat(frontend): wire Vite app to FastAPI parser with NDJSON streaming + Upload page
This commit is contained in:
+306
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 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, the high-level helper methods throw, the streaming
|
||||
* `parse837` / `parse835` calls throw, and `health()` returns `null`. Existing
|
||||
* pages keep working with the in-memory sample store in that case.
|
||||
*
|
||||
* - `parse837(file, { onProgress })` POSTs to `/api/parse-837` and, when
|
||||
* `onProgress` is provided, streams NDJSON (one `{type,data}` object per
|
||||
* line) so the UI can render claims incrementally. With `onProgress`
|
||||
* omitted, 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 }`.
|
||||
*/
|
||||
|
||||
import { useAppStore } from "@/store";
|
||||
import type {
|
||||
ClaimOutput,
|
||||
ClaimPayment,
|
||||
Envelope,
|
||||
FinancialInfo,
|
||||
NdjsonEvent,
|
||||
ParseResult837,
|
||||
ParseResult835,
|
||||
Payer,
|
||||
Payer835,
|
||||
Payee835,
|
||||
ReassociationTrace,
|
||||
BatchSummary,
|
||||
} from "@/types";
|
||||
|
||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
|
||||
|
||||
export const isConfigured = BASE_URL.length > 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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: BatchSummary };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function notConfiguredError(): Error {
|
||||
return new Error(
|
||||
"API not configured. Set VITE_API_BASE_URL in .env.local to use the backend."
|
||||
);
|
||||
}
|
||||
|
||||
function joinUrl(path: string): string {
|
||||
return `${BASE_URL.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
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.
|
||||
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;
|
||||
} catch {
|
||||
// not JSON; fall through
|
||||
}
|
||||
return t;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function parseNdjsonStream(
|
||||
res: Response,
|
||||
onProgress: ParseProgress
|
||||
): Promise<BatchSummary> {
|
||||
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: BatchSummary | 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 BatchSummary;
|
||||
}
|
||||
}
|
||||
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 BatchSummary;
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastSummary) {
|
||||
throw new Error("Stream ended without a summary line.");
|
||||
}
|
||||
return lastSummary;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function parse837(
|
||||
file: File,
|
||||
options: ParseOptions = {}
|
||||
): Promise<ParseResult837 | BatchSummary> {
|
||||
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";
|
||||
const res = await fetch(joinUrl(`/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 | BatchSummary> {
|
||||
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";
|
||||
const res = await fetch(joinUrl(`/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;
|
||||
}
|
||||
|
||||
async function health(): Promise<HealthResponse | null> {
|
||||
if (!isConfigured) return null;
|
||||
const res = await fetch(joinUrl("/api/health"));
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorBody(res);
|
||||
throw new Error(
|
||||
`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`
|
||||
);
|
||||
}
|
||||
return (await res.json()) as HealthResponse;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy helpers (placeholder kept for the data adapter pattern).
|
||||
// The existing pages use the `data` adapter below to either hit the API or
|
||||
// read from the in-memory store. The legacy endpoints listed here are
|
||||
// intentionally not implemented against the live backend (the backend only
|
||||
// exposes the parser endpoints); they exist so the import surface doesn't
|
||||
// break existing callers, but they always throw when invoked.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function legacyNotImplemented(name: string): Promise<never> {
|
||||
throw new Error(
|
||||
`api.${name} is not wired to the live backend. Use api.parse837 / api.parse835 instead, ` +
|
||||
`or read the in-memory sample store via the \`data\` adapter.`
|
||||
);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
isConfigured,
|
||||
baseUrl: BASE_URL,
|
||||
health,
|
||||
parse837,
|
||||
parse835,
|
||||
|
||||
// Legacy placeholders — preserved so existing imports compile but never
|
||||
// resolve against the live backend. The `data` adapter handles the
|
||||
// sample-vs-backend switch.
|
||||
getClaims: () => legacyNotImplemented("getClaims"),
|
||||
createClaim: (_payload: unknown) => legacyNotImplemented("createClaim"),
|
||||
getRemittances: () => legacyNotImplemented("getRemittances"),
|
||||
getProviders: () => legacyNotImplemented("getProviders"),
|
||||
getActivity: () => legacyNotImplemented("getActivity"),
|
||||
};
|
||||
|
||||
/**
|
||||
* Thin adapter that prefers the backend when configured, otherwise
|
||||
* resolves against the in-memory store. Pages should call this rather
|
||||
* than touching the store directly so the swap is trivial later.
|
||||
*
|
||||
* Note: in the current backend the GET endpoints aren't implemented, so
|
||||
* these all fall back to the in-memory store regardless. The structure is
|
||||
* kept for forward-compat.
|
||||
*/
|
||||
export const data = {
|
||||
claims: () => (api.isConfigured ? api.getClaims() : useAppStore.getState().claims),
|
||||
remittances: () =>
|
||||
api.isConfigured ? api.getRemittances() : useAppStore.getState().remittances,
|
||||
providers: () =>
|
||||
api.isConfigured ? api.getProviders() : useAppStore.getState().providers,
|
||||
activity: () => (api.isConfigured ? api.getActivity() : useAppStore.getState().activity),
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
const usd = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const usdPrecise = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const number = new Intl.NumberFormat("en-US");
|
||||
|
||||
const date = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const dateShort = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const time = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const relative = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" });
|
||||
|
||||
/**
|
||||
* Coerce a backend Decimal-serialized value (string) or a number to a finite
|
||||
* number. Returns the supplied fallback (default 0) on null/undefined/NaN.
|
||||
* Used everywhere the FastAPI `Decimal` field needs to flow into a UI that
|
||||
* expects `number`.
|
||||
*/
|
||||
export const toNum = (v: string | number | null | undefined, fallback = 0): number => {
|
||||
if (v === null || v === undefined) return fallback;
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
|
||||
export const fmt = {
|
||||
usd: (n: number) => usd.format(n),
|
||||
usdPrecise: (n: number) => usdPrecise.format(n),
|
||||
usdDecimal: (v: string | number | null | undefined) =>
|
||||
usdPrecise.format(toNum(v)),
|
||||
num: (n: number) => number.format(n),
|
||||
date: (iso: string) => date.format(new Date(iso)),
|
||||
dateShort: (iso: string) => dateShort.format(new Date(iso)),
|
||||
time: (iso: string) => time.format(new Date(iso)),
|
||||
pct: (n: number, digits = 1) => `${n.toFixed(digits)}%`,
|
||||
relative: (iso: string) => {
|
||||
const ms = new Date(iso).getTime() - Date.now();
|
||||
const minutes = Math.round(ms / 60_000);
|
||||
if (Math.abs(minutes) < 60) return relative.format(minutes, "minute");
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (Math.abs(hours) < 24) return relative.format(hours, "hour");
|
||||
const days = Math.round(hours / 24);
|
||||
if (Math.abs(days) < 7) return relative.format(days, "day");
|
||||
return fmt.date(iso);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user