// 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 ( <>
setQ(e.target.value)} placeholder="Rechercher par nom, prénom ou IPP" style={{ width: '100%', height: DT_VT.hMd || 40, borderRadius: DT_VT.rMd || 12, border: `1px solid ${DT_HAIR2}`, background: DT_SURF, padding: '0 14px', fontFamily: 'inherit', fontSize: 14, color: DT_INK, outline: 'none', }} />
{/* 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. */}
setTypeOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 4, }} />
{DICT_TYPES.map((t) => { const on = t.id === type; return ( ); })}
)}
{/* Micro bleu — halo léger, jamais clignotant */}
{!paused && ( <> )}
{/* Chrono */}
{mm}:{ss} {live && !ready ? 'Micro en attente' : (paused ? 'En pause' : 'Enregistrement')}
{/* Actions — hors de la carte, alignées sur les autres écrans */}
); } // ── Étape 4 : transcription ── function DictProcessing({ phase = null }) { return (
{phase === 'uploading' ? 'Envoi de l’enregistrement…' : 'Transcription en cours…'}
{phase === 'uploading' ? 'Le fichier part vers le serveur' : 'Le fournisseur traite l’audio'}
); } // ── Étape 5 : relecture avant envoi à l'assistant ── function DictReview({ patient, type, text, onChange, onSend, onRetake, onDiscard, onAssociate, busy = false }) { const typeLabel = (DICT_TYPES.find((t) => t.id === type) || {}).label || 'Note'; return ( <> {/* Statut d'association — explicite tant qu'aucun dossier n'est rattaché */}
{patient ? `${patient.last} ${patient.first}` : 'Aucun patient associé'}