// Dictée — machine d'état complète, entièrement mockée.
// idle → patient → type → recording (pause/reprise) → processing → review → envoi à l'agent
//
// Le compte rendu n'est pas « validé » : il part dans le chat comme un message
// adressé à l'assistant, qui l'analysera et proposera les actions à faire.
//
// Zones sûres : le châssis iOS dessine la barre d'état (zIndex 10) et la Dynamic
// Island (zIndex 50) PAR-DESSUS le contenu. Tout écran doit donc dégager 62px en
// haut et 34px en bas (home indicator) — même convention que les autres écrans.
const DT_SAFE_TOP = 62;
const DT_SAFE_BOTTOM = 34;
const DT_VT = window.VT || {};
const DT_INK = DT_VT.ink || '#0a0a0a';
const DT_SUB = DT_VT.sub || '#6b7280';
const DT_BG = DT_VT.paper || '#f6f5f1';
const DT_SURF = DT_VT.surface || '#ffffff';
const DT_HAIR = DT_VT.hair || 'rgba(10,10,10,0.06)';
const DT_HAIR2 = DT_VT.hair2 || 'rgba(10,10,10,0.10)';
const DT_MED = DT_VT.medBlue || '#3d6b8f';
const DT_MED_TINT = DT_VT.medBlueTint || 'rgba(61,107,143,0.10)';
const DT_DANGER = DT_VT.danger || '#b91c1c';
// Coupe une ligne trop longue au lieu de pousser les éléments voisins hors écran.
const DT_TRUNCATE = { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' };
// Teinte de l'état enregistrement — bleu clinique, accordé au micro.
// Elle DOIT être composée par-dessus le fond opaque : une simple rgba() à 4% en
// `background` laisse voir l'écran du dessous au lieu de le couvrir, et
// l'overlay paraît alors posé sur la page.
const DT_REC_BG = `linear-gradient(rgba(61,107,143,0.045), rgba(61,107,143,0.045)), ${DT_BG}`;
const DICT_TYPES = [
{ id: 'CRO', label: 'CRO', sub: 'Compte rendu opératoire' },
{ id: 'CRC', label: 'CRC', sub: 'Compte rendu de consultation' },
{ id: 'CRH', label: 'CRH', sub: "Compte rendu d'hospitalisation" },
{ id: 'NOTE', label: 'Note', sub: 'Note libre' },
{ id: 'LETTRE', label: 'Lettre', sub: 'Lettre au médecin traitant' },
];
// La phrase de rappel s'accorde avec le type : « ce compte rendu … envoyé », mais
// « cette lettre … envoyée ». On stocke donc la proposition entière, pas le seul nom.
const DICT_TYPE_SENTENCE = {
CRO: 'Ce compte rendu opératoire sera envoyé',
CRC: 'Ce compte rendu de consultation sera envoyé',
CRH: "Ce compte rendu d'hospitalisation sera envoyé",
NOTE: 'Cette note libre sera envoyée',
LETTRE: 'Cette lettre au médecin traitant sera envoyée',
};
// ═══════════════════════════════════════════════════════════
// Boîte de réception de l'agent
// ═══════════════════════════════════════════════════════════
// L'écran de chat est démonté quand on ne le regarde pas : un envoi depuis le
// dashboard n'a donc aucun destinataire au moment où il part. On met en file
// d'attente, et le chat vide la file à son montage.
const AgentInbox = {
queue: [],
listeners: new Set(),
subscribe(fn) {
this.listeners.add(fn);
if (this.queue.length) {
const pending = this.queue;
this.queue = [];
pending.forEach((m) => fn(m));
}
return () => this.listeners.delete(fn);
},
send(payload) {
if (this.listeners.size === 0) {
this.queue.push(payload);
return;
}
this.listeners.forEach((fn) => fn(payload));
},
};
// Les transcriptions pré-écrites ont été retirées : elles remplissaient
// l'écran de relecture d'un compte rendu clinique complet que personne
// n'avait dicté, et qui partait ensuite dans l'assistant. La transcription
// vient du fournisseur, via le serveur, ou la dictée échoue.
// ── En-tête commun ──
function DictHeader({ title, sub, onClose, onBack }) {
return (
{onBack && (
)}
{title}
{sub && (
{sub}
)}
{onClose && (
)}
);
}
// ── Waveform discrète ──
// Amplitude contenue, cadence lente, bleu clinique : elle signale l'activité
// sans capter l'attention. Une waveform agitée fait « composant technique ».
function DictWaveform({ active }) {
const BARS = 24;
const [bars, setBars] = React.useState(() => new Array(BARS).fill(4));
React.useEffect(() => {
if (!active) {
setBars(new Array(BARS).fill(4));
return;
}
let t = 0;
const id = setInterval(() => {
t += 1;
setBars(new Array(BARS).fill(0).map((_, i) => {
const wave = Math.sin(t / 4 + i * 0.5) * 8;
return Math.max(4, Math.min(26, 13 + wave + (Math.random() * 7 - 3.5)));
}));
}, 140);
return () => clearInterval(id);
}, [active]);
return (
{bars.map((h, i) => (
))}
);
}
// ── Pastille d'état (type de CR, patient associé ou non) ──
function DictChip({ children, tone = 'neutral' }) {
const tones = {
accent: { bg: DT_MED_TINT, fg: DT_MED },
neutral: { bg: 'rgba(10,10,10,0.05)', fg: DT_SUB },
};
const c = tones[tone] || tones.neutral;
return (
{children}
);
}
// ── Étape 1 : choix du patient ──
function DictPatientPicker({ onPick, onClose, title, sub, runtime = null }) {
const [q, setQ] = React.useState('');
// `PATIENTS_FULL` contient encore les patients de la maquette tant que
// l'onglet Patients n'a pas été ouvert : choisir l'un d'eux aurait envoyé au
// serveur une clé qui n'existe pas. La dictée interroge donc sa propre
// source, celle que l'API expose pour ce choix.
const [roster, setRoster] = React.useState(null);
React.useEffect(() => {
if (!runtime?.patients) return undefined;
let cancelled = false;
runtime.patients()
.then((items) => {
if (cancelled) return;
setRoster(items.map((item) => ({
id: item.id,
last: item.family_name.toLocaleUpperCase('fr-FR'),
first: item.given_name,
ipp: item.medical_record_number,
age: item.age,
})));
})
.catch((failure) => {
if (!cancelled) { showToast(failure?.message || 'Patients indisponibles', 'error'); setRoster([]); }
});
return () => { cancelled = true; };
}, [runtime]);
const source = roster || (runtime ? [] : (typeof PATIENTS_FULL !== 'undefined' ? PATIENTS_FULL : []));
const list = source.filter((p) => {
const s = q.trim().toLowerCase();
if (!s) return true;
return `${p.last} ${p.first} ${p.ipp}`.toLowerCase().includes(s);
});
return (
<>
{/* minHeight: 0 est indispensable : sans lui, un enfant flex refuse de
rétrécir sous sa hauteur de contenu et déborde au lieu de défiler. */}
{list.length === 0 && (
Aucun patient ne correspond à « {q} »
)}
{list.map((p) => (
))}
>
);
}
// ── Étape 2 : type de dictée ──
function DictTypePicker({ patient, onPick, onBack, onClose }) {
return (
<>
{DICT_TYPES.map((t) => (
))}
>
);
}
// ── Étape 3 : enregistrement ──
// Une carte dédiée, centrée, qui installe un vrai « mode dictée » : contexte en
// haut (type + patient), micro bleu au centre, chrono, waveform discrète.
// Les actions sont sorties de la carte pour garder son intérieur calme.
function DictRecorder({ patient, type, onStop, onCancel, onChangeType, live = false }) {
const [elapsed, setElapsed] = React.useState(0);
const [paused, setPaused] = React.useState(false);
// Micro effectivement ouvert. Tant qu'il ne l'est pas, l'écran ne prétend pas
// enregistrer et « Terminer » dit pourquoi il n'a rien à rendre.
const [ready, setReady] = React.useState(false);
// Le chrono tournait sans que rien ne soit capté : « Arrêter » rendait la
// main sans audio. Le micro est ouvert pour de bon quand l'écran est relié
// au serveur.
const captureRef = React.useRef(null);
React.useEffect(() => {
if (!live) return undefined;
let cancelled = false;
const open = async () => {
try {
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('Ce navigateur ne permet pas l’enregistrement.');
}
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) throw new Error('L’enregistrement n’est pas disponible.');
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (cancelled) { stream.getTracks().forEach((track) => track.stop()); return; }
const context = new AudioContextClass();
const source = context.createMediaStreamSource(stream);
const processor = context.createScriptProcessor(4096, 1, 1);
const silentOutput = context.createGain();
const chunks = [];
silentOutput.gain.value = 0;
processor.onaudioprocess = (event) => {
if (captureRef.current?.paused) return;
chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
};
source.connect(processor);
processor.connect(silentOutput);
silentOutput.connect(context.destination);
captureRef.current = {
context, source, processor, silentOutput, stream, chunks,
sampleRate: context.sampleRate, startedAt: performance.now(), paused: false,
};
setReady(true);
} catch (error) {
showToast(error?.message || 'Le micro n’a pas pu démarrer', 'error');
onCancel();
}
};
void open();
return () => {
cancelled = true;
const capture = captureRef.current;
captureRef.current = null;
if (!capture) return;
capture.processor.onaudioprocess = null;
capture.source.disconnect();
capture.processor.disconnect();
capture.silentOutput.disconnect();
capture.stream.getTracks().forEach((track) => track.stop());
capture.context.close().catch(() => {});
};
}, [live, onCancel]);
React.useEffect(() => {
if (captureRef.current) captureRef.current.paused = paused;
}, [paused]);
const finish = () => {
const capture = captureRef.current;
// Terminer sans micro ouvert fermait la dictée sans un mot : ni son, ni
// transcription, ni explication. L'autorisation peut n'avoir jamais été
// donnée — le navigateur attend une réponse que personne n'a cliquée.
if (live && !capture) {
showToast('Le micro n’a pas démarré : rien n’a été enregistré', 'error');
onCancel();
return;
}
if (!live || !capture) { onStop(elapsed, null); return; }
captureRef.current = null;
capture.processor.onaudioprocess = null;
capture.source.disconnect();
capture.processor.disconnect();
capture.silentOutput.disconnect();
capture.stream.getTracks().forEach((track) => track.stop());
capture.context.close().catch(() => {});
const durationMs = Math.max(1, performance.now() - capture.startedAt);
const audio = window.encodePcmWav(capture.chunks, capture.sampleRate);
if (audio.size <= 44) {
showToast('Aucun son n’a été enregistré', 'error');
onCancel();
return;
}
onStop(elapsed, { blob: audio, durationMs });
};
const [typeOpen, setTypeOpen] = React.useState(false);
// Le menu s'ouvre vers le haut (l'espace y est vide, et vers le bas il
// masquerait le micro). Sur un écran court il n'y a pas la place : on mesure
// après ouverture et on rebascule vers le bas plutôt que de le laisser rogné.
const [menuUp, setMenuUp] = React.useState(true);
const menuRef = React.useRef(null);
React.useLayoutEffect(() => {
if (!typeOpen) { setMenuUp(true); return; }
const el = menuRef.current;
if (!el) return;
const flow = el.closest('[data-dict-flow]');
const limit = flow ? flow.getBoundingClientRect().top : 0;
if (el.getBoundingClientRect().top < limit + 4) setMenuUp(false);
}, [typeOpen]);
// Le chrono ne part qu'une fois le micro réellement ouvert. Tant que
// l'autorisation n'est pas donnée, il n'y a rien à compter : afficher
// « Enregistrement 00:06 » pendant que le navigateur attend une réponse
// annonce une captation qui n'a pas lieu.
React.useEffect(() => {
if (paused || (live && !ready)) return undefined;
const id = setInterval(() => setElapsed((e) => e + 1), 1000);
return () => clearInterval(id);
}, [paused, live, ready]);
const mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
const ss = String(elapsed % 60).padStart(2, '0');
const typeLabel = (DICT_TYPES.find((t) => t.id === type) || {}).label || 'Note';
const ghostBtn = {
flex: 1, minWidth: 0, height: 46, borderRadius: DT_VT.rMd || 12,
background: 'none', border: `1px solid ${DT_HAIR2}`,
fontFamily: 'inherit', fontSize: 13, fontWeight: 600, color: DT_INK,
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
};
return (
<>
{/* Contexte : quel type, quel patient. Le type reste modifiable sans
interrompre l'enregistrement — le chrono continue de tourner. */}
{patient ? `${patient.last} ${patient.first}` : 'Aucun patient associé'}
{typeOpen && (
<>
{/* Ferme au clic à côté, sans masquer la carte. */}
{/* Dit explicitement où part le texte : ce n'est pas un simple archivage. */}
{patient
? `${DICT_TYPE_SENTENCE[type] || 'Ce compte rendu sera envoyé'} à l'assistant, qui l'analysera et proposera les actions à faire.`
: "Sans patient associé, l'assistant ouvrira une nouvelle conversation. Vous pourrez y rattacher un dossier existant, en créer un, ou traiter plus tard."}
{/* Deux rangées : le libellé principal est long, une seule rangée déborderait. */}
>
);
}
// ── Le flux complet ──
function DictationFlow({ onClose, onSent, mode = 'full', runtime = null }) {
const quick = mode === 'quick';
// Dictée rapide : on entre directement en enregistrement, sans patient ni
// choix de type. Le type sera « Note » par défaut, modifiable après coup.
const [step, setStep] = React.useState(quick ? 'recording' : 'patient');
const [patient, setPatient] = React.useState(null);
const [type, setType] = React.useState(quick ? 'NOTE' : null);
const [duration, setDuration] = React.useState(0);
const [text, setText] = React.useState('');
// La dictée du serveur, une fois transcrite : c'est elle qui porte la version
// attendue par les corrections et par la remise à l'assistant.
const [dictation, setDictation] = React.useState(null);
const [phase, setPhase] = React.useState(null);
const [busy, setBusy] = React.useState(false);
// La maquette attendait deux secondes puis affichait un texte pris dans
// `MOCK_TRANSCRIPTS`. Le praticien relisait un compte rendu que personne
// n'avait dicté, et pouvait l'envoyer à l'assistant. Sans serveur, la
// dictée s'arrête maintenant au lieu d'en inventer un.
const stop = async (elapsed, capture) => {
setDuration(elapsed);
if (!runtime) {
showToast('La dictée nécessite une connexion au serveur', 'error');
onClose();
return;
}
if (!capture) { onClose(); return; }
setStep('processing');
setPhase('uploading');
try {
const result = await runtime.start({
patientId: patient?.id || null,
documentType: type || 'NOTE',
blob: capture.blob,
durationMs: capture.durationMs,
}, (progress) => setPhase(progress.stage));
setDictation(result);
setText(result.transcript?.text || '');
setStep('review');
} catch (failure) {
showToast(failure?.message || 'La transcription a échoué', 'error');
onClose();
} finally {
setPhase(null);
}
};
// Envoyer, c'est remettre la dictée à l'assistant côté serveur : une
// conversation réelle est créée, avec le texte relu.
const sendToAgent = async () => {
if (!runtime || !dictation) { onClose(); return; }
setBusy(true);
try {
let current = dictation;
const edited = text.trim();
if (edited && edited !== (current.transcript?.text || '').trim()) {
current = await runtime.editTranscript(current, edited);
}
const handoff = await runtime.handoff(current);
// L'écran bascule sur la conversation qui porte la dictée : la
// destination vaut mieux qu'une annonce sur l'écran qu'on quitte.
onClose();
onSent && onSent(handoff.conversation);
} catch (failure) {
showToast(failure?.message || 'Envoi impossible', 'error');
} finally {
setBusy(false);
}
};
const discard = () => {
if (runtime && dictation) runtime.cancel(dictation).catch(() => {});
onClose();
};
return (
{/* key={step} remonte le sous-arbre à chaque étape, ce qui rejoue
l'animation d'entrée : la nouvelle vue prend la place de l'ancienne
au lieu d'apparaître d'un coup. */}