// AI Chat — assistant médical intelligent avec widgets riches. // Style ChatGPT adapté au design Vasco. const CH_VT = window.VT || {}; const CH_INK = CH_VT.ink || '#0a0a0a'; const CH_SUB = CH_VT.sub || '#6b7280'; const CH_BG = CH_VT.paper || '#f6f5f1'; const CH_SURF = CH_VT.surface || '#ffffff'; const CH_HAIR = CH_VT.hair || 'rgba(10,10,10,0.06)'; const CH_HAIR2 = CH_VT.hair2 || 'rgba(10,10,10,0.10)'; const CH_OK = CH_VT.success || '#059669'; const CH_MED = CH_VT.medBlue || '#3d6b8f'; const CH_MED_TINT = CH_VT.medBlueTint || 'rgba(61,107,143,0.10)'; const CH_OK_TINT = CH_VT.successTint || 'rgba(5,150,105,0.10)'; const CH_OK_TINT_SOFT = CH_VT.successTintSoft || 'rgba(5,150,105,0.06)'; // Le serveur n'accepte que quatre types et vérifie en plus le contenu réel du // fichier : Word, Excel et les autres formats bureautiques sont refusés, y // compris renommés, un `.docx` étant une archive ZIP détectée comme telle. // La règle vit dans `api-client.js` ; on ne la duplique pas ici. const CH_ACCEPTED_LABEL = 'PDF, Word (.docx), JPEG, PNG ou TXT, 10 Mo maximum'; const CH_WAVE_BARS = 20; // Un RMS de parole ordinaire tourne autour de 0,02–0,15 : sans gain, les // barres resteraient collées au plancher. const CH_WAVE_GAIN = 12; // En dessous, on considère qu'aucune voix n'est captée. const CH_SILENCE_RMS = 0.004; // Libellés repris de `mobile/src/features/assistant/assistant-widgets.tsx` : // deux formulations pour un même refus serveur finiraient par diverger. const CH_WIDGET_REASONS = { role_not_allowed: 'Votre rôle ne permet pas cette action.', not_owner: 'Seul le propriétaire de la conversation peut agir.', patient_access_revoked: 'L’accès au patient a été révoqué.', resource_stale: 'Cette proposition doit être rechargée.', generation_in_progress: 'La réponse est encore en cours.', provider_unavailable: 'Le fournisseur requis est indisponible.', scanner_unavailable: 'Le service d’inspection est indisponible.', extractor_unavailable: 'L’extracteur de contenu n’est pas configuré.', mail_channel_not_configured: 'Aucun canal sécurisé (MSSanté ou Apicrypt) n’est configuré.', print_provider_unavailable: 'La préparation du document imprimable est indisponible.', ccam_source_unavailable: 'Le référentiel CCAM autorisé est indisponible.', source_unavailable: 'Une source nécessaire n’est plus accessible.', conversation_deleted: 'La conversation a été supprimée.', recipient_missing: 'Ajoutez un destinataire avant l’envoi.', already_confirmed: 'Ce rendez-vous est déjà confirmé.', }; const CH_ACTION_META = { open: { label: 'Ouvrir', icon: <> }, copy: { label: 'Copier', icon: <> }, edit: { label: 'Modifier', icon: <> }, send: { label: 'Envoyer', icon: <> }, print: { label: 'Imprimer', icon: <> }, confirm: { label: 'Confirmer', icon: <> }, discard: { label: 'Rejeter', icon: <> }, }; function chWidgetReason(capability) { if (!capability || capability.enabled) return null; return CH_WIDGET_REASONS[capability.reason_code] || 'Action indisponible.'; } // ── Widget action buttons ── // Un bouton refusé par le serveur affiche sa raison au survol et reste inerte : // inventer un succès sur une action clinique serait un mensonge d'interface. // `done` remplace l'icône par une coche, le temps de faire savoir que l'action a // abouti. C'est le bouton qui porte sa confirmation, jamais une notification. const CH_DONE_ICON = ; function WAction({ icon, onClick, label, reason = null, busy = false, done = false, doneLabel = null }) { const blocked = Boolean(reason) || busy; const shown = done ? (doneLabel || label) : label; return ( ); } // Même bouton, écrit en toutes lettres. Les commandes d'un codage se lisent au pied // de la carte, où une icône seule demanderait de deviner ce qu'on engage. function WTextAction({ label, onClick, reason = null, busy = false, done = false, doneLabel = null, style = null, }) { const blocked = Boolean(reason) || busy; const shown = done ? (doneLabel || label) : label; return ( ); } // Barre d'actions d'un widget, entièrement dérivée de `capabilities` renvoyé // par le serveur : l'ordre, la présence et l'état de chaque bouton viennent de // l'allowlist canonique, jamais d'une supposition du client. // `variant="text"` rend la même barre en libellés discrets, pour un pied de carte. function WidgetActions({ msg, runtime, onWidgetUpdated, onEdit, onNeedPatient, onOpenPatient, variant = 'icons', }) { const [busyAction, setBusyAction] = React.useState(null); const [doneAction, markDone] = useActionDone(); const capabilities = msg.widgetCapabilities || []; if (!runtime || !msg.widgetId || capabilities.length === 0) return null; const widgetRef = { id: msg.widgetId, version: msg.widgetVersion, type: msg.widget }; const run = async (action, options) => { if (busyAction) return; if (action === 'edit') { onEdit?.(); return; } let settings = options; // Un compte rendu ne s'écrit que dans le dossier d'un patient. Le fil décide : le // résolveur n'est fourni que s'il n'en désigne aucun. On demande alors avant // d'appeler le serveur — partir pour se faire refuser, puis rouvrir une feuille, // ferait deux allers pour un seul geste. if (action === 'save' && onNeedPatient) { const patientId = await onNeedPatient(widgetRef); if (!patientId) return; settings = { ...(options || {}), patientId }; } setBusyAction(action); try { const result = await runtime.widgetAction(widgetRef, action, settings); onWidgetUpdated?.(result.widget); // `copy` est la seule action dont l'effet utile est local : le reçu // serveur prouve la traçabilité, le presse-papiers rend le service. if (action === 'copy' && navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(chWidgetPlainText(msg)); } catch { /* Le refus du presse-papiers n'annule pas le reçu serveur. */ } } // `print` est de la même famille : le serveur enregistre la passe-main, et // c'est l'appareil qui imprime. Un refus du navigateur remonte en revanche // jusqu'au toast et laisse le bouton muet — le reçu resterait acquis, mais // rien n'aurait été transmis, et la coche le dirait à tort. if (action === 'print') await chPrintOrdonnance(msg); // « Ouvrir » d'une fiche patient ne se termine pas au reçu : ce que le geste // promet, c'est le dossier à l'écran. Le serveur a vérifié que ce patient // appartient bien à l'organisation et aux sources de la fiche — il renvoie son // id dans le reçu, et c'est celui-là qu'on ouvre, jamais un id relu de la carte. // Un refus (fiche sans patient, dossier d'une autre organisation, dossier archivé) // sort en 409 ou 404 avant ce point : il tombe dans le `catch`, dit sa raison au // toast et laisse la conversation à l'écran — la carte reste sous les yeux, alors // qu'un écran « Dossier introuvable » aurait fait quitter le fil pour l'apprendre. if (action === 'open' && onOpenPatient) { const patientId = msg.widget === 'patient' ? result.receipt?.resource_id : null; if (!patientId) { throw new Error('La commande d’ouverture ne désigne aucun patient autorisé'); } // Le dossier est chargé avant la navigation : si ce chargement échoue, // l'ouverture a dit pourquoi et le bouton ne se coche pas — le reçu, lui, // reste acquis au serveur, mais aucun écran ne s'est ouvert. if (await onOpenPatient(patientId) === false) return; } // Aucune notification de réussite : le bouton porte lui-même le résultat, // et les actions qui changent l'état du widget — confirmer, écarter — le // montrent déjà dans la carte. markDone(action); } catch (error) { showToast(error?.message || 'Action impossible', 'error'); } finally { setBusyAction(null); } }; if (variant === 'text') { const data = msg.data || {}; const capability = (action) => capabilities.find((entry) => entry.action === action) || null; const discarded = Boolean(data.discarded_at); const confirmed = Boolean(data.confirmed_at); // Le codage s'édite en cochant les actes dans la carte elle-même : un bouton // « modifier » ouvrirait un éditeur de texte sur une liste de codes. const command = (action, label, options) => { const entry = capability(action); if (!entry) return null; return ( void run(action, options)} /> ); }; // Le compte rendu n'a qu'une commande au pied : enregistrer. Une fois signé, le // bouton cède la place à la signature elle-même ; corriger le texte l'efface côté // serveur et le bouton revient, pour réécrire une version de plus au dossier. // Un relevé de constantes entre au dossier de la même façon, et se lit de la même // façon : même commande, même signature, même place. if (msg.widget === 'cr' || msg.widget === 'constantes') { // Tant que le serveur n'autorise pas « enregistrer » et que rien n'est signé, la // carte se termine où elle se terminait : un pied vide y ajouterait un blanc. if (!data.saved_at && !capability('save')) return null; return (
{data.saved_at ? ( Enregistré {chStamp(data.saved_at)} ) : command('save', 'Enregistrer')}
); } return (
{discarded ? ( <> Codage écarté {command('discard', 'Rétablir', { discarded: false })} ) : ( <> {/* Une signature déjà posée ne se repose pas : elle s'affiche. Toute modification l'efface côté serveur, et le bouton revient. */} {confirmed ? ( Confirmé {chStamp(data.confirmed_at)} ) : command('confirm', 'Confirmer')} {command('discard', 'Rejeter', { discarded: true })} )}
); } return (
{capabilities.map((capability) => { const meta = CH_ACTION_META[capability.action]; if (!meta) return null; // Le codage s'édite en cochant les actes dans la carte elle-même, et se // confirme ou s'écarte au pied de celle-ci : l'en-tête ne garde que // « copier », dont l'effet ne dépend pas de ce qu'on vient de décider. if (msg.widget === 'ccam' && capability.action !== 'copy') return null; // Enregistrer se lit au pied de la carte, sous le compte rendu — ou sous le // relevé — qu'il fait entrer au dossier : une icône de plus dans l'en-tête ne // dirait pas ce qu'elle engage. if ((msg.widget === 'cr' || msg.widget === 'constantes') && capability.action === 'save') return null; return ( void run(capability.action)} /> ); })}
); } // Ce que le bouton peut affirmer s'arrête à ce que l'application a constaté. La // feuille est remise au dialogue de l'appareil, qui ne dit jamais si elle est // sortie : « transmis à l'impression » est tout ce qui est vrai — le serveur, lui, // n'enregistre que la passe-main (`widget_print_authorized`). // Aucun libellé pour `send` : tant qu'aucun canal sécurisé n'est configuré, le serveur // refuse l'envoi, et un mot d'aboutissement gardé au chaud ici finirait par s'afficher. // Il reviendra avec l'adaptateur MSSanté ou Apicrypt, et dira ce que le reçu prouve. const CH_ACTION_RESULTS = { open: 'Ouverture enregistrée', copy: 'Copié', print: 'Transmis à l’impression', confirm: 'Rendez-vous confirmé', }; // Confirmer un codage n'est pas confirmer un rendez-vous, et écarter n'est pas // rétablir : un message unique par action mentirait une fois sur deux. function chActionResult(widget, action, options) { if (widget === 'ccam' && action === 'confirm') return 'Codage confirmé'; if (action === 'save') return 'Enregistré au dossier'; if (action === 'discard') { return options?.discarded === false ? 'Codage rétabli' : 'Codage écarté'; } return CH_ACTION_RESULTS[action] || 'Action enregistrée'; } // Horodatage d'une signature : jour et heure, sans les secondes que personne ne lit. function chStamp(value) { const at = new Date(value); if (Number.isNaN(at.getTime())) return ''; return `le ${at.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} à ${ at.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`; } // Le presse-papiers doit recevoir le contenu réellement affiché, pas un JSON. function chWidgetPlainText(msg) { const data = msg.data || {}; if (msg.widget === 'email') return `${data.subject || ''}\n\n${data.body || ''}`.trim(); if (msg.widget === 'courrier') return `${data.to || ''}\n\n${data.body || ''}`.trim(); if (msg.widget === 'cr') { return [data.title, ...(data.sections || []).map((s) => `${s.heading}\n${s.content}`)] .filter(Boolean).join('\n\n'); } if (msg.widget === 'constantes') { return (data.vitals || []).map((v) => `${v.label} ${v.value} ${v.unit || ''}`.trim()).join('\n'); } if (msg.widget === 'ccam') { const principal = data.principal ? `${data.principal.code} ${data.principal.label}` : ''; const extras = (data.complementaires || []).map((c) => `${c.code} ${c.label}`).join('\n'); return [principal, extras].filter(Boolean).join('\n'); } return msg.text || ''; } // ═══════════════════════════════════════════════════════════ // Impression de la fiche ordonnance // ═══════════════════════════════════════════════════════════ // L'application vit dans un cadre de téléphone : la feuille est composée dans un // document à part et remise au dialogue d'impression de l'appareil, par la même // mécanique que le dossier patient (`vascoPrintFrame`). // // Le titre et la mention sont ceux de // `mobile/src/features/assistant/assistant-conversation-screen.tsx`, qui imprime // déjà cette fiche : deux formulations pour un même papier finiraient par diverger, // et c'est sur du papier que la divergence se lirait. // // Ce qui part sur la feuille est exactement ce que la carte affiche : le patient // nommé par la fiche et ses lignes de traitement, rien d'autre. Aucun prescripteur // n'y est imprimé — la fiche n'en porte aucun, et un nom sous des médicaments se // lit comme une signature. Le document dit donc ce qu'il est : un brouillon non // signé. `verification_required` est vrai pour toute ordonnance servie par l'API, // et le papier, lui, circule sans écran autour pour le rappeler. const CH_ORDONNANCE_PRINT_STYLES = ` @page { size: A4; margin: 18mm 16mm; } body { margin: 0; font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 10.5pt; line-height: 1.45; color: #111; } .head { border-bottom: 1.5pt solid #111; padding-bottom: 8pt; margin-bottom: 16pt; } .kicker { font-size: 8pt; letter-spacing: 1.2pt; text-transform: uppercase; color: #555; } .name { font-size: 20pt; font-weight: 700; margin-top: 4pt; } .head-meta { display: flex; justify-content: space-between; gap: 12pt; font-size: 9pt; color: #555; margin-top: 6pt; } .med { padding: 8pt 0; border-bottom: 0.5pt solid #bbb; page-break-inside: avoid; } .med-name { font-size: 12pt; font-weight: 700; } .med-posology { margin-top: 2pt; } .med-duration { margin-top: 2pt; color: #555; } .draft { margin-top: 20pt; border: 1pt solid #111; padding: 10pt 12pt; font-size: 10pt; font-weight: 700; } `; function chOrdonnancePrintHtml(data, printedAt) { const esc = window.vascoPrintEscape || ((value) => String(value)); // Un champ vide ne s'imprime pas : sur du papier qui suivra le patient, une // ligne remplie pour faire joli se lit comme une posologie. const value = (raw) => { const text = typeof raw === 'string' ? raw.trim() : raw; return text && text !== '—' ? String(text) : null; }; const meds = (Array.isArray(data.meds) ? data.meds : []).map((med) => { const name = value(med.name); if (!name) return ''; const posology = value(med.posology); const duration = value(med.duration); return `
${esc(name)}
${ posology ? `
${esc(posology)}
` : ''}${ duration ? `
${esc(duration)}
` : ''}
`; }).join(''); const patient = value(data.patient) || ''; return ` Ordonnance — brouillon — ${esc(patient)}
Ordonnance — brouillon
${esc(patient)}
Imprimé le ${esc(printedAt)}
${meds}
Document à vérifier. Non validé et non signé.
`; } // Remettre la feuille au dialogue d'impression de l'appareil. Ce que l'appareil en // fait ensuite — une feuille, un PDF, une annulation — ne remonte pas, ni ici ni au // serveur : personne dans l'application ne peut donc dire qu'une ordonnance est // imprimée, seulement qu'elle a été transmise. function chPrintOrdonnance(msg) { // Seule l'ordonnance s'imprime. Si le serveur venait à offrir « imprimer » // ailleurs, mieux vaut un refus visible qu'une coche sur une feuille que // personne n'a su composer. if (msg.widget !== 'ordonnance') { return Promise.reject(new Error('Cette fiche ne peut pas être imprimée')); } const frame = window.vascoPrintFrame; if (!frame) { return Promise.reject( new Error('Le dialogue d’impression est indisponible. Le brouillon est conservé.'), ); } const printedAt = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long', timeStyle: 'short' }).format(new Date()); return frame(chOrdonnancePrintHtml(msg.data || {}, printedAt)); } // ═══════════════════════════════════════════════════════════ // Rendu Markdown minimal des réponses // ═══════════════════════════════════════════════════════════ // Le modèle répond en Markdown : sans interprétation, l'utilisateur lit des // astérisques au milieu d'un texte clinique. Les éléments sont construits en // React et jamais injectés en HTML : la réponse reste une donnée non fiable, // et `dangerouslySetInnerHTML` ouvrirait une injection depuis une pièce jointe // ou un message d'un tiers. function chInline(text, keyPrefix) { const nodes = []; // Gras, italique et code littéral, dans cet ordre : `**` doit être reconnu // avant `*`, sinon un gras se lit comme deux italiques. const pattern = /(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*\n]+\*|_[^_\n]+_)/g; let last = 0; let match = pattern.exec(text); let index = 0; while (match) { if (match.index > last) nodes.push(text.slice(last, match.index)); const token = match[0]; const key = `${keyPrefix}-i${index}`; index += 1; if (token.startsWith('**') || token.startsWith('__')) { nodes.push({token.slice(2, -2)}); } else if (token.startsWith('`')) { nodes.push( {token.slice(1, -1)}, ); } else { nodes.push({token.slice(1, -1)}); } last = match.index + token.length; match = pattern.exec(text); } if (last < text.length) nodes.push(text.slice(last)); return nodes.length ? nodes : [text]; } function ChatMarkdown({ text }) { if (!text) return null; const lines = String(text).replace(/\r/g, '').split('\n'); const blocks = []; let list = null; const flushList = () => { if (!list) return; const Tag = list.ordered ? 'ol' : 'ul'; blocks.push( {list.items.map((item, index) => (
  • {chInline(item, `l${blocks.length}-${index}`)}
  • ))}
    , ); list = null; }; lines.forEach((raw, lineIndex) => { const line = raw.trimEnd(); if (!line.trim()) { flushList(); return; } const heading = /^(#{1,4})\s+(.*)$/.exec(line); if (heading) { flushList(); blocks.push(
    {chInline(heading[2], `h${lineIndex}`)}
    , ); return; } const bullet = /^\s*[-*•]\s+(.*)$/.exec(line); const ordered = /^\s*(\d+)[.)]\s+(.*)$/.exec(line); if (bullet || ordered) { const isOrdered = Boolean(ordered); if (!list || list.ordered !== isOrdered) { flushList(); list = { ordered: isOrdered, items: [] }; } list.items.push(isOrdered ? ordered[2] : bullet[1]); return; } flushList(); blocks.push(
    {chInline(line, `p${lineIndex}`)}
    , ); }); flushList(); return
    {blocks}
    ; } // ═══════════════════════════════════════════════════════════ // Pièces jointes d'un message — vignette, ouverture, téléchargement // ═══════════════════════════════════════════════════════════ // Comme dans une messagerie ordinaire : l'image se voit, le PDF s'ouvre dans le // lecteur du navigateur, et le Word se télécharge — aucun navigateur ne sait // l'afficher. Une pièce jointe qu'on ne peut ni voir ni ouvrir n'est qu'un nom // de fichier, et l'expéditeur ne peut pas vérifier ce qu'il a envoyé. const CH_DOCX_MEDIA_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; const CH_KIND_LABELS = { 'application/pdf': 'PDF', [CH_DOCX_MEDIA_TYPE]: 'Document Word', 'text/plain': 'Texte', }; function chAttachmentLabel(attachment) { if (attachment.mediaType?.startsWith('image/')) return 'Image'; return CH_KIND_LABELS[attachment.mediaType] || 'Document'; } function ChatAttachment({ attachment, runtime, isUser, onPreview }) { const isImage = Boolean(attachment.mediaType?.startsWith('image/')); const [url, setUrl] = React.useState(attachment.previewUrl || null); const [failed, setFailed] = React.useState(false); const [busy, setBusy] = React.useState(false); const ownedRef = React.useRef(null); // Une URL d'objet non libérée retient le fichier entier en mémoire tant que // l'onglet vit. React.useEffect(() => () => { if (ownedRef.current) URL.revokeObjectURL(ownedRef.current); }, []); const ensureUrl = React.useCallback(async () => { if (url) return url; if (!runtime?.attachmentObjectUrl || !attachment.id) return null; const next = await runtime.attachmentObjectUrl(attachment); ownedRef.current = next; setUrl(next); return next; }, [url, runtime, attachment]); // La vignette se charge d'elle-même ; le reste attend un geste explicite. React.useEffect(() => { if (!isImage || url || failed || !runtime?.attachmentObjectUrl) return undefined; let cancelled = false; (async () => { try { const next = await ensureUrl(); if (cancelled && next) URL.revokeObjectURL(next); } catch { if (!cancelled) setFailed(true); } })(); return () => { cancelled = true; }; }, [isImage, url, failed, runtime, ensureUrl]); const open = async () => { if (busy) return; setBusy(true); try { const target = await ensureUrl(); if (!target) return; if (isImage) { onPreview?.({ url: target, filename: attachment.filename }); return; } if (attachment.mediaType === 'application/pdf') { // Le navigateur sait afficher un PDF ; il ne sait pas afficher un Word. window.open(target, '_blank', 'noopener'); return; } const link = document.createElement('a'); link.href = target; link.download = attachment.filename || 'document'; document.body.appendChild(link); link.click(); link.remove(); } catch (error) { showToast(error?.message || 'Pièce jointe indisponible', 'error'); } finally { setBusy(false); } }; const meta = [ chAttachmentLabel(attachment), attachment.sizeBytes ? readableFileSize(attachment.sizeBytes) : null, ].filter(Boolean).join(' · '); if (isImage && url) { return ( ); } return ( ); } // Aperçu plein cadre d'une image, refermable au clic ou à l'Échap. function ChatImagePreview({ preview, onClose }) { React.useEffect(() => { const onKey = (event) => { if (event.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); return (
    {preview.filename}
    {preview.filename}
    ); } // ═══════════════════════════════════════════════════════════ // Feuille générique — saisie, choix ou confirmation // ═══════════════════════════════════════════════════════════ // Les boîtes natives `prompt`/`confirm` bloquent le fil du navigateur, sortent // du cadre iOS de l'application et ne suivent aucun de ses styles. Tout passe // donc par cette feuille, comme partout ailleurs dans Vasco. function ChPromptSheet({ title, message, mode = 'text', value = '', options = [], confirmLabel = 'Valider', danger = false, busy = false, onCancel, onSubmit }) { const [draft, setDraft] = React.useState(value); const inputRef = React.useRef(null); React.useEffect(() => { if (mode === 'text') setTimeout(() => inputRef.current?.focus(), 60); }, [mode]); const canSubmit = mode !== 'text' || draft.trim().length > 0; return ( <>
    {title}
    {message && (
    {message}
    )} {mode === 'text' && ( setDraft(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter' && canSubmit) onSubmit(draft.trim()); }} maxLength={120} style={{ marginTop: 14, width: '100%', height: 44, padding: '0 12px', borderRadius: VT.rMd || 12, border: `1px solid ${CH_HAIR2}`, background: CH_SURF, outline: 'none', fontFamily: 'inherit', fontSize: 14, fontWeight: 500, color: CH_INK, }} /> )} {mode === 'choice' && (
    {options.map((option) => ( ))}
    )}
    {mode !== 'choice' && ( )}
    ); } // ═══════════════════════════════════════════════════════════ // Édition d'un widget — POST /assistant/widgets/{id}/actions (action `edit`) // ═══════════════════════════════════════════════════════════ // Le serveur exige un remplacement complet et typé, pas un patch : chaque // champ ci-dessous correspond exactement au schéma `WidgetEditReplacement`. const CH_EDIT_TITLES = { email: 'Modifier l’e-mail', courrier: 'Modifier le courrier', cr: 'Modifier le compte rendu', ordonnance: 'Modifier l’ordonnance', }; const chEditInput = { width: '100%', padding: '10px 12px', borderRadius: VT.rMd || 12, border: `1px solid ${CH_HAIR2}`, background: CH_SURF, outline: 'none', fontFamily: 'inherit', fontSize: 13.5, fontWeight: 500, color: CH_INK, }; function ChEditField({ label, children }) { return ( ); } function WidgetEditSheet({ msg, busy, onClose, onSave }) { const data = msg.data || {}; const [to, setTo] = React.useState(data.to || ''); const [subject, setSubject] = React.useState(data.subject || ''); const [body, setBody] = React.useState(data.body || ''); const [type, setType] = React.useState(data.type || ''); const [documentType, setDocumentType] = React.useState(data.document_type || 'CRO'); const [title, setTitle] = React.useState(data.title || ''); const [sections, setSections] = React.useState(() => (data.sections || []).map((s) => ({ ...s }))); const [meds, setMeds] = React.useState(() => (data.meds || []).map((m) => ({ ...m }))); const kind = msg.widget; const valid = (() => { if (kind === 'email') return subject.trim() && body.trim(); if (kind === 'courrier') return to.trim() && body.trim(); if (kind === 'cr') { return type.trim() && title.trim() && sections.length > 0 && sections.every((s) => s.heading.trim() && s.content.trim()); } if (kind === 'ordonnance') { return meds.length > 0 && meds.every((m) => m.name.trim() && m.posology.trim() && m.duration.trim()); } return false; })(); const submit = () => { if (!valid || busy) return; if (kind === 'email') { onSave({ to: to.trim() || null, subject: subject.trim(), body }); return; } if (kind === 'courrier') { onSave({ to: to.trim(), body }); return; } if (kind === 'cr') { onSave({ type: type.trim(), document_type: documentType, title: title.trim(), sections, }); return; } onSave({ meds }); }; return ( <>
    {CH_EDIT_TITLES[kind] || 'Modifier'}
    Le brouillon est enregistré sur le serveur. Il reste à vérifier avant tout usage.
    {kind === 'email' && <> setTo(e.target.value)} maxLength={254} style={chEditInput} /> setSubject(e.target.value)} maxLength={200} style={chEditInput} />