feat(frontend): wire Vite app to FastAPI parser with NDJSON streaming + Upload page

This commit is contained in:
Tyler
2026-06-19 17:31:59 -06:00
parent 999889ecb3
commit 3d3bdfb07c
44 changed files with 7811 additions and 0 deletions
+306
View File
@@ -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),
};
+66
View File
@@ -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);
},
};
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}