import { useEffect, useMemo, useRef, useState } from "react"; import { AlertCircle, Plus, Trash2 } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../../../../components/ui/dialog.tsx"; import { Button } from "../../../../components/ui/button.tsx"; import { Input } from "../../../../components/ui/input.tsx"; import { Label } from "../../../../components/ui/label.tsx"; import { Checkbox } from "../../../../components/ui/checkbox.tsx"; import { Alert, AlertDescription } from "../../../../components/ui/alert.tsx"; import { relayServerFailure } from "../../../../utils/fetchFailureMessage"; import { isSaveRetired, outcomeUnknown, useSaveScope, type SaveTransport } from "../../../../utils/saveOperation"; import { isRecordChanged } from "../../../../utils/recordChanged"; import { onViewerChange } from "../../../../utils/writeThroughHold"; import { draftFrom, draftProblem, readsAs, TEST_CHANGED_MESSAGE, type AnalyteDraft, type EntryQuestionDraft, type OrderableDraft, type OrderableRow, } from "../../../../hooks/useDiagnosticCatalogue.ts"; import "./DiagnosticsPage.css"; // Adding a test, editing one, and retiring it -- which is a field on the test rather than a separate // act, so it is a checkbox here and there is no delete anywhere. // // A MEASURED VALUE MUST CARRY A UNIT. Result entry refuses a number with no scale, so a test // published without one is guaranteed to fail at the bench later. The server holds the same rule, // because the client is never the boundary; this one exists so somebody learns while still typing. export type EditorTarget = { mode: "add" } | { mode: "edit"; row: OrderableRow } | undefined; interface Props { target: EditorTarget; kinds: ("laboratory" | "imaging")[]; onClose: () => void; addTest: (draft: OrderableDraft, transport?: SaveTransport) => Promise; saveTest: (id: string, draft: OrderableDraft, expectedVersion: string, transport?: SaveTransport) => Promise; latestRow: (id: string, transport?: SaveTransport) => Promise; } // An edit is saved over the edition it was opened at, never over whatever is stored by then. A refusal keeps the draft // until the person reloads the latest on purpose; a save whose answer was lost stays unconfirmed, and is checked or // resent exactly as it was sent. type Phase = "editing" | "saving" | "reading" | "changed" | "unconfirmed"; type Sent = { draft: OrderableDraft; version?: string }; const UNCONFIRMED = "This save could not be confirmed. It may or may not have been saved. Check what the test says now, or retry the same change."; // A list read can lag a save that landed, or come before one still landing, so it never says a save failed. const SAME_EDITION = "The test still reads at the edition you edited. That does not show whether your save went through: it may have saved, or may still be saving. Retry sends the same change at the same edition, which cannot write over a newer one."; const MATCHED = "The test now reads as your change. This is how it stands now; it does not say which save wrote it. What you typed here is kept: Reload latest to edit from the test as it is."; const RETRY_REFUSED = "This retry was not saved: the test is no longer at the edition you edited, and it may already hold your change. Reload latest to see it as it is now."; const SAVED_KEPT = "Your earlier change was saved. What you typed since is kept here and is not saved yet."; const RELOAD_KEPT = "You changed the draft while the latest was being read, so your draft was kept. Reload latest again to replace it."; const emptyDraft = (kind: "laboratory" | "imaging"): OrderableDraft => ({ name: "", kind, offered: true, analytes: [], entryQuestions: [], }); const TestEditor = ({ target, kinds, onClose, addTest, saveTest, latestRow }: Props) => { const [draft, setDraft] = useState(emptyDraft(kinds[0] ?? "laboratory")); const [phase, setPhase] = useState("editing"); const [version, setVersion] = useState(); const [sent, setSent] = useState(); const [error, setError] = useState(); const [notice, setNotice] = useState(); // Each opening is its own: closing, opening another test (or this one again) or another viewer retires // whatever the last opening started, so its answers reach nothing. const openings = useRef(0); const opening = useMemo(() => (target ? String(++openings.current) : undefined), [target]); const scope = useSaveScope(opening); // The draft as typed now, which a read or a save that answers later must not replace without a deliberate choice. const typed = useRef(draft); typed.current = draft; const close = useRef(onClose); close.current = onClose; useEffect(() => onViewerChange(() => close.current()), []); useEffect(() => { setDraft( target?.mode === "edit" ? draftFrom(target.row) : emptyDraft(kinds[0] ?? "laboratory"), ); setVersion(target?.mode === "edit" ? target.row.versionId : undefined); setPhase("editing"); setSent(undefined); setError(undefined); setNotice(undefined); // eslint-disable-next-line react-hooks/exhaustive-deps }, [target]); if (!target) return null; // A read can be left by closing; a save cannot, since it may still be writing. const saving = phase === "saving"; const busy = saving || phase === "reading"; const set = (patch: Partial) => setDraft((prev) => ({ ...prev, ...patch })); const setQuestion = (index: number, patch: Partial) => setDraft((prev) => ({ ...prev, entryQuestions: (prev.entryQuestions ?? []).map((q, at) => (at === index ? { ...q, ...patch } : q)), })); const setAnalyte = (index: number, patch: Partial) => setDraft((prev) => ({ ...prev, analytes: prev.analytes.map((a, at) => (at === index ? { ...a, ...patch } : a)), })); const numberOrUndefined = (value: string) => (value.trim() === "" ? undefined : Number(value)); // What was sent is what is resent: typing after the save cannot change the body a retry or a check is about. const send = async (body: Sent, retry = false) => { const operation = scope.start(); setSent(body); setPhase("saving"); setError(undefined); setNotice(undefined); try { if (target.mode === "edit") { const saved = await operation.step(() => saveTest(target.row.id as string, body.draft, body.version as string, operation.transport), true); // Typing while it saved is newer work: it stays open, to be saved on the edition this save made. if (JSON.stringify(typed.current) !== JSON.stringify(body.draft)) { setVersion(saved); setSent(undefined); setPhase(saved ? "editing" : "changed"); setNotice(SAVED_KEPT); return; } } else { await operation.step(() => addTest(body.draft, operation.transport), true); } onClose(); } catch (err) { if (isSaveRetired(err)) return; console.error("Error saving the test:", err); if (isRecordChanged(err)) { setPhase("changed"); setError(retry ? RETRY_REFUSED : TEST_CHANGED_MESSAGE); } else if (outcomeUnknown(err)) { setPhase("unconfirmed"); setError(UNCONFIRMED); } else { setPhase("editing"); setError(relayServerFailure(err, "Could not save this test. Please try again.")); } } }; const submit = () => { const problem = draftProblem(draft); if (problem) { setError(problem); return; } void send({ draft, version }); }; // A deliberate read of the test as it stands. Reload latest takes it over the draft; Check only reports it. const readLatest = async (then: (latest: OrderableRow | undefined, before: Phase) => void) => { if (target.mode !== "edit") return; const operation = scope.start(); const before = phase; setPhase("reading"); try { const latest = await operation.step(() => latestRow(target.row.id as string, operation.transport)); then(latest, before); } catch (err) { if (isSaveRetired(err)) return; setPhase(before); setError(relayServerFailure(err, "Could not read this test just now. Please try again.")); } }; const reloadLatest = () => { const asked = typed.current; void readLatest((latest, before) => { if (!latest?.versionId) { setPhase(before); setError("This test could not be read at a version to edit. Try again."); return; } if (typed.current !== asked) { setPhase(before); setNotice(RELOAD_KEPT); return; } setDraft(draftFrom(latest)); setVersion(latest.versionId); setPhase("editing"); setError(undefined); setNotice(undefined); }); }; // What the test says now. It never replaces the draft, and never says whether, or which, save wrote it. const check = () => void readLatest((latest) => { setNotice(undefined); if (!latest?.versionId || !sent) { setPhase("unconfirmed"); setError(UNCONFIRMED); } else if (latest.versionId === sent.version) { setPhase("unconfirmed"); setError(SAME_EDITION); } else if (readsAs(latest, sent.draft)) { setPhase("unconfirmed"); setError(undefined); setNotice(MATCHED); } else { setPhase("changed"); setError(TEST_CHANGED_MESSAGE); } }); return ( { if (!open && !saving) onClose(); }}> {target.mode === "edit" ? "Edit a test" : "Add a test"} What this test needs from the patient, and what it returns. {error ? ( {error} ) : null} {notice ?

