import { useEffect, useRef, useState } from "react";
import { ApiError, clinicianApi } from "../../api/client";
import type { CheckRun, Extraction, PatientDetail } from "../../api/types";
import type { CoverageReview } from "../../coverage";
import { plural } from "../../flow";
import type { Review } from "../../types";
import { Button, cn, Icon, type IconName, Notice, StatusPill, type Tone } from "../kit";
import { NewInformation } from "../patient/Overview";
import { RecordsPanel } from "../patient/Records";
import { errorText, Spinner } from "../ui";
import { FindingsView } from "./FindingsView";
import { ReviewStep } from "./ReviewStep";
type Step = "records" | "review" | "findings";
const readLook: Record<string, { label: string; tone: Tone; icon: IconName }> = {
read: { label: "Read", tone: "green", icon: "checkbox-circle-fill" },
failed: { label: "Not read", tone: "red", icon: "close-circle-fill" },
held_back: { label: "Held back", tone: "amber", icon: "spam-2-fill" },
};
/** Records → Review → Findings (PAT-3 Check). Decisions save on the server with a revision (PAT-6). */
export function CheckTab({ patient, onChanged }: { patient: PatientDetail; onChanged: () => void }) {
const [extraction, setExtraction] = useState<Extraction | null>(null);
const [check, setCheck] = useState<CheckRun | null>(null);
const [step, setStep] = useState<Step>("records");
const [ready, setReady] = useState(false);
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [conflict, setConflict] = useState(false);
const [review, setReview] = useState<Review>({});
const [coverage, setCoverage] = useState<CoverageReview>({});
const [acknowledged, setAcknowledged] = useState(false);
const [confirmed, setConfirmed] = useState<string[]>([]);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const saving = useRef<Promise<void>>(Promise.resolve());
const revision = useRef(0);
const adopt = (ex: Extraction) => {
setExtraction(ex);
setReview(ex.decisions);
setCoverage(ex.coverageReview);
setAcknowledged(ex.acknowledgedOriginals);
setConfirmed(ex.confirmedHardToRead);
revision.current = ex.revision;
setConflict(false);
};
useEffect(() => {
let alive = true;
Promise.all([clinicianApi.latestExtraction(patient.id), patient.lastCheckId ? clinicianApi.check(patient.lastCheckId) : Promise.resolve(null)]).then(
([{ extraction: ex }, last]) => {
if (!alive) return;
if (ex) adopt(ex);
setCheck(last);
setStep(last && ex && !ex.stale && last.extractionId === ex.id ? "findings" : ex && !ex.stale ? "review" : "records");
setReady(true);
},
(e) => {
if (alive) {
setError(errorText(e));
setReady(true);
}
},
);
return () => {
alive = false;
};
// Load once per patient; later changes come from this tab's own actions.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [patient.id]);
const save = (next: { review?: Review; coverage?: CoverageReview; acknowledged?: boolean; confirmed?: string[] }) => {
if (!extraction) return;
const body = { review: next.review ?? review, coverage: next.coverage ?? coverage, acknowledged: next.acknowledged ?? acknowledged, confirmed: next.confirmed ?? confirmed };
if (next.review) setReview(next.review);
if (next.coverage) setCoverage(next.coverage);
if (next.acknowledged !== undefined) setAcknowledged(next.acknowledged);
if (next.confirmed) setConfirmed(next.confirmed);
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
saving.current = saving.current.then(async () => {
try {
const saved = await clinicianApi.saveDecisions(extraction.id, {
revision: revision.current,
decisions: body.review,
coverageReview: body.coverage,
acknowledgedOriginals: body.acknowledged,
confirmedHardToRead: body.confirmed,
});
revision.current = saved.revision;
} catch (e) {
if (e instanceof ApiError && e.code === "STALE_REVISION") setConflict(true);
else setError(errorText(e));
}
});
}, 400);
};
const flush = async () => {
await saving.current;
if (!saveTimer.current || !extraction) return;
clearTimeout(saveTimer.current);
saveTimer.current = null;
const saved = await clinicianApi.saveDecisions(extraction.id, {
revision: revision.current,
decisions: review,
coverageReview: coverage,
acknowledgedOriginals: acknowledged,
confirmedHardToRead: confirmed,
});
revision.current = saved.revision;
};
const read = async () => {
setBusy("Reading the records");
setError("");
try {
adopt(await clinicianApi.startExtraction(patient.id));
setStep("review");
} catch (e) {
setError(errorText(e));
} finally {
setBusy("");
}
};
const loadLatest = async () => {
if (!extraction) return;
adopt(await clinicianApi.extraction(extraction.id));
};
const run = async () => {
if (!extraction) return;
setBusy("Running the check");
setError("");
try {
await flush();
const result = await clinicianApi.runCheck(patient.id, { extractionId: extraction.id, revision: revision.current });
setCheck(result);
setStep("findings");
window.scrollTo({ top: 0 });
onChanged();
} catch (e) {
if (e instanceof ApiError && e.code === "STALE_REVISION") setConflict(true);
setError(errorText(e));
} finally {
setBusy("");
}
};
if (!ready) return <Spinner label="Opening the check" />;
const decided = extraction ? extraction.facts.filter((f) => review[f.id]).length : 0;
const steps: { id: Step; title: string; sub: string; done: boolean; enabled: boolean }[] = [
{ id: "records", title: "Records", sub: extraction?.stale ? "Changed since the last reading" : "Add and read", done: !!extraction && !extraction.stale, enabled: true },
{ id: "review", title: "Review", sub: extraction ? `${decided} of ${extraction.facts.length} decided` : "Confirm each fact", done: !!check && check.extractionId === extraction?.id, enabled: !!extraction && !extraction.stale },
{ id: "findings", title: "Findings", sub: check ? plural(check.assessment.contextual.findings.length, "finding") : "With and without the patient's details", done: false, enabled: !!check },
];
return (
<div className="check-tab flex flex-col gap-4">
{step !== "findings" && <NewInformation patient={patient} action={false} />}
<ol className="m-0 grid list-none grid-cols-1 gap-1 rounded-2xl bg-quiet p-1 sm:grid-cols-3" aria-label="Check steps">
{steps.map((s, i) => {
const current = step === s.id;
return (
<li key={s.id}>
<button
type="button"
aria-current={current ? "step" : undefined}
disabled={!s.enabled || !!busy}
onClick={() => setStep(s.id)}
className={cn(
"flex h-12 w-full cursor-pointer items-center gap-2.5 rounded-xl border-0 px-3 text-left font-sans outline-none transition-[background-color,box-shadow,color] focus-visible:shadow-[0_0_0_2px_var(--color-brand)] disabled:cursor-not-allowed disabled:opacity-50",
current ? "bg-page text-ink shadow-chip" : "bg-transparent text-muted hover:text-ink",
)}
>
<span
className={cn(
"flex size-[22px] shrink-0 items-center justify-center rounded-full font-mono text-[11px] font-medium",
current ? "bg-ink text-white" : s.done ? "bg-green-fill text-green-text" : "bg-page text-muted shadow-[inset_0_0_0_1px_var(--color-line)]",
)}
>
{s.done && !current ? <Icon name="check-line" size={13} /> : i + 1}
</span>
<span className="flex min-w-0 flex-col gap-0.5 leading-tight">
<strong className="text-[13px] font-medium">{s.title}</strong>
<small className="truncate text-[12px] text-muted">{s.sub}</small>
</span>
</button>
</li>
);
})}
</ol>
{error && (
<Notice tone="red" role="alert" icon="error-warning-line">
{error}
</Notice>
)}
{conflict && (
<Notice tone="amber" role="alert" icon="refresh-line" actions={<Button size="sm" onClick={() => void loadLatest()}>Load the latest</Button>}>
This review was changed in another tab. Load the latest version before you go on; your last change here was not saved.
</Notice>
)}
{busy && <Spinner label={busy} />}
{step === "records" && (
<>
<RecordsPanel patient={patient} onChanged={() => extraction && setExtraction({ ...extraction, stale: true })} />
{extraction && (
<div className="flex flex-wrap gap-2" aria-label="Reading progress">
{extraction.records.map((r) => {
const look = readLook[r.status] ?? { label: "Reading", tone: "amber" as const, icon: "loader-4-line" as const };
return (
<StatusPill key={r.recordId} size="sm" tone={look.tone} icon={look.icon}>
{r.title} · {look.label}
</StatusPill>
);
})}
</div>
)}
<div className="actionbar">
<div className="actionbar-inner">
<span className="hint">{extraction?.stale ? "The records changed. Read them again before the check." : "Context reads each record and links every fact to its line."}</span>
<Button variant="primary" disabled={!!busy} onClick={() => void read()}>
{extraction ? "Read the records again" : "Read records"}
</Button>
</div>
</div>
</>
)}
{step === "review" && extraction && (
<ReviewStep
patient={patient}
extraction={extraction}
review={review}
coverage={coverage}
acknowledged={acknowledged}
confirmed={confirmed}
onConfirm={(next) => save({ confirmed: next })}
busy={!!busy || conflict}
onReview={(next) => save({ review: next })}
onCoverage={(next) => save({ coverage: next })}
onAcknowledge={(value) => save({ acknowledged: value })}
onClarified={(ex) => adopt(ex)}
onRun={() => void run()}
/>
)}
{step === "findings" && check && <FindingsView patient={patient} check={check} onCheck={setCheck} />}
</div>
);
}