import { useState } from "react";
import { clinicianApi } from "../../api/client";
import type { Extraction, PatientDetail } from "../../api/types";
import { unresolvedCoverage, type CoverageCandidate, type CoverageReview } from "../../coverage";
import { checkBlocker } from "../../flow";
import { factDetail } from "../../logic/format";
import { conflicts } from "../../review";
import type { Fact, Review } from "../../types";
import { Button, DialogActions, FieldError, Icon, type IconName, Input, labelClass, Modal, Panel, Select, StatusPill } from "../kit";
import { displayName, useClinician } from "../session";
import { SourceDrawer, type SourceRef } from "../patient/SourceDrawer";
import { errorText } from "../ui";
const order: Fact["kind"][] = ["patient", "age", "sex", "weight", "pregnancy", "medicine", "allergy", "lab", "condition"];
const groupNames: Record<Fact["kind"], string> = {
patient: "Patient",
age: "Age",
sex: "Sex",
weight: "Weight",
pregnancy: "Pregnancy",
medicine: "Medicines",
allergy: "Allergies",
lab: "Lab results",
condition: "Conditions",
};
const factIcons: Record<string, IconName> = { medicine: "capsule-line", allergy: "error-warning-line", lab: "flask-line", weight: "scales-3-line", pregnancy: "women-line", condition: "heart-pulse-line" };
export function FactIcon({ kind }: { kind: string }) {
return <Icon name={factIcons[kind] ?? "user-line"} size={16} />;
}
type Props = {
patient: PatientDetail;
extraction: Extraction;
review: Review;
coverage: CoverageReview;
acknowledged: boolean;
/** Included Hard to read facts the clinician checked one by one (REC-5). */
confirmed: string[];
busy: boolean;
onConfirm: (ids: string[]) => void;
onReview: (r: Review) => void;
onCoverage: (c: CoverageReview) => void;
onAcknowledge: (v: boolean) => void;
onClarified: (ex: Extraction) => void;
onRun: () => void;
};
/** REV-1 to REV-5: every fact with its source line; include or exclude each; the omission block. */
const kindNames: Record<string, string> = { medicine: "Medicine", allergy: "Allergy", kidney: "Kidney result" };
export function ReviewStep({ extraction, review, coverage, acknowledged, confirmed, busy, onConfirm, onReview, onCoverage, onAcknowledge, onClarified, onRun }: Props) {
const { me } = useClinician();
const [source, setSource] = useState<SourceRef | null>(null);
const [filter, setFilter] = useState<"all" | "pending" | "excluded">("all");
const [adding, setAdding] = useState<CoverageCandidate | null>(null);
const [reasons, setReasons] = useState<Record<string, string>>({});
const title = (id: string) => recordTitle(extraction, id);
const selected = extraction.facts.filter((f) => review[f.id] === "include");
const conflictKeys = conflicts(selected);
const unconfirmed = extraction.facts.filter((f) => f.hardToRead && review[f.id] === "include" && !confirmed.includes(f.id));
const blocker =
checkBlocker({ draft: extraction, review, acknowledged, coverageReview: coverage, editing: false }) ??
(unconfirmed.length ? `Check ${unconfirmed.length === 1 ? "the Hard to read value" : `${unconfirmed.length} Hard to read values`} against the original before the check.` : null);
const unresolved = unresolvedCoverage(extraction.coverage ?? [], coverage, extraction.fingerprint);
const decided = extraction.facts.filter((f) => review[f.id]).length;
const visible = extraction.facts.filter((f) => (filter === "pending" ? !review[f.id] : filter === "excluded" ? review[f.id] === "exclude" : true));
const decide = (id: string, d: "include" | "exclude") => {
if (d === "exclude" && confirmed.includes(id)) onConfirm(confirmed.filter((x) => x !== id));
onReview({ ...review, [id]: d });
};
const toggleConfirmed = (id: string, on: boolean) => onConfirm(on ? [...new Set([...confirmed, id])] : confirmed.filter((x) => x !== id));
// Excluding an allergy needs the clinician's own reason (10 characters or more); the others take the choice alone.
const resolve = (c: CoverageCandidate, choice: "not_current" | "not_relevant") => {
const own = (reasons[c.id] ?? "").trim();
if (c.kind === "allergy" && own.length < 10) return;
onCoverage({
...coverage,
[c.id]: {
fingerprint: extraction.fingerprint,
choice,
reason: own || (choice === "not_current" ? "Not current: marked by the clinician" : "Not relevant: recorded, not checked"),
confirmedBy: displayName(me),
},
});
};
return (
<div className="review-step">
<div className="review-head">
<p className="answer-line">
<strong>
{decided} of {extraction.facts.length} decided
</strong>{" "}
· Nothing goes into the check until you have reviewed it.
</p>
{extraction.mode === "model" && extraction.model && (
<p className="m-0 flex flex-wrap items-center gap-2 text-[12.5px] text-muted">
<StatusPill tone="blue" icon="sparkling-fill" size="sm">
Read by {extraction.model.startsWith("muse-") ? `Meta Muse Spark (${extraction.model})` : extraction.model}
</StatusPill>
Every fact quotes its source line; anything that doesn't is dropped.
</p>
)}
<div className="row-wrap">
<div className="segmented" role="group" aria-label="Show facts">
{(["all", "pending", "excluded"] as const).map((f) => (
<button key={f} aria-pressed={filter === f} onClick={() => setFilter(f)}>
{f === "all" ? "All" : f === "pending" ? "Waiting" : "Excluded"}
</button>
))}
</div>
<button
className="button"
disabled={busy}
onClick={() => onReview({ ...review, ...Object.fromEntries(extraction.facts.filter((f) => !f.problem && !f.hardToRead && !review[f.id]).map((f) => [f.id, "include" as const])) })}
>
Include all supported
</button>
</div>
</div>
{order
.filter((kind) => visible.some((f) => f.kind === kind))
.map((kind) => (
<Panel key={kind} title={groupNames[kind]} icon={<FactIcon kind={kind} />} flush>
{visible
.filter((f) => f.kind === kind)
.map((f) => {
const d = review[f.id];
const sourceTitle = title(f.sourceId);
return (
<div key={f.id} className={`fact ${d ?? "pending"} ${conflictKeys.includes(f.key) && d === "include" ? "conflict" : ""} ${f.problem ? "problem" : ""}`}>
<span className={`fact-icon ${f.kind}`} aria-hidden="true">
<FactIcon kind={f.kind} />
</span>
<div className="fact-body">
<div className="fact-title">
<strong>{f.label}</strong>
{f.detail && <span>{factDetail(f.detail)}</span>}
</div>
{f.problem && (
<p className="fact-problem">
<Icon name="error-warning-line" size={14} /> {f.problem}
</p>
)}
{f.hardToRead && (
<label className="check hard-to-read">
<input type="checkbox" checked={confirmed.includes(f.id)} disabled={busy || d !== "include"} onChange={(e) => toggleConfirmed(f.id, e.target.checked)} />
<span>
<b>Hard to read.</b> {d === "include" ? "I checked this value against the original." : "Include it, then check the value against the original."}
</span>
</label>
)}
<div className="fact-links">
<button className="link quiet" onClick={() => setSource({ recordId: f.sourceId, title: sourceTitle, lines: [f.line] })}>
<Icon name="file-text-line" size={13} /> {sourceTitle} · line {f.line}
</button>
</div>
</div>
<div className="decision" role="group" aria-label={`Decision: ${f.label}`}>
<button aria-label={`Include ${f.label} from ${sourceTitle}`} aria-pressed={d === "include"} disabled={!!f.problem || busy} onClick={() => decide(f.id, "include")}>
<Icon name="check-line" size={15} />
<span>Include</span>
</button>
<button aria-label={`Exclude ${f.label} from ${sourceTitle}`} aria-pressed={d === "exclude"} disabled={busy} onClick={() => decide(f.id, "exclude")}>
<Icon name="close-line" size={15} />
<span>Exclude</span>
</button>
</div>
</div>
);
})}
</Panel>
))}
{unresolved.length > 0 || Object.keys(coverage).length > 0 ? (
<Panel title="Mentions the reader did not turn into facts" icon="capsule-line">
<p className="hint">Each mention must be added, marked not current, or marked not relevant before the check runs.</p>
<ul className="coverage-list">
{(extraction.coverage ?? [])
.filter((c) => !c.matchedFactIds.length && !c.contextExclusion)
.map((c) => {
const done = coverage[c.id];
const allergy = c.kind === "allergy";
const reasonShort = allergy && (reasons[c.id] ?? "").trim().length < 10;
return (
<li key={c.id}>
<div>
<small className="muted">{kindNames[c.kind ?? "medicine"]}</small> <b>{c.text}</b>
<button className="link quiet" onClick={() => setSource({ recordId: c.sourceId, title: title(c.sourceId), lines: [c.line] })}>
<q>{c.quote}</q>
</button>
{done && <small className="muted">{done.reason} · {done.confirmedBy}</small>}
{allergy && !done && (
<label className="stack">
<small>Why is this allergy not current or not relevant? (10 characters or more)</small>
<input value={reasons[c.id] ?? ""} disabled={busy} onChange={(e) => setReasons({ ...reasons, [c.id]: e.target.value })} />
</label>
)}
</div>
<div className="row-wrap">
{(c.kind ?? "medicine") === "medicine" && (
<button className="button" disabled={busy} onClick={() => setAdding(c)}>
Add it
</button>
)}
<button className="button" aria-pressed={done?.choice === "not_current" || done?.reason.startsWith("Not current")} disabled={busy || reasonShort} onClick={() => resolve(c, "not_current")}>
Not current
</button>
<button className="button" aria-pressed={done?.choice === "not_relevant" || done?.reason.startsWith("Not relevant")} disabled={busy || reasonShort} onClick={() => resolve(c, "not_relevant")}>
Not relevant
</button>
</div>
</li>
);
})}
</ul>
</Panel>
) : null}
{(extraction.unparsed.length > 0 || extraction.sourceReviewRequired) && (
<Panel title="Lines not turned into facts" icon="file-text-line" note={extraction.unparsed.length === 1 ? "1 line" : `${extraction.unparsed.length} lines`}>
<ul className="unparsed">
{extraction.unparsed.map((u) => (
<li key={`${u.sourceId}:${u.line}`}>
<button className="link quiet" onClick={() => setSource({ recordId: u.sourceId, title: title(u.sourceId), lines: [u.line] })}>
{title(u.sourceId)} · line {u.line}
</button>
<q>{u.text}</q>
</li>
))}
</ul>
<label className="check">
<input type="checkbox" checked={acknowledged} disabled={busy} onChange={(e) => onAcknowledge(e.target.checked)} />
<span>I read the original records for anything the reader missed. These lines are not checked by any rule.</span>
</label>
</Panel>
)}
<div className="actionbar">
<div className="actionbar-inner">
<span className={`hint ${blocker ? "blocked" : "ready"}`} role="status">
{blocker ?? "Ready. The check runs twice: the list alone, then with what you confirmed."}
</span>
<Button variant="primary" disabled={!!blocker || busy} onClick={onRun}>
Run the check
</Button>
</div>
</div>
<SourceDrawer source={source} onClose={() => setSource(null)} />
<AddMedicine candidate={adding} extraction={extraction} onClose={() => setAdding(null)} onAdded={(ex) => { setAdding(null); onClarified(ex); }} />
</div>
);
}
export const recordTitle = (ex: Extraction, id: string) => ex.titles?.[id] ?? ex.records.find((r) => r.recordId === id)?.title ?? "Record";
/** REV-4 "Add it": the clinician states the medicine; it is linked to the unchanged original line. */
function AddMedicine({ candidate, extraction, onClose, onAdded }: { candidate: CoverageCandidate | null; extraction: Extraction; onClose: () => void; onAdded: (ex: Extraction) => void }) {
const [form, setForm] = useState({ name: "", strength: "", amount: "", form: "tablet", perDay: "", reason: "" });
const [error, setError] = useState("");
const [forId, setForId] = useState<string | null>(null);
if (candidate && forId !== candidate.id) {
setForId(candidate.id);
setForm({ name: candidate.text.replace(/^./, (c) => c.toUpperCase()), strength: "", amount: "", form: "tablet", perDay: "", reason: "" });
setError("");
}
const save = async () => {
if (!candidate) return;
if (!form.name.trim()) return setError("Name the medicine.");
if (form.reason.trim().length < 3) return setError("Say how you confirmed it, for example 'Confirmed with the patient today'.");
const parts = [`Medication: ${form.name.trim()}`];
if (form.strength.trim()) parts.push(`strength: ${form.strength.trim()}`);
if (form.amount) parts.push(`form: ${form.form}`, `dose: ${form.amount} ${form.form}`);
if (form.perDay) parts.push(`frequency: ${form.perDay}/day`);
try {
onAdded(
await clinicianApi.addClarification(extraction.id, {
recordId: candidate.sourceId,
line: candidate.line,
quote: candidate.quote,
text: parts.join("; "),
reason: form.reason.trim(),
}),
);
} catch (e) {
setError(errorText(e));
}
};
return (
<Modal open={!!candidate} title="Add a medicine the reader missed" onClose={onClose}>
{candidate && (
<p className="m-0">
From <q>{candidate.quote}</q>. It stays linked to that line, with your name on it.
</p>
)}
<label className={labelClass}>
Medicine
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className={labelClass}>
Strength (optional)
<Input value={form.strength} placeholder="500 mg" onChange={(e) => setForm({ ...form, strength: e.target.value })} />
</label>
<label className={labelClass}>
Times a day (optional)
<Input inputMode="numeric" value={form.perDay} onChange={(e) => setForm({ ...form, perDay: e.target.value.replace(/[^\d.]/g, "") })} />
</label>
<label className={labelClass}>
Amount each time (optional)
<Input inputMode="numeric" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value.replace(/[^\d.]/g, "") })} />
</label>
<label className={labelClass}>
Form
<Select value={form.form} onChange={(e) => setForm({ ...form, form: e.target.value })}>
<option value="tablet">Tablet</option>
<option value="capsule">Capsule</option>
</Select>
</label>
</div>
<label className={labelClass}>
How you confirmed it
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} />
</label>
{error && <FieldError>{error}</FieldError>}
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={() => void save()}>
Add and read again
</Button>
</DialogActions>
</Modal>
);
}