// pages.jsx — Transcribe + Settings, wired to the real backend.
//
// Transcribe flow:
// Setup → Recording → Processing → Review & Save
// Setup picks a real patient (or adds a new one) and a template; Start
// creates a backend session and begins MediaRecorder. Stop ends the session
// (per CLAUDE.md gotcha #8: stop recorder → end session → upload audio),
// uploads the blob, polls for the generated note, and shows transcript +
// clinical note for review/save.
const { useState: useStateX, useEffect: useEffectX, useRef: useRefX, useMemo: useMemoX } = React;
const TX_FALLBACK_TEMPLATES = [
{ id: "soap", nameKey: "tx.templates.soap.name", descriptionKey: "tx.templates.soap.desc", fallbackName: "SOAP Note", fallbackDescription: "Subjective · Objective · Assessment · Plan" },
{ id: "progress", nameKey: "tx.templates.progress.name", descriptionKey: "tx.templates.progress.desc", fallbackName: "Progress Note", fallbackDescription: "Chief complaint · HPI · ROS · Plan" },
{ id: "referral", nameKey: "tx.templates.referral.name", descriptionKey: "tx.templates.referral.desc", fallbackName: "Referral Letter", fallbackDescription: "Patient summary · reason · request" },
{ id: "procedure", nameKey: "tx.templates.procedure.name", descriptionKey: "tx.templates.procedure.desc", fallbackName: "Procedure Note", fallbackDescription: "Indication · procedure · findings" },
];
const TX_LANGUAGES = [
{ id: "auto", labelKey: "tx.lang.auto", fallback: "Auto detect" },
{ id: "en", labelKey: "tx.lang.en", fallback: "English" },
{ id: "es", labelKey: "tx.lang.es", fallback: "Spanish" },
{ id: "es-419", labelKey: "tx.lang.es419", fallback: "Spanish (Latin America)" },
{ id: "es-ES", labelKey: "tx.lang.esES", fallback: "Spanish (Spain)" },
{ id: "ca", labelKey: "tx.lang.ca", fallback: "Catalan" },
{ id: "hi-IN", labelKey: "tx.lang.hi", fallback: "Hindi" },
{ id: "bn-IN", labelKey: "tx.lang.bn", fallback: "Bengali" },
{ id: "ta-IN", labelKey: "tx.lang.ta", fallback: "Tamil" },
{ id: "te-IN", labelKey: "tx.lang.te", fallback: "Telugu" },
{ id: "kn-IN", labelKey: "tx.lang.kn", fallback: "Kannada" },
{ id: "ml-IN", labelKey: "tx.lang.ml", fallback: "Malayalam" },
{ id: "mr-IN", labelKey: "tx.lang.mr", fallback: "Marathi" },
{ id: "gu-IN", labelKey: "tx.lang.gu", fallback: "Gujarati" },
{ id: "pa-IN", labelKey: "tx.lang.pa", fallback: "Punjabi" },
{ id: "od-IN", labelKey: "tx.lang.od", fallback: "Odia" },
];
function txTemplateName(t, tmpl) {
if (!tmpl) return "";
return (tmpl.nameKey && t(tmpl.nameKey)) || tmpl.name || tmpl.label || tmpl.fallbackName || tmpl.id;
}
function txTemplateDescription(t, tmpl) {
if (!tmpl) return "";
return (tmpl.descriptionKey && t(tmpl.descriptionKey)) || tmpl.description || tmpl.desc || tmpl.fallbackDescription || t("tx.templateHelp");
}
function pickMimeType() {
if (typeof MediaRecorder === "undefined") return null;
const candidates = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/mp4;codecs=mp4a.40.2",
"audio/mp4",
];
for (const m of candidates) {
try { if (MediaRecorder.isTypeSupported(m)) return m; } catch (_) {}
}
return "";
}
function txLanguageLabel(t, lang) {
return (lang.labelKey && t(lang.labelKey)) || lang.fallback || lang.id;
}
function defaultTxLanguage() {
if (window.MSStore && window.MSStore.getStoredDefaultEncounterLanguage) {
return window.MSStore.getStoredDefaultEncounterLanguage();
}
return "auto";
}
// ── Step 1: Setup (patient + language + template) ────────────────────────────
function TxSetup({ onStart }) {
const store = window.MSStore.useStore();
const t = window.MSStore.useT();
const [lang] = window.MSStore.useLang();
const [patient, setPatient] = useStateX(null);
const [templateId, setTemplateId] = useStateX("soap");
const [language, setLanguage] = useStateX(defaultTxLanguage);
const [patSearch, setPatSearch] = useStateX("");
const [addingNew, setAddingNew] = useStateX(false);
const [newPat, setNewPat] = useStateX({ first: "", last: "", dob: "", sex: "F", phone: "", email: "" });
const setNP = (k, v) => setNewPat(p => ({ ...p, [k]: v }));
const [adding, setAdding] = useStateX(false);
const [addErr, setAddErr] = useStateX("");
useEffectX(() => {
if (window.MSStore && window.MSStore.getStoredDefaultEncounterLanguage) {
setLanguage(window.MSStore.getStoredDefaultEncounterLanguage());
return;
}
setLanguage(current => current === "en" || current === "es" ? (lang === "es" ? "es" : "en") : current);
}, [lang]);
const todayPats = useMemoX(() => {
const today = new Date(); today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today.getTime() + 86400000);
const seen = new Set();
const out = [];
for (const a of (store.appointments || [])) {
const t = new Date(a.scheduledAt);
if (isNaN(t) || t < today || t >= tomorrow || !a.patientName) continue;
const key = a.patientName.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
const p = (store.patients || []).find(p => `${p.first} ${p.last}`.toLowerCase() === key) || {
id: a.patientName, first: a.patientName.split(" ")[0], last: a.patientName.split(" ").slice(1).join(" "),
avatar: (a.patientName || "?").split(" ").map(s => s[0]).slice(0, 2).join("").toUpperCase(),
phone: a.raw && a.raw.patient_phone || "",
email: a.raw && a.raw.patient_email || "",
};
out.push({ ...p, _appt: a });
}
return out;
}, [store.appointments, store.patients]);
const searchResults = useMemoX(() => {
if (!patSearch) return [];
const ql = patSearch.toLowerCase();
return (store.patients || [])
.filter(p => `${p.first} ${p.last}`.toLowerCase().includes(ql) || (p.phone || "").includes(patSearch) || (p.email || "").toLowerCase().includes(ql))
.slice(0, 6);
}, [patSearch, store.patients]);
const newPatValid = newPat.first.trim() && newPat.last.trim();
const tmplList = (store.templates && store.templates.length ? store.templates : TX_FALLBACK_TEMPLATES);
async function quickAddPatient() {
if (!newPatValid || adding) return;
setAdding(true); setAddErr("");
try {
const fullName = `${newPat.first.trim()} ${newPat.last.trim()}`;
let age = "";
if (newPat.dob) {
const d = new Date(newPat.dob);
if (!isNaN(d)) age = String(Math.floor((Date.now() - d.getTime()) / (365.25 * 86400000)));
}
await store.createPatient({ name: fullName, age, phone: newPat.phone, email: newPat.email });
setPatient({
id: fullName,
first: newPat.first.trim(),
last: newPat.last.trim(),
avatar: ((newPat.first[0] || "") + (newPat.last[0] || "")).toUpperCase(),
age, phone: newPat.phone, email: newPat.email,
});
setAddingNew(false);
setNewPat({ first: "", last: "", dob: "", sex: "F", phone: "", email: "" });
} catch (e) {
setAddErr(e && e.message || t("tx.errors.addPatient"));
} finally {
setAdding(false);
}
}
return (
{/* Patient column */}
{t("tx.step1")}
{t("tx.selectPatient")}
{!addingNew ? (
<>
{!patSearch && todayPats.length > 0 && (
{t("common.today")}
{todayPats.map(p => {
const sel = patient && patient.id === p.id;
return (
setPatient(p)}
style={{appearance: "none", background: sel ? "var(--primary-soft)" : "var(--bg-soft)", border: "1px solid " + (sel ? "var(--primary-line)" : "var(--line)"), borderRadius: 10, padding: "10px 12px", cursor: "pointer", textAlign: "left", display: "flex", alignItems: "center", gap: 10}}>
{p.avatar}
{p.first} {p.last}
{p._appt && p._appt.time}
{sel && }
);
})}
)}
setPatSearch(e.target.value)} placeholder={t("tx.searchPatients")}/>
{patSearch && setPatSearch("")}> }
{patSearch && (
{searchResults.map(p => {
const sel = patient && patient.id === p.id;
return (
{ setPatient(p); setPatSearch(""); }}
style={{appearance: "none", background: sel ? "var(--primary-soft)" : "transparent", border: "1px solid " + (sel ? "var(--primary-line)" : "transparent"), borderRadius: 8, padding: "8px 10px", cursor: "pointer", textAlign: "left", display: "flex", alignItems: "center", gap: 10}}>
{p.avatar}
{p.first} {p.last}
{p.phone || p.email || ""}
{sel && }
);
})}
{searchResults.length === 0 &&
{t("tx.noPatients")}
}
)}
setAddingNew(true)}>
{t("tx.addNew")}
>
) : (
{addErr &&
{addErr}
}
setAddingNew(false)}>{t("common.cancel")}
{adding ? t("tx.adding") : t("tx.addPatient")}
)}
{patient && (
{patient.avatar}
{patient.first} {patient.last}
setPatient(null)}>
)}
{/* Template column */}
{t("tx.step2")}
{t("tx.configureEncounter")}
{t("tx.language")}
setLanguage(e.target.value)} aria-label={t("tx.language")}>
{TX_LANGUAGES.map(lang => (
{txLanguageLabel(t, lang)}
))}
{t("tx.chooseTemplate")}
setTemplateId(e.target.value)} aria-label={t("tx.chooseTemplate")}>
{tmplList.map(tmpl => (
{txTemplateName(t, tmpl)}
))}
{(() => {
const selected = tmplList.find(tmpl => tmpl.id === templateId) || tmplList[0] || {};
return (
{txTemplateDescription(t, selected)}
);
})()}
patient && onStart(patient, templateId, language)}>
{t("tx.startRecording")}
{!patient &&
{t("tx.selectPatientFirst")}
}
);
}
// ── Step 2: Recording (real MediaRecorder) ───────────────────────────────────
function TxRecording({ patient, onStop, onCancel }) {
const t = window.MSStore.useT();
const [elapsed, setElapsed] = useStateX(0);
const [stopped, setStopped] = useStateX(false);
const [paused, setPaused] = useStateX(false);
const [error, setError] = useStateX("");
const [level, setLevel] = useStateX(0);
const recRef = useRefX(null);
const chunksRef = useRefX([]);
const streamRef = useRefX(null);
const audioCtxRef = useRefX(null);
const rafRef = useRefX(null);
const pausedRef = useRefX(false);
useEffectX(() => {
let cancelled = false;
(async () => {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error(t("tx.cantRecord"));
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (cancelled) { stream.getTracks().forEach(t => t.stop()); return; }
streamRef.current = stream;
try {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx) {
const audioCtx = new AudioCtx();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.78;
const source = audioCtx.createMediaStreamSource(stream);
source.connect(analyser);
audioCtxRef.current = audioCtx;
const data = new Uint8Array(analyser.fftSize);
const tick = () => {
if (pausedRef.current) {
setLevel(0);
rafRef.current = requestAnimationFrame(tick);
return;
}
analyser.getByteTimeDomainData(data);
let sum = 0;
for (let i = 0; i < data.length; i++) {
const centered = (data[i] - 128) / 128;
sum += centered * centered;
}
const rms = Math.sqrt(sum / data.length);
const normalized = Math.min(1, Math.max(0, (rms - 0.012) * 7.5));
setLevel(prev => prev * 0.72 + normalized * 0.28);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
}
} catch (_) {
// Recording still works without the analyser; CSS keeps a fallback pulse.
}
const mime = pickMimeType();
const rec = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
recRef.current = rec;
rec.addEventListener("dataavailable", (e) => {
if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
});
rec.start();
} catch (e) {
const raw = (e && (e.name || e.message)) || "";
const micMissing = /notfound|not found|devicesnotfound/i.test(raw);
const micDenied = /notallowed|permission|denied/i.test(raw);
setError(micMissing ? t("tx.micNotFound") : micDenied ? t("tx.micDenied") : t("tx.couldNotStart"));
}
})();
const id = setInterval(() => setElapsed(e => pausedRef.current ? e : e + 1), 1000);
return () => {
cancelled = true;
clearInterval(id);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
try { audioCtxRef.current && audioCtxRef.current.close(); } catch (_) {}
try { recRef.current && recRef.current.state !== "inactive" && recRef.current.stop(); } catch (_) {}
const s = streamRef.current;
if (s) s.getTracks().forEach(t => { try { t.stop(); } catch (_) {} });
};
}, []);
const fmt = s => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
function finalize() {
if (stopped) return;
setStopped(true);
pausedRef.current = false;
setPaused(false);
const rec = recRef.current;
if (!rec) {
onCancel(t("tx.errors.noRecorder"));
return;
}
const finishWithBlob = () => {
const type = rec.mimeType || "audio/webm";
const blob = new Blob(chunksRef.current, { type });
// Stop the mic.
const s = streamRef.current;
if (s) s.getTracks().forEach(t => { try { t.stop(); } catch (_) {} });
if (rafRef.current) cancelAnimationFrame(rafRef.current);
try { audioCtxRef.current && audioCtxRef.current.close(); } catch (_) {}
setLevel(0);
onStop(blob, elapsed);
};
if (rec.state === "inactive") {
finishWithBlob();
} else {
rec.addEventListener("stop", finishWithBlob, { once: true });
try { rec.stop(); } catch (_) { finishWithBlob(); }
}
}
function togglePause() {
if (stopped) return;
const rec = recRef.current;
if (!rec || rec.state === "inactive") return;
try {
if (pausedRef.current || rec.state === "paused") {
if (rec.state === "paused") rec.resume();
pausedRef.current = false;
setPaused(false);
} else if (rec.state === "recording") {
rec.pause();
pausedRef.current = true;
setPaused(true);
setLevel(0);
}
} catch (_) {
setError(t("tx.errors.pause"));
}
}
if (error) {
return (
{error}
{t("tx.backToSetup")}
);
}
const isVoiceActive = !paused && level > 0.16;
const orbStyle = {
width: 200,
height: 200,
"--voice-level": level.toFixed(3),
};
const bars = Array.from({length: 32}).map((_, i) => {
const phase = Math.abs(Math.sin(i * 0.62));
const boosted = Math.min(1, 0.18 + level * (0.7 + phase * 0.55));
return isVoiceActive
? {
height: `${22 + boosted * 74}%`,
opacity: 0.48 + Math.min(0.52, level * 1.4),
transform: `scaleY(${0.45 + boosted * 0.75})`,
}
: {
height: "2px",
opacity: 0.5,
transform: "scaleY(1)",
};
});
return (
{patient.avatar}
{patient.first} {patient.last}
{patient.age && {patient.age} }
{paused &&
{t("tx.paused")} }
{bars.map((style, i) => (
))}
{fmt(elapsed)}
{paused ? : }
{paused ? t("tx.resume") : t("tx.pause")}
{stopped ? t("tx.stopping") : paused ? t("tx.paused") : t("tx.tapToStop")}
);
}
// ── Step 3: Processing — uploads, ends session, polls for note ───────────────
function TxProcessing({ patient, blob, templateId, language, onReady, onError }) {
const t = window.MSStore.useT();
const [detail, setDetail] = useStateX(t("tx.creatingSession"));
const [progress, setProgress] = useStateX(8);
const ranRef = useRefX(false);
const store = window.MSStore.useStore();
useEffectX(() => {
if (ranRef.current) return;
ranRef.current = true;
let cancelled = false;
(async () => {
let sessionId = null;
try {
// 1. Start session.
setDetail(t("tx.creatingSession"));
setProgress(14);
const fullName = `${patient.first} ${patient.last}`.trim();
const session = await store.startSession({
patient_name: fullName,
patient_phone: patient.phone || "",
patient_email: patient.email || "",
language: language || "auto",
});
if (cancelled) return;
sessionId = session && (session.id || session.session_id);
if (!sessionId) throw new Error(t("tx.errors.createSession"));
// 2. End session BEFORE uploading audio (CLAUDE.md gotcha #8: the
// backend rejects upload if the session is ended after upload).
setDetail(t("tx.endingSession"));
setProgress(34);
try {
await store.endSession(sessionId);
} catch (e) {
throw new Error(t("tx.errors.endSession") + ": " + (e && e.message || e));
}
if (cancelled) return;
// 3. Upload audio — the backend transcribes (sync) and then queues
// clinical-note generation in the background. We do NOT call
// /summarize separately because /upload-audio already enqueues it
// when transcripts exist; calling /summarize again races and errors
// with "No transcript available" if Deepgram returned 0 segments.
if (!blob || blob.size === 0) {
throw new Error(t("tx.errors.noAudio"));
}
setDetail(t("tx.uploadingSized", { size: (blob.size / 1024 / 1024).toFixed(1) }));
setProgress(58);
try {
await store.uploadAudio(sessionId, blob, templateId);
} catch (e) {
throw new Error(t("tx.errors.upload") + ": " + (e && e.message || e));
}
if (cancelled) return;
// 4. Show the transcript immediately. Note generation continues in the
// background and the review screen refreshes itself when ready.
setDetail(t("tx.loadingTranscript"));
setProgress(86);
let sessionFull = null;
try { sessionFull = await store.getSession(sessionId); } catch (_) {}
if (cancelled) return;
if (!sessionFull) throw new Error(t("tx.errors.loadSession"));
setProgress(100);
setTimeout(() => onReady({ sessionId, session: sessionFull, note: sessionFull.clinical_note || null }), 250);
} catch (e) {
if (cancelled) return;
onError(e && e.message ? e.message : t("tx.errors.process"), sessionId);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
{detail}
{progress}%
{t("tx.processingHint")}
);
}
// ── Step 4: Review & Save ────────────────────────────────────────────────────
function TxReview({ patient, sessionId, session, note, onDone, onSaved }) {
const store = window.MSStore.useStore();
const t = window.MSStore.useT();
const [sessionData, setSessionData] = useStateX(session || null);
const [noteText, setNoteText] = useStateX((note && note.note_text) || "");
const [noteReady, setNoteReady] = useStateX(!!(note && note.note_text));
const [ignoreNoteCreatedAt, setIgnoreNoteCreatedAt] = useStateX("");
const [addingMore, setAddingMore] = useStateX(false);
const [appendPending, setAppendPending] = useStateX(false);
const [saved, setSaved] = useStateX(false);
const [pending, setPending] = useStateX(false);
const [error, setError] = useStateX("");
const [previewOpen, setPreviewOpen] = useStateX(false);
const [copiedNote, setCopiedNote] = useStateX(false);
const [followupAppointment, setFollowupAppointment] = useStateX(null);
useEffectX(() => {
if (!sessionId || noteReady) return;
let cancelled = false;
const started = Date.now();
async function poll() {
try {
const fresh = await store.getSession(sessionId);
if (cancelled) return;
if (fresh) setSessionData(fresh);
const freshNote = fresh && fresh.clinical_note;
if (freshNote && freshNote.note_text && freshNote.created_at !== ignoreNoteCreatedAt) {
setNoteText(freshNote.note_text);
setNoteReady(true);
setAppendPending(false);
return;
}
} catch (_) {}
if (!cancelled && Date.now() - started < 120_000) setTimeout(poll, 3000);
if (!cancelled && Date.now() - started >= 120_000) setAppendPending(false);
}
const id = setTimeout(poll, 3000);
return () => { cancelled = true; clearTimeout(id); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, noteReady, ignoreNoteCreatedAt]);
const transcripts = (sessionData && sessionData.transcripts) || [];
const followupText = window.MSFollowupTextFromSession ? window.MSFollowupTextFromSession(sessionData, noteText) : "";
async function handleSave() {
if (!noteText.trim() || pending) return;
setError("");
setPending(true);
try {
await store.saveClinicalNote(sessionId, noteText);
const followupRes = await store.ensureFollowupDraft(sessionId);
const followupRaw = followupRes && followupRes.appointment;
if (followupRaw) {
setFollowupAppointment({
scheduledAt: followupRaw.scheduled_at,
reason: followupRaw.reason || "",
});
}
if (onSaved) onSaved();
setSaved(true);
} catch (e) {
setError(e && e.message || t("tx.errors.saveNote"));
} finally {
setPending(false);
}
}
function handleCopyNote() {
if (!noteText.trim()) return;
window.copyClinicalNoteText(noteText, () => {
setCopiedNote(true);
setTimeout(() => setCopiedNote(false), 1600);
});
}
async function handleAddMore(blob) {
if (!blob || blob.size === 0 || appendPending) {
setAddingMore(false);
setError(t("tx.errors.noAudio"));
return;
}
const previousNote = sessionData && sessionData.clinical_note;
setAddingMore(false);
setAppendPending(true);
setError("");
setIgnoreNoteCreatedAt((previousNote && previousNote.created_at) || "");
setNoteReady(false);
setNoteText("");
try {
const template = (previousNote && previousNote.template_used) || "soap";
await store.uploadAudio(sessionId, blob, template);
const fresh = await store.getSession(sessionId);
if (fresh) setSessionData(fresh);
} catch (e) {
setAppendPending(false);
setError(e && e.message || t("tx.errors.upload"));
}
}
const NotePreviewModal = window.ClinicalNotePreviewModal;
if (saved) {
return (
{t("tx.saved")}
{patient.first} {patient.last}
{followupText && (
Follow-up captured
{followupText}
)}
{followupAppointment ? (
Follow-up added to calendar
{new Date(followupAppointment.scheduledAt).toLocaleString()} · {followupAppointment.reason || "Follow-up visit"}
) : (
No follow-up calendar item was detected for this note.
)}
{t("tx.startNew")}
);
}
return (
{t("tx.transcript")}
{patient.first} {patient.last} · {t("tx.transcriptCount", { n: transcripts.length })}
{ setError(""); setAddingMore(true); }} disabled={addingMore || appendPending}>
{appendPending ? t("tx.addMoreProcessing") : t("tx.addMore")}
{addingMore && (
{t("tx.addMore")}
{t("tx.addMoreHint")}
setAddingMore(false)}>{t("common.cancel")}
setAddingMore(false)}/>
)}
{appendPending && (
{t("tx.addMoreProcessing")}
)}
{transcripts.length === 0 && (
{t("tx.transcriptEmpty")}
)}
{transcripts.map((seg, i) => {
const speaker = seg.speaker_label || (i % 2 === 0 ? "Provider" : "Patient");
const isProvider = String(speaker).toLowerCase().includes("provider") || String(speaker).toLowerCase().startsWith("doctor") || String(speaker) === "0";
const text = seg.corrected_text || seg.auto_corrected_text || seg.text || "";
return (
{isProvider ? "Dr" : (patient.avatar || "Pt")}
{isProvider ? t("tx.provider") : t("tx.patient")}
{text}
);
})}
{t("tx.note.title")}
{noteReady ? t("tx.note.aiSub") : t("tx.note.pending")}
{followupText && (
Follow-up captured
{followupText}
)}
setPreviewOpen(true)}>
Preview
{copiedNote ? "Copied" : "Copy"}
{error &&
{error}
}
{pending ? t("common.saving") : t("tx.saveTo", { name: patient.first })}
{t("tx.discard")}
setPreviewOpen(false)}
/>
);
}
// ── Main Transcribe component ─────────────────────────────────────────────────
function Transcribe() {
const t = window.MSStore.useT();
const [step, setStep] = useStateX("setup");
const [patient, setPatient] = useStateX(null);
const [templateId, setTemplateId] = useStateX("soap");
const [language, setLanguage] = useStateX(defaultTxLanguage);
const [recordingBlob, setRecordingBlob] = useStateX(null);
const [resultPayload, setResultPayload] = useStateX(null);
const [errorMsg, setErrorMsg] = useStateX("");
const [errorSessionId, setErrorSessionId] = useStateX(null);
const [reviewSaved, setReviewSaved] = useStateX(false);
const handleStart = (p, tmpl, lang) => { setPatient(p); setTemplateId(tmpl); setLanguage(lang || "en"); setReviewSaved(false); setStep("recording"); };
const handleStop = (blob) => { setRecordingBlob(blob); setReviewSaved(false); setStep("processing"); };
const handleReady = (payload) => { setResultPayload(payload); setReviewSaved(false); setStep("review"); };
const handleError = (msg, sessionId) => {
setErrorMsg(msg);
setErrorSessionId(sessionId || null);
setStep("error");
};
const handleDone = () => {
setStep("setup"); setPatient(null); setRecordingBlob(null); setResultPayload(null); setErrorMsg(""); setErrorSessionId(null); setReviewSaved(false); setLanguage(defaultTxLanguage());
};
const handleCancel = () => {
setStep("setup"); setRecordingBlob(null);
};
const handleRetry = () => {
setStep("processing");
setErrorMsg(""); setErrorSessionId(null);
};
const stepLabels = [
{ key: "setup", label: t("tx.step.setup") },
{ key: "recording", label: t("tx.step.recording") },
{ key: "review", label: t("tx.step.review") },
{ key: "save", label: t("tx.step.save") },
];
const stepKeys = stepLabels.map(s => s.key);
const stepKey = reviewSaved ? "save" : (step === "processing" || step === "error" ? "review" : step);
const stepIdx = stepKeys.indexOf(stepKey);
return (
{t("tx.title")}
{t("tx.sub")}
{stepLabels.map((s, i) => {
const isDone = i < stepIdx || (reviewSaved && s.key === "save");
const isActive = i === stepIdx && !isDone;
return (
{isDone ? : {i + 1} }
{s.label}
{i < stepLabels.length - 1 &&
}
);
})}
{step === "setup" &&
}
{step === "recording" && }
{step === "processing" && }
{step === "review" && setReviewSaved(true)}/>}
{step === "error" && (
Recording captured, but processing failed
{errorMsg || t("tx.unknownError")}
Common causes: microphone permission was denied, the audio was silent, the network dropped during upload, or the transcription service is unreachable. {errorSessionId ? <>The session {errorSessionId.slice(0, 8)} was created — you can review it in the patient's chart later.> : null}
Start over
Re-record
{recordingBlob && Retry processing }
)}
);
}
/* ──────────────────────────── DICTIONARY ──────────────────────────── */
function Dictionary() {
const t = window.MSStore.useT();
const [entries, setEntries] = useStateX([]);
const [loading, setLoading] = useStateX(true);
const [saving, setSaving] = useStateX(false);
const [error, setError] = useStateX("");
const [query, setQuery] = useStateX("");
const [form, setForm] = useStateX({ term: "", original_mistake: "", category: "medical" });
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
async function loadDictionary() {
setLoading(true);
setError("");
try {
const data = await window.MSApi.apiFetch("/api/dictionary");
setEntries(Array.isArray(data) ? data : []);
} catch (e) {
setError(e && e.message || t("dict.errorLoad"));
} finally {
setLoading(false);
}
}
useEffectX(() => { loadDictionary(); }, []);
async function addEntry(e) {
e.preventDefault();
const term = form.term.trim();
const original = form.original_mistake.trim();
if (!term || !original || saving) return;
setSaving(true);
setError("");
try {
const row = await window.MSApi.apiFetch("/api/dictionary", {
method: "POST",
body: { term, original_mistake: original, category: form.category || "other" },
});
setEntries(list => [row, ...list.filter(x => x && x.id !== row.id)]);
setForm({ term: "", original_mistake: "", category: "medical" });
} catch (err) {
setError(err && err.message || t("dict.errorAdd"));
} finally {
setSaving(false);
}
}
async function deleteEntry(id) {
if (!id) return;
setError("");
const before = entries;
setEntries(list => list.filter(entry => entry.id !== id));
try {
await window.MSApi.apiFetch(`/api/dictionary/${id}`, { method: "DELETE" });
} catch (err) {
setEntries(before);
setError(err && err.message || t("dict.errorDelete"));
}
}
const filtered = useMemoX(() => {
const q = query.trim().toLowerCase();
if (!q) return entries;
return entries.filter(entry => {
const haystack = [entry.term, entry.original_mistake, entry.category].filter(Boolean).join(" ").toLowerCase();
return haystack.includes(q);
});
}, [entries, query]);
const canAdd = form.term.trim() && form.original_mistake.trim() && !saving;
const categories = ["medical", "medication", "diagnosis", "procedure", "other"];
return (
{t("dict.title")}
{t("dict.sub")}
{t("common.refresh")}
{t("dict.term")}
{t("dict.mistake")}
{t("dict.frequency")}
{t("dict.lastUsed")}
{loading ? (
{t("common.loading")}
) : filtered.length === 0 ? (
{query ? t("dict.emptySearch") : t("dict.empty")}
) : filtered.map(entry => (
{entry.term}
{entry.category || "other"}
{entry.original_mistake}
{entry.frequency || 1}
{entry.last_used ? String(entry.last_used).slice(0, 10) : "—"}
deleteEntry(entry.id)} aria-label={t("common.delete")} title={t("common.delete")}>
))}
);
}
/* ──────────────────────────── SETTINGS ──────────────────────────── */
function Settings() {
const t = window.MSStore.useT();
const [section, setSection] = useStateX("profile");
const sections = [
{ id: "profile", label: t("set.nav.profile"), icon: Icon.User },
{ id: "clinic", label: t("set.nav.clinic"), icon: Icon.Pin },
{ id: "whatsapp", label: "WhatsApp", icon: Icon.Phone },
{ id: "team", label: t("set.nav.team"), icon: Icon.Patients },
{ id: "security", label: t("set.nav.security"), icon: Icon.Heart },
];
return (
{t("set.title")}
{t("set.sub")}
{sections.map(s => (
setSection(s.id)}>
{s.label}
))}
{section === "profile" && }
{section === "clinic" && }
{section === "whatsapp" && }
{section === "team" && }
{section === "security" && }
);
}
function SetCard({ title, sub, children, action }) {
return (
);
}
function SetRow({ label, hint, children }) {
return (
);
}
function SetProfile() {
const store = window.MSStore.useStore();
const t = window.MSStore.useT();
const [appLang, setAppLang] = window.MSStore.useLang();
const prefs = window.MSStore.usePreferences();
const profile = store.profile || {};
const [form, setForm] = useStateX({
name: profile.name || "",
specialty: profile.specialty || "",
phone: profile.phone || "",
license_number: profile.license_number || "",
bio: profile.bio || "",
});
const [pending, setPending] = useStateX(false);
const [saved, setSaved] = useStateX(false);
const [error, setError] = useStateX("");
useEffectX(() => {
setForm({
name: profile.name || "",
specialty: profile.specialty || "",
phone: profile.phone || "",
license_number: profile.license_number || "",
bio: profile.bio || "",
});
}, [profile.name, profile.specialty, profile.phone, profile.license_number, profile.bio]);
const set = (k, v) => setForm(f => ({...f, [k]: v}));
async function save() {
setPending(true); setError(""); setSaved(false);
try {
await store.updateProfile(form);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
setError(e && e.message || t("set.profile.error"));
} finally {
setPending(false);
}
}
const initials = (profile.name || "MS").split(" ").map(s => s[0]).slice(0, 2).join("").toUpperCase();
return (
<>
{initials}
{profile.email || ""}
{t("set.profile.signedInVia", { method: profile.auth_provider === "google" ? t("set.profile.googleSso") : t("set.profile.password") })}
{t("set.profile.displayName")}
set("name", e.target.value)} placeholder="Dr. Jane Doe"/>
{t("set.profile.specialty")}
set("specialty", e.target.value)} placeholder="Family Medicine"/>
{t("set.profile.phone")}
set("phone", e.target.value)} placeholder="(555) 555-0100"/>
{t("set.profile.license")}
set("license_number", e.target.value)}/>
{window.MSi18n && window.MSi18n.SUPPORTED.map(code => (
setAppLang(code)} aria-pressed={appLang === code}>
{code.toUpperCase()}
))}
prefs.setDefaultEncounterLanguage(e.target.value)} aria-label={t("set.preferences.encounterLanguage")}>
{TX_LANGUAGES.map(lang => (
{txLanguageLabel(t, lang)}
))}
prefs.setTheme("light")} aria-pressed={prefs.theme === "light"}>
{t("set.preferences.themeLight")}
prefs.setTheme("dark")} aria-pressed={prefs.theme === "dark"}>
{t("set.preferences.themeDark")}
{error && {error} }
{saved && {t("common.saved")} }
{pending ? t("common.saving") : t("set.profile.save")}
>
);
}
function SetClinic() {
const store = window.MSStore.useStore();
const account = store.activeAccount || {};
const [name, setName] = useStateX(account.name || "");
const [startHour, setStartHour] = useStateX(account.business_start_hour != null ? account.business_start_hour : 9);
const [endHour, setEndHour] = useStateX(account.business_end_hour != null ? account.business_end_hour : 18);
const [pending, setPending] = useStateX(false);
const [saved, setSaved] = useStateX(false);
const [error, setError] = useStateX("");
useEffectX(() => {
setName(account.name || "");
setStartHour(account.business_start_hour != null ? account.business_start_hour : 9);
setEndHour(account.business_end_hour != null ? account.business_end_hour : 18);
}, [account.id, account.name, account.business_start_hour, account.business_end_hour]);
function fmtHour(h) {
if (h === 0) return "12 AM";
if (h < 12) return h + " AM";
if (h === 12) return "12 PM";
return (h - 12) + " PM";
}
async function save() {
setPending(true); setError(""); setSaved(false);
try {
await store.updateAccount(name, { business_start_hour: startHour, business_end_hour: endHour });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
setError(e && e.message || "Could not update clinic.");
} finally {
setPending(false);
}
}
const hourOptions = Array.from({length: 24}, (_, i) => i);
return (
<>
Clinic name
setName(e.target.value)}/>
{account.type === "team" ? "Clinic" : (account.type || "individual")}
{store.activeRole || "—"}
Opens at
setStartHour(Number(e.target.value))}>
{hourOptions.map(h => {fmtHour(h)} )}
Closes at
setEndHour(Number(e.target.value))}>
{hourOptions.filter(h => h > startHour).map(h => {fmtHour(h)} )}
Currently: {fmtHour(startHour)} – {fmtHour(endHour)} ({endHour - startHour} hours)
{error && {error} }
{saved && Saved. }
{pending ? "Saving…" : "Save"}
>
);
}
function SetWhatsApp() {
const store = window.MSStore.useStore();
const [data, setData] = useStateX(null);
const [number, setNumber] = useStateX("");
const [pending, setPending] = useStateX(false);
const [loading, setLoading] = useStateX(true);
const [saved, setSaved] = useStateX(false);
const [error, setError] = useStateX("");
const [copied, setCopied] = useStateX(false);
const [provisionOpen, setProvisionOpen] = useStateX(false);
const [provClinicName, setProvClinicName] = useStateX("");
const [provCountry, setProvCountry] = useStateX("US");
const [provAreaCode, setProvAreaCode] = useStateX("");
const [provisioning, setProvisioning] = useStateX(false);
const [provResult, setProvResult] = useStateX(null);
const [linkOpen, setLinkOpen] = useStateX(false);
const [linkPhone, setLinkPhone] = useStateX("");
const [linkName, setLinkName] = useStateX("");
const [linkSid, setLinkSid] = useStateX("");
const [linkToken, setLinkToken] = useStateX("");
const [linking, setLinking] = useStateX(false);
async function load() {
setLoading(true); setError("");
try {
const r = await window.MSApi.apiFetch("/api/account/integrations");
setData(r || {});
setNumber((r && r.whatsapp_number) || "");
} catch (e) {
setError(e && e.message || "Could not load WhatsApp settings.");
} finally {
setLoading(false);
}
}
useEffectX(() => { load(); }, [store.activeAccount && store.activeAccount.id]);
async function save() {
setPending(true); setError(""); setSaved(false);
try {
const r = await window.MSApi.apiFetch("/api/account/whatsapp-number", {
method: "PATCH",
body: { whatsapp_number: number.trim() },
});
setData(d => ({...(d || {}), ...(r || {})}));
setNumber((r && r.whatsapp_number) || "");
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
setError(e && e.message || "Could not update WhatsApp number.");
} finally {
setPending(false);
}
}
async function copyWebhook() {
const url = data && data.webhook_url;
if (!url) return;
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 1600);
} catch (_) {}
}
async function provisionNumber() {
if (!provClinicName.trim()) return;
setProvisioning(true); setError(""); setProvResult(null);
try {
const r = await window.MSApi.apiFetch("/api/whatsapp/provision", {
method: "POST",
body: {
clinic_name: provClinicName.trim(),
country: provCountry,
area_code: provAreaCode.trim(),
},
});
setProvResult(r);
setProvisionOpen(false);
setProvClinicName("");
setProvAreaCode("");
await load(); // Refresh senders list
} catch (e) {
setError(e && e.message || "Provisioning failed.");
} finally {
setProvisioning(false);
}
}
async function deactivateSender(senderId) {
if (!confirm("Deactivate this WhatsApp sender? It will stop receiving messages.")) return;
try {
await window.MSApi.apiFetch(`/api/whatsapp/senders/${senderId}`, { method: "DELETE" });
await load();
} catch (e) {
setError(e && e.message || "Could not deactivate sender.");
}
}
async function linkExisting() {
if (!linkPhone.trim()) return;
setLinking(true); setError("");
try {
const r = await window.MSApi.apiFetch("/api/whatsapp/link-existing", {
method: "POST",
body: {
phone_number: linkPhone.trim(),
display_name: linkName.trim(),
twilio_subaccount_sid: linkSid.trim(),
twilio_subaccount_token: linkToken.trim(),
},
});
setProvResult(r);
setLinkOpen(false);
setLinkPhone(""); setLinkName(""); setLinkSid(""); setLinkToken("");
await load();
} catch (e) {
setError(e && e.message || "Failed to link number.");
} finally {
setLinking(false);
}
}
const senders = (data && data.senders) || [];
const primary = senders.find(s => s.is_primary) || senders[0] || null;
const isOwner = store.activeRole === "owner" || store.activeRole === "admin_doctor";
return (
<>
{data && data.twilio_configured ? "Twilio configured" : "Twilio token missing"}
{primary ? primary.status : "No sender linked"}
{error && {error}
}
{saved && Saved.
}
{/* Auto-provision card */}
{isOwner && (
{provResult && (
Number provisioned!
{provResult.phone_number} — {provResult.display_name}
)}
{!provisionOpen ? (
{ setProvisionOpen(true); setProvClinicName(store.activeAccount?.name || ""); }}>
Provision new number
) : (
Clinic display name
setProvClinicName(e.target.value)} placeholder="Dr. Smith's Clinic"/>
{provisioning ? "Provisioning..." : "Provision number"}
setProvisionOpen(false)} disabled={provisioning}>Cancel
This creates a Twilio subaccount, buys a phone number, and configures the webhook automatically.
The number will be ready for WhatsApp within minutes.
)}
)}
{/* Link existing number card */}
{isOwner && (
{!linkOpen ? (
setLinkOpen(true)}>
Link existing number
) : (
Twilio subaccount credentials (optional — leave blank to use master account)
{linking ? "Linking..." : "Link number"}
setLinkOpen(false)} disabled={linking}>Cancel
If this number is on a Twilio subaccount, provide the credentials so messages route through that account.
Otherwise, messages will use the platform's master Twilio account.
)}
)}
{(data && data.webhook_url) || "—"}
{copied && Copied.
}
{loading && Loading...
}
{!loading && senders.length === 0 && No WhatsApp sender linked. Use auto-provision above or manually enter a Twilio number.
}
{!loading && senders.map(sender => (
{sender.phone_number}
{sender.display_name && {sender.display_name} }
{sender.provider} · {sender.inbound_count || 0} inbound · {sender.outbound_count || 0} outbound
{sender.twilio_subaccount_sid && " · subaccount"}
{sender.last_inbound_at && {sender.last_inbound_at.slice(0, 16)} }
{sender.status}
{isOwner && sender.status === "active" && (
deactivateSender(sender.id)}>
Deactivate
)}
))}
>
);
}
function SetTeam() {
const store = window.MSStore.useStore();
const [email, setEmail] = useStateX("");
const [role, setRole] = useStateX("doctor");
const [pending, setPending] = useStateX(false);
const [error, setError] = useStateX("");
const [success, setSuccess] = useStateX("");
const members = store.members || [];
const invitations = store.invitations || [];
async function invite() {
if (!email.trim() || pending) return;
setPending(true); setError(""); setSuccess("");
try {
const r = await store.inviteTeamMember(email.trim(), role);
setSuccess(r && r.status === "added" ? "Member added." : "Invitation sent.");
setEmail("");
} catch (e) {
setError(e && e.message || "Could not send invitation.");
} finally {
setPending(false);
}
}
const canInvite = store.isAdmin;
const initials = (n) => (n || "?").split(" ").map(s => s[0]).slice(0, 2).join("").toUpperCase();
return (
<>
{canInvite && (
{error && {error}
}
{success && {success}
}
)}
m.role === "doctor" || m.role === "admin_doctor" || m.role === "owner").length} providers · ${members.filter(m => m.role === "staff").length} staff`}>
{members.length === 0 && No active members yet.
}
{members.map(m => (
{initials(m.name)}
{m.name}
{m.role.replace("_", " ")} · {m.email}
Active
))}
{invitations.length > 0 && (
<>
Pending invitations
{invitations.map(inv => (
{inv.email}
{inv.role.replace("_", " ")}
Invited
))}
>
)}
>
);
}
function SetTemplates() {
const store = window.MSStore.useStore();
const list = (store.templates && store.templates.length) ? store.templates : TX_FALLBACK_TEMPLATES;
return (
{list.map(t => (
{t.name || t.label}
{t.description || t.desc || ""}
{t.specialty &&
{t.specialty} }
))}
);
}
function SetSecurity() {
const store = window.MSStore.useStore();
const t = window.MSStore.useT();
const profile = store.profile || {};
const [deleting, setDeleting] = useStateX(false);
const [error, setError] = useStateX("");
const [confirmOpen, setConfirmOpen] = useStateX(false);
const [confirmText, setConfirmText] = useStateX("");
async function deleteAccount() {
const phrase = t("set.sec.deleteConfirmPhrase");
if (confirmText !== phrase) {
setError(t("set.sec.deletePhraseError", { phrase }));
return;
}
setDeleting(true);
setError("");
try {
await store.deleteOwnAccount();
} catch (e) {
setError(e && e.message || t("set.sec.deleteError"));
setDeleting(false);
}
}
return (
<>
{profile.auth_provider === "google" ? t("set.sec.googleSso") : t("set.sec.pwd")}
{profile.email || "—"}
{(profile.created_at || "").slice(0, 10)}
{t("set.sec.active")}
{t("set.sec.enabled")}
{ setConfirmOpen(true); setConfirmText(""); setError(""); }} style={{color: "var(--accent-danger)"}}>
{deleting ? t("common.loading") : t("set.sec.deleteButton")}
{error && {error}
}
{confirmOpen && (
{ if (e.target === e.currentTarget && !deleting) setConfirmOpen(false); }}>
{t("set.sec.deleteAccount")}
{profile.email || ""}
setConfirmOpen(false)} aria-label={t("common.close")}>
{t("set.sec.deleteModalBody")}
{t("set.sec.deleteTypeLabel", { phrase: t("set.sec.deleteConfirmPhrase") })}
{ setConfirmText(e.target.value); if (error) setError(""); }} autoFocus/>
{error &&
{error}
}
setConfirmOpen(false)}>{t("common.cancel")}
{deleting ? t("common.loading") : t("set.sec.deleteButton")}
)}
>
);
}
Object.assign(window, { Transcribe, Dictionary, Settings });