diff --git a/src/hooks/useAckClaims.test.ts b/src/hooks/useAckClaims.test.ts new file mode 100644 index 0000000..235b3f7 --- /dev/null +++ b/src/hooks/useAckClaims.test.ts @@ -0,0 +1,188 @@ +// @vitest-environment happy-dom +// React Query's internal state updates need an `act`-aware environment +// or it logs noisy warnings. Mirror the useClaimDetail / +// useClaimAcks / useTailStream test convention. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useAckClaims } from "./useAckClaims"; +import { api } from "@/lib/api"; + +// Module-level mock: vitest hoists `vi.mock` above imports. We mock the +// single `api.listAckClaims` method the hook calls. +vi.mock("@/lib/api", () => ({ + api: { + listAckClaims: vi.fn(), + }, +})); + +/** Minimal `renderHook` shim — same pattern as `useClaimAcks.test.ts`. */ +function renderHook( + useCallback: () => TResult +): { + result: { current: TResult | undefined }; + unmount: () => void; +} { + const result: { current: TResult | undefined } = { current: undefined }; + const container = document.createElement("div"); + document.body.appendChild(container); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + function Probe() { + result.current = useCallback(); + return null; + } + + const root: Root = createRoot(container); + act(() => { + root.render( + React.createElement(QueryClientProvider, { client: qc }, React.createElement(Probe)) + ); + }); + + return { + result, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +/** Poll the result until `predicate` is true or we time out. */ +async function waitForState( + predicate: () => boolean, + timeoutMs = 1000 +): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error( + `waitForState: predicate did not hold within ${timeoutMs}ms`, + ); + } + await act(async () => { + await Promise.resolve(); + }); + } +} + +/** Sample fixture — one 999 with two AK2 set-responses, both auto-linked + * to the same claim. Mirrors the camelCase `ClaimAck` shape (the api + * layer's `mapClaimAck` does the snake_case→camelCase conversion). */ +const SAMPLE_ROWS = [ + { + id: 11, + claimId: "CLM-1", + batchId: null, + ackId: 99, + ackKind: "999" as const, + ak2Index: 0, + setControlNumber: "991102989", + setAcceptRejectCode: "A", + linkedAt: "2026-07-02T12:00:00Z", + linkedBy: "auto" as const, + claimState: "received", + }, + { + id: 12, + claimId: "CLM-1", + batchId: null, + ackId: 99, + ackKind: "999" as const, + ak2Index: 1, + setControlNumber: "991102990", + setAcceptRejectCode: "R", + linkedAt: "2026-07-02T12:00:01Z", + linkedBy: "auto" as const, + claimState: "received", + }, +]; + +describe("useAckClaims", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test_returns_idle_state_and_does_not_fetch_when_ackId_is_null", () => { + // Drawer closed → no network call, no loading, no error. The hook + // must short-circuit BEFORE the queryFn is ever invoked so a + // closed drawer is free. + const { result, unmount } = renderHook(() => useAckClaims("999", null)); + + expect(result.current).toBeDefined(); + expect(result.current?.data).toBeNull(); + expect(result.current?.isLoading).toBe(false); + expect(result.current?.isError).toBe(false); + expect(result.current?.error).toBeNull(); + expect(typeof result.current?.refetch).toBe("function"); + expect(api.listAckClaims).not.toHaveBeenCalled(); + + unmount(); + }); + + it("test_returns_idle_state_and_does_not_fetch_when_kind_is_null", () => { + // Defensive — the hook accepts `kind: ClaimAckKind | null` and + // short-circuits when it's null (mirrors the ackId null check). + const { result, unmount } = renderHook(() => useAckClaims(null, 42)); + + expect(result.current?.data).toBeNull(); + expect(result.current?.isLoading).toBe(false); + expect(api.listAckClaims).not.toHaveBeenCalled(); + + unmount(); + }); + + it("test_fetches_and_returns_claim_ack_rows_for_a_valid_ack", async () => { + (api.listAckClaims as unknown as ReturnType).mockResolvedValue( + SAMPLE_ROWS, + ); + + const { result, unmount } = renderHook(() => useAckClaims("999", 99)); + + await waitForState( + () => result.current?.data !== null && result.current?.data !== undefined, + ); + + // The hook must have invoked the API exactly once with the kind+id. + expect(api.listAckClaims).toHaveBeenCalledTimes(1); + expect(api.listAckClaims).toHaveBeenCalledWith("999", 99); + + const data = result.current?.data; + expect(data).not.toBeNull(); + expect(data).toHaveLength(2); + // Both rows belong to the same ack (per-AK2 granularity for 999). + expect(data?.[0]?.ackId).toBe(99); + expect(data?.[0]?.ackKind).toBe("999"); + expect(data?.[0]?.ak2Index).toBe(0); + expect(data?.[1]?.ak2Index).toBe(1); + + // isLoading must flip false once the fetch resolves. + expect(result.current?.isLoading).toBe(false); + expect(result.current?.isError).toBe(false); + + unmount(); + }); + + it("test_returns_empty_array_when_ack_has_no_claim_links", async () => { + (api.listAckClaims as unknown as ReturnType).mockResolvedValue( + [], + ); + + const { result, unmount } = renderHook(() => useAckClaims("999", 77)); + + await waitForState( + () => result.current?.data !== null && result.current?.data !== undefined, + ); + + // Empty list must be surfaced as [] (NOT null), so the AckDrawer + // panel can render its "Link to claim…" dropdown without a null + // check. + expect(result.current?.data).toEqual([]); + + unmount(); + }); +}); \ No newline at end of file diff --git a/src/hooks/useAckClaims.ts b/src/hooks/useAckClaims.ts new file mode 100644 index 0000000..a3bb788 --- /dev/null +++ b/src/hooks/useAckClaims.ts @@ -0,0 +1,80 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { ClaimAck, ClaimAckKind } from "@/types"; + +/** + * Per-ack claims query (AckDrawer · SP28 §5.2). + * + * Lists every `claim_acks` row for the given ack. For 999/277CA, + * one entry per AK2 / ClaimStatus (D1 per-AK2 granularity). For + * TA1, one entry per envelope linked to the originating `Batch`. + * Drives the new `` panel inside `AckDrawer`. + * + * Returns `{ data, isLoading, isError, error, refetch }`: + * - `ackId === null` (drawer closed): the query is disabled and the + * hook short-circuits to the empty drawer state `{ data: null, ... }` + * so a closed drawer doesn't burn a network request or a TanStack + * cache slot. The `AckDrawer` panel guards the `data ?? []` access + * on this and renders the "Link to claim…" dropdown when the list + * is empty. + * - `kind` and `ackId` set: fetches + * `GET /api/acks/{kind}/{ack_id}/claims` via `api.listAckClaims`. + * Cached 30 s — the link rows don't change once created (only + * manual-match / unlink mutate them, and those fire + * `claim_ack_written` / `claim_ack_dropped` events that the + * live-tail subscription delivers via `useTailStream("claim-acks")`). + * - On 404: the hook's retry predicate short-circuits (no retries) + * so the drawer's not-found state appears immediately rather than + * being masked by three back-to-back retry attempts. + * + * The live-tail subscription (`useTailStream("claim-acks")` + + * `useMergedTail("claim-acks", data ?? [], (link) => link.ackId === ackId)`) + * is mounted by the `AckDrawer` itself (per + * `cyclone-frontend-page` convention #3 — subscription on the page, + * not in the hook). + */ +export function useAckClaims( + kind: ClaimAckKind | null, + ackId: number | null, +): { + data: ClaimAck[] | null; + isLoading: boolean; + isError: boolean; + error: Error | null; + refetch: () => void; +} { + const q = useQuery({ + queryKey: ["ack-claims", kind, ackId], + queryFn: () => api.listAckClaims(kind as ClaimAckKind, ackId as number), + enabled: kind !== null && ackId !== null, + staleTime: 30 * 1000, + retry: (failureCount, error) => { + // Don't retry 404 — same posture as the other detail hooks. + if (error instanceof Error && "status" in error) { + const status = (error as { status?: unknown }).status; + if (status === 404) return false; + } + return failureCount < 3; + }, + }); + + if (kind === null || ackId === null) { + return { + data: null, + isLoading: false, + isError: false, + error: null, + refetch: () => {}, + }; + } + + return { + data: q.data ?? null, + isLoading: q.isLoading, + isError: q.isError, + error: q.error, + refetch: () => { + void q.refetch(); + }, + }; +} \ No newline at end of file diff --git a/src/hooks/useClaimAcks.test.ts b/src/hooks/useClaimAcks.test.ts new file mode 100644 index 0000000..a03395a --- /dev/null +++ b/src/hooks/useClaimAcks.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment happy-dom +// React Query's internal state updates need an `act`-aware environment +// or it logs noisy warnings. Mirror the useClaimDetail / +// useReconciliation / useTailStream test convention. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useClaimAcks } from "./useClaimAcks"; +import { api } from "@/lib/api"; + +// Module-level mock: vitest hoists `vi.mock` above imports. We mock the +// single `api.listClaimAcks` method the hook calls. +vi.mock("@/lib/api", () => ({ + api: { + listClaimAcks: vi.fn(), + }, +})); + +/** Minimal `renderHook` shim — same pattern as `useClaimDetail.test.ts`. */ +function renderHook( + useCallback: () => TResult +): { + result: { current: TResult | undefined }; + unmount: () => void; +} { + const result: { current: TResult | undefined } = { current: undefined }; + const container = document.createElement("div"); + document.body.appendChild(container); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + function Probe() { + result.current = useCallback(); + return null; + } + + const root: Root = createRoot(container); + act(() => { + root.render( + React.createElement(QueryClientProvider, { client: qc }, React.createElement(Probe)) + ); + }); + + return { + result, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +/** Poll the result until `predicate` is true or we time out. */ +async function waitForState( + predicate: () => boolean, + timeoutMs = 1000 +): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error( + `waitForState: predicate did not hold within ${timeoutMs}ms`, + ); + } + await act(async () => { + await Promise.resolve(); + }); + } +} + +/** Sample fixture matching the camelCase `ClaimAck` shape (the api + * layer's `mapClaimAck` does the snake_case→camelCase conversion, so + * the mock returns the post-conversion shape directly). */ +const SAMPLE_ROWS = [ + { + id: 1, + claimId: "CLM-1", + batchId: null, + ackId: 99, + ackKind: "999" as const, + ak2Index: 0, + setControlNumber: "991102989", + setAcceptRejectCode: "A", + linkedAt: "2026-07-02T12:00:00Z", + linkedBy: "auto" as const, + claimState: "received", + }, + { + id: 2, + claimId: "CLM-1", + batchId: null, + ackId: 99, + ackKind: "999" as const, + ak2Index: 1, + setControlNumber: "991102990", + setAcceptRejectCode: "R", + linkedAt: "2026-07-02T12:00:01Z", + linkedBy: "auto" as const, + claimState: "received", + }, +]; + +describe("useClaimAcks", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("test_returns_idle_state_and_does_not_fetch_when_claimId_is_null", () => { + // Drawer closed → no network call, no loading, no error. The hook + // must short-circuit BEFORE the queryFn is ever invoked so a + // closed drawer is free. + const { result, unmount } = renderHook(() => useClaimAcks(null)); + + expect(result.current).toBeDefined(); + expect(result.current?.data).toBeNull(); + expect(result.current?.isLoading).toBe(false); + expect(result.current?.isError).toBe(false); + expect(result.current?.error).toBeNull(); + expect(typeof result.current?.refetch).toBe("function"); + expect(api.listClaimAcks).not.toHaveBeenCalled(); + + unmount(); + }); + + it("test_fetches_and_returns_claim_ack_rows_for_a_valid_claim_id", async () => { + (api.listClaimAcks as unknown as ReturnType).mockResolvedValue( + SAMPLE_ROWS, + ); + + const { result, unmount } = renderHook(() => useClaimAcks("CLM-1")); + + await waitForState( + () => result.current?.data !== null && result.current?.data !== undefined, + ); + + // The hook must have invoked the API exactly once with the claim id. + expect(api.listClaimAcks).toHaveBeenCalledTimes(1); + expect(api.listClaimAcks).toHaveBeenCalledWith("CLM-1"); + + const data = result.current?.data; + expect(data).not.toBeNull(); + expect(data).toHaveLength(2); + // Both rows belong to the same claim (per-AK2 granularity for 999). + expect(data?.[0]?.claimId).toBe("CLM-1"); + expect(data?.[0]?.ackKind).toBe("999"); + expect(data?.[0]?.setAcceptRejectCode).toBe("A"); + expect(data?.[1]?.setAcceptRejectCode).toBe("R"); + expect(data?.[1]?.ak2Index).toBe(1); + + // isLoading must flip false once the fetch resolves. + expect(result.current?.isLoading).toBe(false); + expect(result.current?.isError).toBe(false); + + unmount(); + }); + + it("test_returns_empty_array_when_claim_has_no_acks", async () => { + (api.listClaimAcks as unknown as ReturnType).mockResolvedValue( + [], + ); + + const { result, unmount } = renderHook(() => useClaimAcks("CLM-empty")); + + await waitForState( + () => result.current?.data !== null && result.current?.data !== undefined, + ); + + // Empty list must be surfaced as [] (NOT null), so the ClaimDrawer + // panel can render its without a null check. + expect(result.current?.data).toEqual([]); + + unmount(); + }); +}); \ No newline at end of file diff --git a/src/hooks/useClaimAcks.ts b/src/hooks/useClaimAcks.ts new file mode 100644 index 0000000..29c27fe --- /dev/null +++ b/src/hooks/useClaimAcks.ts @@ -0,0 +1,77 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { ClaimAck } from "@/types"; + +/** + * Per-claim acknowledgments query (ClaimDrawer · SP28 §5.1). + * + * Lists every `claim_acks` row for the given claim (per-claim only — + * the backend filters out TA1 batch-level rows with + * `claim_id IS NULL`). Drives the new `` panel + * inside `ClaimDrawer`. + * + * Returns `{ data, isLoading, isError, error, refetch }`: + * - `claimId === null` (drawer closed): the query is disabled and the + * hook short-circuits to `{ data: null, ... }` so a closed drawer + * doesn't burn a network request or a TanStack cache slot. The + * `ClaimDrawer` panel guards the `data ?? []` access on this. + * - `claimId` is set: fetches + * `GET /api/claims/{claim_id}/acks` via `api.listClaimAcks`. + * Cached 30 s — the link rows don't change once created (only + * manual-match / unlink mutate them, and those fire + * `claim_ack_written` / `claim_ack_dropped` events that the + * live-tail subscription delivers via `useTailStream("claim-acks")`). + * - On 404: the hook's retry predicate short-circuits (no retries) + * so the drawer's not-found state appears immediately rather than + * being masked by three back-to-back retry attempts. + * + * The live-tail subscription (`useTailStream("claim-acks")` + + * `useMergedTail("claim-acks", data ?? [], (link) => link.claimId === claimId)`) + * is mounted by the `ClaimDrawer` itself (per + * `cyclone-frontend-page` convention #3 — subscription on the page, + * not in the hook). + */ +export function useClaimAcks(claimId: string | null): { + data: ClaimAck[] | null; + isLoading: boolean; + isError: boolean; + error: Error | null; + refetch: () => void; +} { + const q = useQuery({ + queryKey: ["claim-acks", claimId], + queryFn: () => api.listClaimAcks(claimId as string), + enabled: claimId !== null, + staleTime: 30 * 1000, + retry: (failureCount, error) => { + // Don't retry 404 — same posture as the other detail hooks. The + // drawer treats 404 as a distinct "claim doesn't exist" state + // and doesn't want retries masking it. + if (error instanceof Error && "status" in error) { + const status = (error as { status?: unknown }).status; + if (status === 404) return false; + } + return failureCount < 3; + }, + }); + + if (claimId === null) { + return { + data: null, + isLoading: false, + isError: false, + error: null, + refetch: () => {}, + }; + } + + return { + data: q.data ?? null, + isLoading: q.isLoading, + isError: q.isError, + error: q.error, + refetch: () => { + void q.refetch(); + }, + }; +} \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 3bc94e6..8a55db9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -25,6 +25,8 @@ import type { Ack, BatchDiff, + ClaimAck, + ClaimAckKind, ClaimDetail, ClaimOutput, ClaimPayment, @@ -855,6 +857,11 @@ interface RawAckRow { received_count: number; ack_code: "A" | "E" | "R" | "P"; parsed_at: string; + /** + * SP28: distinct claim ids this 999 ack is linked to. Empty + * array when the auto-linker couldn't resolve (orphan). + */ + linked_claim_ids?: string[]; } function mapAck(row: RawAckRow): Ack { @@ -866,6 +873,7 @@ function mapAck(row: RawAckRow): Ack { receivedCount: row.received_count, ackCode: row.ack_code, parsedAt: row.parsed_at, + linkedClaimIds: row.linked_claim_ids ?? [], }; } @@ -930,6 +938,11 @@ interface RawTa1Row { receiver_id: string | null; source_batch_id: string; parsed_at: string | null; + /** + * SP28: originating `Batch` ids this TA1 envelope is linked to. + * Empty array when the auto-linker couldn't resolve (orphan). + */ + linked_claim_ids?: string[]; } function mapTa1Ack(row: RawTa1Row): Ta1Ack { @@ -944,6 +957,7 @@ function mapTa1Ack(row: RawTa1Row): Ta1Ack { receiverId: row.receiver_id, sourceBatchId: row.source_batch_id, parsedAt: row.parsed_at, + linkedClaimIds: row.linked_claim_ids ?? [], }; } @@ -969,6 +983,164 @@ async function listTa1Acks( }; } +// --------------------------------------------------------------------------- +// SP28: claim↔ack link rows. +// The `claim_acks` table is the durable record of which 999 / 277CA / TA1 +// acknowledged which claim (or, for TA1, which originating 837 `Batch`). +// Three read endpoints (`listClaimAcks`, `listAckClaims`, `listAckOrphans`) +// plus the manual-match + unmatch write endpoints. The wire shape mirrors +// `to_ui_claim_ack` (backend/src/cyclone/store/ui.py) — camelCase keys, +// ISO Z timestamps, numeric ids from the database row. +// --------------------------------------------------------------------------- + +interface RawClaimAckRow { + id: number; + claim_id: string | null; + batch_id: string | null; + ack_id: number; + ack_kind: ClaimAckKind; + ak2_index: number | null; + set_control_number: string | null; + set_accept_reject_code: string | null; + linked_at: string; + linked_by: "auto" | "manual"; + claim_state?: string; +} + +function mapClaimAck(row: RawClaimAckRow): ClaimAck { + return { + id: row.id, + claimId: row.claim_id, + batchId: row.batch_id, + ackId: row.ack_id, + ackKind: row.ack_kind, + ak2Index: row.ak2_index, + setControlNumber: row.set_control_number, + setAcceptRejectCode: row.set_accept_reject_code, + linkedAt: row.linked_at, + linkedBy: row.linked_by, + claimState: row.claim_state, + }; +} + +/** + * `GET /api/claims/{claim_id}/acks` — list every `claim_acks` row + * for the given claim (per-claim only; TA1 batch-level rows with + * `claim_id IS NULL` are filtered out by the backend). + * + * Drives `ClaimDrawer`'s new `` panel. Returns + * an array even on the empty case — the panel renders + * `` when the list is empty (no live-tail data). + */ +async function listClaimAcks(claimId: string): Promise { + if (!isConfigured) throw notConfiguredError(); + const body = await authedFetch<{ items: RawClaimAckRow[] }>( + `/api/claims/${encodeURIComponent(claimId)}/acks`, + ); + return body.items.map(mapClaimAck); +} + +/** + * `GET /api/acks/{kind}/{ack_id}/claims` — list every `claim_acks` + * row for the given ack. For 999/277CA, one entry per AK2 / + * ClaimStatus (D1 per-AK2 granularity). For TA1, one entry per + * envelope linked to the originating `Batch`. + * + * Drives `AckDrawer`'s new `` panel. Returns an + * array; the panel renders the "Link to claim…" dropdown when the + * list is empty (no auto-link resolved). + */ +async function listAckClaims( + kind: ClaimAckKind, + ackId: number, +): Promise { + if (!isConfigured) throw notConfiguredError(); + const body = await authedFetch<{ items: RawClaimAckRow[] }>( + `/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/claims`, + ); + return body.items.map(mapClaimAck); +} + +/** + * `POST /api/acks/{kind}/{ack_id}/match-claim` — manual-match fallback + * (D5). Any logged-in user can run this (D9 differs explicitly from + * the admin-only remit-orphans posture — the metadata-only nature of + * an ack link doesn't warrant admin gating). + * + * Idempotent on the server: re-running with the same `claim_id` + * returns the existing row with HTTP 200. Returns 409 if the target + * claim is in a terminal state (REVERSED). + */ +async function matchAckToClaim( + kind: ClaimAckKind, + ackId: number, + claimId: string, +): Promise { + if (!isConfigured) throw notConfiguredError(); + const body = await authedFetch( + `/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/match-claim`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ claim_id: claimId }), + }, + ); + return mapClaimAck(body); +} + +/** + * `DELETE /api/acks/{kind}/{ack_id}/match-claim/{claim_id}` — unlink. + * Removes the `claim_acks` row and publishes a `claim_ack_dropped` + * event; does NOT revert any `Claim.state` mutation the ack may + * have triggered (e.g. an `apply_999_rejections` flip to REJECTED + * stays — unlinking is metadata-only, per spec §D5/§D6). + */ +async function unmatchAck( + kind: ClaimAckKind, + ackId: number, + claimId: string, +): Promise { + if (!isConfigured) throw notConfiguredError(); + await authedFetch( + `/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/match-claim/${encodeURIComponent(claimId)}`, + { method: "DELETE" }, + ); +} + +/** + * One row in the Inbox "Ack orphans" lane (D7). Mirrors the wire + * shape of `GET /api/inbox/ack-orphans?kind=…`. ``candidates`` is a + * top-3 list of relaxed PCN matches so the operator can decide + * whether to dismiss or manually link. + */ +export interface AckOrphanRow { + id: number; + kind: ClaimAckKind; + pcn: string; + sourceBatchId: string; + parsedAt: string; + candidates: Array<{ + claimId: string; + score: number; + tier: "strong" | "weak" | "hidden"; + }>; +} + +/** + * `GET /api/inbox/ack-orphans?kind=…` — list acks that the + * auto-linker couldn't resolve to a claim. Powers the Inbox + * "Ack orphans" lane (mirrors the existing "Payer-rejected" lane + * shape; D7). + */ +async function listAckOrphans(kind: ClaimAckKind): Promise { + if (!isConfigured) throw notConfiguredError(); + const body = await authedFetch<{ + items: AckOrphanRow[]; + total: number; + }>(`/api/inbox/ack-orphans${qs({ kind })}`); + return body.items; +} + /** * Download a ZIP of regenerated X12 837 files for a parsed batch. * @@ -1064,5 +1236,10 @@ export const api = { listAcks, getAck, listTa1Acks, + listClaimAcks, + listAckClaims, + matchAckToClaim, + unmatchAck, + listAckOrphans, getDashboardKpis, }; diff --git a/src/types/index.ts b/src/types/index.ts index 15b4e8c..7afc37a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -537,6 +537,13 @@ export interface Ack { * round-trip to the detail endpoint. */ patientControlNumber?: string | null; + /** + * SP28: distinct claim ids this 999 ack is linked to. Populated by + * the backend list endpoint so the Acks page can show a "Claims" + * badge column without a per-row detail round-trip. Empty array + * means no auto-link resolved (orphan). + */ + linkedClaimIds?: string[]; } // --------------------------------------------------------------------------- @@ -569,6 +576,62 @@ export interface Ta1Ack { receiverId: string | null; sourceBatchId: string; parsedAt: string | null; + /** + * SP28: originating `Batch` ids this TA1 envelope is linked to + * (per D4, TA1 is envelope-level so it links to `Batch` rather + * than `Claim`). Empty array means no auto-link resolved. + */ + linkedClaimIds?: string[]; +} + +// --------------------------------------------------------------------------- +// SP28: claim↔ack link rows. +// Mirrors the `claim_acks` table (cyclone.db.ClaimAck) and the wire shape +// of `GET /api/claims/{id}/acks` + `GET /api/acks/{kind}/{id}/claims`. +// Per-AK2 granularity (D1): a single 999 with two AK2 set-responses +// produces two ClaimAck rows. TA1 batch-level rows have `claimId === null` +// — the `ClaimDrawer` panel filters those out; the `AckDrawer` panel +// renders them with the originating Batch instead of a claim. +// --------------------------------------------------------------------------- + +/** + * Discriminator for the ack flavor that produced a `ClaimAck` row. + * + * - `"999"` — per-AK2 set-response (one row per AK2 in the inbound 999). + * - `"277ca"` — per-`ClaimStatus` (one row per `STC` in the inbound 277CA). + * - `"ta1"` — envelope-level (one row per TA1, no per-claim granularity). + */ +export type ClaimAckKind = "999" | "277ca" | "ta1"; + +/** + * One persisted claim↔ack link row, camelCased for the UI. The + * `claimId` is `null` for TA1 batch-level rows (D4) — the + * `ClaimDrawer` panel filters those out so the operator only sees + * per-claim acks; the `AckDrawer` panel renders them with the + * originating Batch instead. + * + * `claimState` is a derived join (the 999/277CA row carries the + * current `Claim.state` so the drawer can render the + * `ClaimStateBadge` inline without a second round-trip). For + * TA1 batch-level rows with `claimId === null`, this is `"n/a"`. + * + * `ak2Index` is populated only for `"999"` rows — 277CA and TA1 + * don't have AK2 segments. `setControlNumber` is populated for + * 999/277CA rows (the value the upstream ack actually carried, + * regardless of which join path resolved the link — spec D10). + */ +export interface ClaimAck { + id: number; + claimId: string | null; + batchId: string | null; + ackId: number; + ackKind: ClaimAckKind; + ak2Index: number | null; + setControlNumber: string | null; + setAcceptRejectCode: string | null; + linkedAt: string; + linkedBy: "auto" | "manual"; + claimState?: string; } // --------------------------------------------------------------------------- @@ -710,6 +773,15 @@ export interface ClaimDetail { * composite. */ lineReconciliation?: ClaimDetailLineReconciliation[]; + /** + * SP28: compact form of the claim's `claim_acks` rows. Each entry + * has `ack_id`, `ack_kind`, `set_accept_reject_code`, `parsed_at` + * (no `claim_state` join — the drawer can use this for the + * initial panel render and rely on the live-tail for the full + * per-row payload via `useClaimAcks`). Empty array when the claim + * has no acks. + */ + ackLinks?: ClaimAck[]; } // ---------------------------------------------------------------------------