import { useCallback, useEffect, useState } from "react"; import { useHttpService } from "../services/HttpService.tsx"; import { environment } from "../environments/environment.ts"; import { relayServerFailure } from "../utils/fetchFailureMessage"; import { onViewerChange, viewerEpoch } from "../utils/writeThroughHold"; import { PAGE_OUT_OF_DATE_MESSAGE, RecordChangedError } from "../utils/recordChanged"; import type { SaveTransport } from "../utils/saveOperation"; /** * The tests this practice offers, and the two ways an administrator changes them. * * The catalogue is answered whole rather than by page -- a practice curates fifty to two hundred * tests and the bench needs all of them to draw one row -- so this holds the list itself rather than * going through the shared Paginator, which expects a server-paged endpoint. * * THE DRAFT IS THE WIRE FORMAT. The server builds the FHIR: the requirements it writes are contained * fragments, and a screen assembling those would have to understand the Order Catalog to add one test. */ export interface AnalyteDraft { code?: string; name?: string; dataType?: string; unit?: string; low?: number; high?: number; } /** * A question the laboratory asks BEFORE the sample is taken. * * NOT AN ANALYTE. An analyte is what the test reports; this is what it needs to be told -- fasting * status, last menstrual period, how long a timed collection ran. The laboratory refuses the work * without it, or runs it and reports a number nobody can read correctly. */ export interface EntryQuestionDraft { /** How an answer finds its question. Accessioning matches on it and refuses one not asked. */ code?: string; name?: string; dataType?: string; unit?: string; } export interface OrderableRow { id?: string; /** The edition this row was read at. An edit is saved against it; a row without one cannot be edited until read again. */ versionId?: string; code?: string; name?: string; kind?: "laboratory" | "imaging"; testCode?: string; /** The vocabulary the code belongs to. The server assumes LOINC when none is given. */ codeSystem?: string; offered: boolean; container?: string; volume?: string; analytes: AnalyteDraft[]; entryQuestions?: EntryQuestionDraft[]; } export interface OrderableDraft { code?: string; name: string; kind: "laboratory" | "imaging"; offered: boolean; testCode?: string; /** The vocabulary the code belongs to. The server assumes LOINC when none is given. */ codeSystem?: string; container?: string; volume?: string; analytes: AnalyteDraft[]; entryQuestions: EntryQuestionDraft[]; } const ENDPOINT = `${environment.fhirClinicalApi}/diagnostic-catalogue`; const rowFromDraft = (id: string | undefined, draft: OrderableDraft, versionId?: string): OrderableRow => ({ id, ...(versionId ? { versionId } : {}), code: draft.code, name: draft.name, kind: draft.kind, offered: draft.offered, testCode: draft.testCode, codeSystem: draft.codeSystem, container: draft.container, volume: draft.volume, analytes: draft.analytes, entryQuestions: draft.entryQuestions, }); /** * The same rule the server holds, asked before the call. * * A measured value with no unit is refused at result entry, so a test published without one is * guaranteed to fail at the bench later. Saying so while somebody is still typing beats a round trip * that comes back refused -- and the server keeps its own copy, because the client is never the * boundary. */ export const draftProblem = (draft: OrderableDraft): string | undefined => { if (!draft.name.trim()) return "A test needs a name"; const asked = new Set(); for (const question of draft.entryQuestions ?? []) { if (!question.name?.trim()) return "Every question the test asks needs a name"; // Accessioning matches an answer to its question by code and refuses one the test never asks, // so a question without a code is a row that discards whatever is typed into it. const code = question.code?.trim(); if (!code) return `"${question.name.trim()}" needs a code, or its answer cannot be recorded`; if (asked.has(code)) return `Two questions are both asked as "${code}"`; asked.add(code); if ((question.dataType ?? "string") === "Quantity" && !question.unit?.trim()) { return `"${question.name.trim()}" is a measured answer, so it needs a unit`; } } for (const analyte of draft.analytes) { if (!analyte.name?.trim()) return "Every result the test returns needs a name"; if ((analyte.dataType ?? "Quantity") === "Quantity" && !analyte.unit?.trim()) { return `"${analyte.name.trim()}" is a measured value, so it needs a unit`; } } return undefined; }; export const draftFrom = (row: OrderableRow): OrderableDraft => ({ code: row.code, name: row.name ?? "", kind: row.kind ?? "laboratory", offered: row.offered, testCode: row.testCode, codeSystem: row.codeSystem, container: row.container, volume: row.volume, analytes: row.analytes ?? [], entryQuestions: row.entryQuestions ?? [], }); /** Whether a test as read says what this draft says: the fields an editor owns, compared as an editor would set them. */ export const readsAs = (row: OrderableRow, draft: OrderableDraft): boolean => JSON.stringify(draftFrom(row)) === JSON.stringify(draftFrom(rowFromDraft(row.id, draft))); export const TEST_CHANGED_MESSAGE = "Someone else changed this test while you were editing. Nothing was saved. Reload latest to see it as it is now."; // A refusal because the test is not the edition this edit was made on. Nothing was written. const asTestChanged = (error: unknown): unknown => { const status = (error as { status?: unknown } | null)?.status; if (status === 409 || status === 412) return new RecordChangedError(TEST_CHANGED_MESSAGE); if (status === 428) return new RecordChangedError(PAGE_OUT_OF_DATE_MESSAGE, true); return error; }; export const useDiagnosticCatalogue = () => { const { get, post, put } = useHttpService(); const [orderables, setOrderables] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(); // Every answer here belongs to the viewer and practice it was asked for; one that answers after a change is dropped. const load = useCallback(async () => { const viewer = viewerEpoch(); setLoading(true); setError(undefined); try { const response = await get<{ orderables?: OrderableRow[] }>(ENDPOINT); if (viewer === viewerEpoch()) setOrderables(response?.orderables ?? []); } catch (err) { console.error("Error loading the diagnostic catalogue:", err); if (viewer === viewerEpoch()) setError(relayServerFailure(err, "Could not load the test catalogue. Please try again.")); } finally { if (viewer === viewerEpoch()) setLoading(false); } }, [get]); useEffect(() => { void load(); }, [load]); useEffect(() => onViewerChange(() => { setOrderables([]); void load(); }), [load]); const addTest = useCallback(async (draft: OrderableDraft, transport?: SaveTransport) => { // The 201 is the stored row. Reloading the catalogue here would search HealthLake before the // new test is visible, so the list would look unchanged until a later refresh. const viewer = viewerEpoch(); const created = await post<{ id?: string; versionId?: string }>(ENDPOINT, draft, { ...transport }); if (viewer === viewerEpoch()) setOrderables((prev) => [rowFromDraft(created?.id, draft, created?.versionId), ...prev]); }, [post]); /** * Editing and retiring are one call: `offered` is a field on the test, not a separate act. It is saved only over * `expectedVersion`, the edition the edit was opened at; the row then carries the edition the save returned, or none. */ const saveTest = useCallback(async (id: string, draft: OrderableDraft, expectedVersion: string, transport?: SaveTransport): Promise => { const viewer = viewerEpoch(); let saved: { versionId?: string } | undefined; try { saved = await put<{ versionId?: string }>(`${ENDPOINT}/${id}`, { ...draft, expectedVersion }, { ...transport }); } catch (err) { throw asTestChanged(err); } if (viewer === viewerEpoch()) { setOrderables((prev) => prev.map((row) => (row.id === id ? rowFromDraft(id, draft, saved?.versionId) : row))); } return saved?.versionId; }, [put]); /** * The test as the server lists it now, read on purpose; the list it came in replaces the one shown, but only while * the opening that asked for it is still open. */ const latestRow = useCallback(async (id: string, transport?: SaveTransport): Promise => { const viewer = viewerEpoch(); const response = await get<{ orderables?: OrderableRow[] }>(ENDPOINT, { cache: "no-store", ...transport }); transport?.admit(); if (viewer === viewerEpoch()) setOrderables(response?.orderables ?? []); return (response?.orderables ?? []).find((row) => row.id === id); }, [get]); return { orderables, loading, error, reload: load, addTest, saveTest, latestRow }; };