{notice}

: null}
set({ name: e.target.value })} placeholder="Potassium" />
set({ code: e.target.value })} placeholder="K" />
{/* Named for its vocabulary rather than left vague: a code stored without one cannot be matched against an order, which is what left an order with no tube on the bench. */}
set({ testCode: e.target.value })} placeholder="2823-3" />
{kinds.length > 1 ? (
Kind
{kinds.map((kind) => ( ))}
) : null} {draft.kind === "laboratory" ? (
set({ container: e.target.value })} placeholder="Green top" />
set({ volume: e.target.value })} placeholder="3 mL" />
) : null} {/* WHAT THE LABORATORY ASKS BEFORE THE SAMPLE IS TAKEN, which is not what the test returns. A laboratory refuses a lipid panel with no fasting status, or runs it and reports a number nobody can read correctly. It appears on the accession screen and on the requisition that travels with the specimen. */}
What it asks first {(draft.entryQuestions ?? []).length === 0 ? (

Most tests ask nothing. Add one only where the laboratory needs it — fasting, last menstrual period, how long a collection ran.

) : null} {(draft.entryQuestions ?? []).map((question, at) => (
setQuestion(at, { name: e.target.value })} /> setQuestion(at, { code: e.target.value })} /> setQuestion(at, { unit: e.target.value })} />
))}
What it returns {draft.analytes.length === 0 ? (

{draft.kind === "imaging" ? "An imaging report is findings, so it usually returns none." : "None yet. Give each measured result a unit so the bench does not have to type one."}

) : null} {draft.analytes.map((analyte, index) => (
setAnalyte(index, { name: e.target.value })} placeholder="Potassium" /> setAnalyte(index, { unit: e.target.value })} placeholder="mmol/L" /> setAnalyte(index, { low: numberOrUndefined(e.target.value) })} placeholder="Low" /> setAnalyte(index, { high: numberOrUndefined(e.target.value) })} placeholder="High" />
))}
set({ offered: checked === true })} />
{phase === "changed" || (phase === "unconfirmed" && target.mode === "edit") ? ( ) : null} {phase === "unconfirmed" ? ( <> {target.mode === "edit" ? : null} ) : null}
); }; export default TestEditor;