From 44e1c4616d28d08f9021f426c629e60221bbedbf Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 10:01:40 -0600 Subject: [PATCH] feat(frontend): ClaimDetail type + api.getClaimDetail --- src/lib/api.test.ts | 107 ++++++++++++++++++++++++++++++++++++- src/lib/api.ts | 25 +++++++++ src/types/index.ts | 125 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 1 deletion(-) diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 4239b05..7986805 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { api } from "./api"; +import { ApiError, api } from "./api"; const originalFetch = global.fetch; const mockFetch = vi.fn(); @@ -70,4 +70,109 @@ describe("api GET helpers", () => { const called = mockFetch.mock.calls[0][0] as string; expect(called).toContain("/api/batches/abc"); }); + + it("getClaimDetail hits /api/claims/{id} and returns the parsed body", async () => { + // Minimal valid ClaimDetail shape — every spec-mandated key present, + // including the optional `matchedRemittance: null` for an unpaired + // claim (the dominant UI render path). + const body = { + id: "CLM-1", + batchId: "B-1", + state: "submitted", + stateLabel: "Submitted", + billedAmount: 123.45, + patientName: "Jane Doe", + providerNpi: "1234567890", + providerName: "Acme Clinic", + payerName: "Medicaid", + payerId: "MEDCO", + submissionDate: "2026-01-15T00:00:00Z", + serviceDateFrom: "2026-01-10", + serviceDateTo: "2026-01-10", + parsedAt: "2026-01-15T00:00:01Z", + diagnoses: [{ code: "E11.9", qualifier: "ABK" }], + serviceLines: [ + { + lineNumber: 1, + procedureQualifier: "HC", + procedureCode: "99213", + modifiers: [], + charge: 100.0, + units: 1, + unitType: "UN", + serviceDate: "2026-01-10", + }, + ], + parties: { + billingProvider: { + name: "Acme Clinic", + npi: "1234567890", + taxId: "12-3456789", + address: { + line1: "123 Main St", + line2: null, + city: "Denver", + state: "CO", + zip: "80202", + }, + }, + subscriber: { + firstName: "Jane", + lastName: "Doe", + memberId: "M-1", + dob: null, + gender: "F", + }, + payer: { name: "Medicaid", id: "MEDCO" }, + }, + validation: { passed: true, errors: [], warnings: [] }, + rawSegments: [["ISA*00*"]], + matchedRemittance: null, + stateHistory: [ + { + kind: "claim_submitted", + ts: "2026-01-15T00:00:00Z", + batchId: "B-1", + remittanceId: null, + }, + ], + }; + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + const out = await api.getClaimDetail("CLM-1"); + expect(out).toEqual(body); + expect(mockFetch).toHaveBeenCalledTimes(1); + const called = mockFetch.mock.calls[0][0] as string; + // URL must include the encoded id, no trailing query string. + expect(called).toContain("/api/claims/CLM-1"); + expect(called).not.toContain("?"); + }); + + it("getClaimDetail throws an ApiError on 404", async () => { + // The backend raises `{"error":"Not found","detail":"Claim X not found"}` + // on missing ids (see api.py:get_claim_detail_endpoint). The client + // must surface it as an ApiError so the drawer can distinguish + // "doesn't exist" from a transient fetch failure (spec §3.4). + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ error: "Not found", detail: "Claim ghost not found" }), + { + status: 404, + headers: { "content-type": "application/json" }, + } + ) + ); + let caught: unknown; + try { + await api.getClaimDetail("ghost"); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ApiError); + expect((caught as ApiError).status).toBe(404); + }); }); diff --git a/src/lib/api.ts b/src/lib/api.ts index f0e52c5..9f0f21b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -23,6 +23,7 @@ import type { Ack, + ClaimDetail, ClaimOutput, ClaimPayment, Envelope, @@ -451,6 +452,29 @@ async function listClaims( return (await res.json()) as PaginatedResponse; } +/** + * 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 { + if (!isConfigured) throw notConfiguredError(); + const res = await fetch(joinUrl(`/api/claims/${encodeURIComponent(id)}`), { + 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 ClaimDetail; +} + async function listRemittances( params: ListRemittancesParams ): Promise> { @@ -649,6 +673,7 @@ export const api = { listBatches, getBatch, listClaims, + getClaimDetail, listRemittances, getRemittance, listProviders, diff --git a/src/types/index.ts b/src/types/index.ts index b2cc4c6..b0a45f0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -418,3 +418,128 @@ export interface Ack { ackCode: "A" | "E" | "R" | "P"; parsedAt: string; } + +// --------------------------------------------------------------------------- +// SP4 claim detail drawer types (Task 4). +// Mirrors the JSON shape of `GET /api/claims/{claim_id}` 1:1 — camelCase +// keys, numeric money fields (coerced from Decimal on the backend), ISO +// timestamps with a trailing `Z`. Distinct from the parser-aligned +// snake_case types above (e.g. ``BillingProvider``, ``ServiceLine``) which +// describe the EDI parse tree; these describe the UI-ready detail payload. +// The ``ClaimDetail*`` prefix keeps the two flavors side-by-side without +// a TypeScript duplicate-identifier collision. +// --------------------------------------------------------------------------- + +export interface ClaimDetailDiagnosis { + code: string; + qualifier: string | null; +} + +export interface ClaimDetailServiceLine { + lineNumber: number; + procedureQualifier: string; + procedureCode: string; + modifiers: string[]; + charge: number; + units: number | null; + unitType: string | null; + serviceDate: string | null; // ISO date YYYY-MM-DD or null +} + +/** + * The backend can return ``{}`` for a missing address (see + * ``store._address_to_ui``); callers that want a populated address must + * branch on ``Object.keys(addr).length === 0`` before reading fields. + */ +export interface ClaimDetailAddress { + line1: string; + line2: string | null; + city: string; + state: string; + zip: string; +} + +export interface ClaimDetailBillingProvider { + name: string; + npi: string; + taxId: string; + address: ClaimDetailAddress | Record; +} + +export interface ClaimDetailSubscriber { + firstName: string; + lastName: string; + memberId: string; + dob: string | null; + gender: string | null; +} + +export interface ClaimDetailPayer { + name: string; + id: string; +} + +export interface ClaimDetailParties { + billingProvider: ClaimDetailBillingProvider; + subscriber: ClaimDetailSubscriber; + payer: ClaimDetailPayer; +} + +export interface ClaimDetailValidationIssue { + rule: string; + severity: "error" | "warning"; + message: string; +} + +export interface ClaimDetailValidation { + passed: boolean; + errors: ClaimDetailValidationIssue[]; + warnings: ClaimDetailValidationIssue[]; +} + +export interface ClaimDetailMatchedRemittance { + id: string; + totalPaid: number; + /** Mirrors the same `"received" | "reconciled"` mapping as the list endpoint. */ + status: string; + receivedAt: string; // ISO Z +} + +export interface ClaimDetailStateHistoryEvent { + kind: string; + ts: string; // ISO Z (most-recent-first, capped at 50 by the backend) + batchId: string | null; + remittanceId: string | null; +} + +/** + * One claim with the full drawer context: header, state, parties, + * validation, service lines, diagnoses, raw segments, recent + * ``stateHistory`` (most-recent-first, capped at 50), and a populated + * ``matchedRemittance`` block when the claim has been paired with an + * ERA. Mirrors ``CycloneStore.get_claim_detail`` (backend/src/cyclone/store.py) + * and the ``GET /api/claims/{claim_id}`` endpoint contract. + */ +export interface ClaimDetail { + id: string; + batchId: string; + state: string; + stateLabel: string; + billedAmount: number; + patientName: string; + providerNpi: string; + providerName: string; + payerName: string; + payerId: string; + submissionDate: string; // ISO Z + serviceDateFrom: string | null; + serviceDateTo: string | null; + parsedAt: string; // ISO Z + diagnoses: ClaimDetailDiagnosis[]; + serviceLines: ClaimDetailServiceLine[]; + parties: ClaimDetailParties; + validation: ClaimDetailValidation; + rawSegments: string[][]; + matchedRemittance: ClaimDetailMatchedRemittance | null; + stateHistory: ClaimDetailStateHistoryEvent[]; +}