// Coordonnées réelles du PatientDetail, ajoutées sans modifier la V5 figée. const PatientContactDetailsBaseResume = TabResume; const PatientSectionEditBaseHeader = PatientHeader; const PatientSectionEditBaseClinicalSummary = clinicalSummaryBlock; const PatientSectionEditBaseActionSheet = ActionSheet; const PatientSectionEditController = (() => { let patientId = null; let openActions = null; let pending = null; return Object.freeze({ bind(nextPatientId, nextOpenActions) { patientId = nextPatientId; openActions = nextOpenActions; }, request(nextPatientId, scope) { if (nextPatientId !== patientId || typeof openActions !== 'function') return false; pending = { patientId: nextPatientId, scope }; openActions(); return true; }, peek(nextPatientId) { return pending?.patientId === nextPatientId ? pending.scope : null; }, consume(nextPatientId, fallback) { const scope = pending?.patientId === nextPatientId ? pending.scope : fallback; if (pending?.patientId === nextPatientId) pending = null; return scope; }, }); })(); window.VascoPatientSectionEdit = PatientSectionEditController; function exposePatientSectionEditError(error) { const message = error?.message || String(error || 'Erreur inconnue'); console.error('[vasco-patient-section-edit-global]', { message, stack: error?.stack || null, }); window.setTimeout(() => { if (document.getElementById('vasco-patient-section-debug')) return; const panel = document.createElement('pre'); panel.id = 'vasco-patient-section-debug'; panel.setAttribute('role', 'alert'); panel.textContent = `Erreur dossier patient\n${message}`; Object.assign(panel.style, { position: 'fixed', inset: '24px', zIndex: '99999', padding: '16px', background: '#fff', color: '#991b1b', border: '1px solid #991b1b', whiteSpace: 'pre-wrap', fontSize: '12px', overflow: 'auto', }); document.body.appendChild(panel); }, 0); } window.addEventListener('error', (event) => exposePatientSectionEditError(event.error || event.message)); window.addEventListener('unhandledrejection', (event) => exposePatientSectionEditError(event.reason)); function PatientSectionEditIconButton({ label, onClick }) { if (!onClick) return null; const { IconButton } = window.VascoUI; return ( ); } // Où le patient est hospitalisé, d'un bloc // // L'en-tête du dossier ne portait que le lit, sous une pastille « LIT … » dont // le mot est écrit en dur dans la référence gelée. Un lit ne se retrouve pas // sans l'étage et la chambre : c'est dans cet ordre qu'on les cherche en // arrivant dans le service, du plus grand au plus petit. // // La pastille gelée est reprise telle quelle, même bordure, même graisse, même // famille mono : seul son texte change. La refabriquer aurait recopié un style // que le design system porte déjà. Sans aucune des trois parties, la pastille // sort de la ligne : elle affichait « LIT – » à tout patient ambulatoire. function patientStayChipRow(row, patient) { if (!React.isValidElement(row)) return row; const children = React.Children.toArray(row.props.children); const index = children.findIndex((child) => React.isValidElement(child) && child.type === 'span' && String(React.Children.toArray(child.props.children)[0] ?? '').startsWith('LIT')); if (index < 0) return row; const label = window.VascoPatientContactDetails.stayLabel(patient); const next = label ? children.map((child, position) => (position === index ? React.cloneElement(child, { key: 'patient-stay' }, label) : child)) : children.filter((child, position) => position !== index); return React.cloneElement(row, row.props, next); } // La même place, dite au même mot dans la liste dépliée. // // La fiche dépliée de la V5 gelée porte une ligne « Lit » remplie du seul // `patient.bed`. Un lit ne se retrouve pas sans son étage ni sa chambre : la // ligne porte désormais le séjour entier et s'appelle donc « Séjour ». Le // libellé vient de la même fonction que la pastille de l'en-tête ; rien n'est // recomposé ici. Sans aucune des trois parties, la ligne sort de la carte : // elle disait « Lit – », c'est-à-dire une place vide, à tout dossier // ambulatoire, le tiret du client n'étant pas celui que la référence filtre. function patientStayFocusLabel(row) { if (!React.isValidElement(row)) return null; const [caption] = React.Children.toArray(row.props.children); if (!React.isValidElement(caption) || caption.props.className !== 'mono') return null; const [text] = React.Children.toArray(caption.props.children); return typeof text === 'string' ? text : null; } function patientStayFocusRows(node, patient) { if (!React.isValidElement(node) || typeof node.type !== 'string') return node; const children = React.Children.toArray(node.props.children); if (!children.length) return node; const index = children.findIndex((child) => patientStayFocusLabel(child) === 'Lit'); if (index < 0) { return React.cloneElement(node, node.props, children.map((child) => patientStayFocusRows(child, patient))); } const label = window.VascoPatientContactDetails.stayLabel(patient); if (!label) { const kept = children.filter((child, position) => position !== index); // La carte gelée n'existe que pour ses lignes. Quand la place était la // dernière, la retirer laissait un cadre blanc vide sous le nom : on retire // alors le cadre avec elle. return kept.length ? React.cloneElement(node, node.props, kept) : null; } return React.cloneElement(node, node.props, children.map((child, position) => { if (position !== index) return child; const [caption, value] = React.Children.toArray(child.props.children); return React.cloneElement(child, { key: 'patient-stay-row' }, [ React.cloneElement(caption, { key: 'caption' }, 'Séjour'), React.cloneElement(value, { key: 'value' }, label), ]); })); } const PatientStayBaseFocusDetails = FocusDetails; function PatientStayFocusDetails(props) { const rendered = PatientStayBaseFocusDetails(props); return patientStayFocusRows(rendered, props.patient); } FocusDetails = PatientStayFocusDetails; function PatientSectionalHeader(props) { PatientSectionEditController.bind(props.p.id, props.onActions); const rendered = PatientSectionEditBaseHeader(props); const blocks = React.Children.toArray(rendered.props.children); const canArchive = props.p.capabilities?.archive === true; const topBar = blocks[0]; if (!canArchive && React.isValidElement(topBar)) { const controls = React.Children.toArray(topBar.props.children) .filter((control) => control?.props?.['aria-label'] !== 'Actions'); blocks[0] = React.cloneElement(topBar, topBar.props, controls); } const identity = blocks[1]; if (!React.isValidElement(identity)) return rendered; const identityChildren = React.Children.toArray(identity.props.children); identityChildren[0] = patientStayChipRow(identityChildren[0], props.p); blocks[1] = React.cloneElement(identity, { ...identity.props, style: { ...identity.props.style, position: 'relative', paddingRight: 64 }, }, [ ...identityChildren, PatientSectionEditController.request(props.p.id, 'identity')} /> , ]); return React.cloneElement(rendered, rendered.props, blocks); } function PatientSectionalClinicalSummary(patient) { const rendered = PatientSectionEditBaseClinicalSummary(patient); const content = React.Children.toArray(rendered.props.children); content[0] = (
{content[0]} PatientSectionEditController.request(patient.id, 'clinical')} />
); return React.cloneElement(rendered, rendered.props, content); } function PatientSectionalActionSheet(props) { const pendingScope = PatientSectionEditController.peek(props.patient.id); React.useEffect(() => { if (!pendingScope || !props.onEdit) return; props.onClose?.(); props.onEdit(props.patient); }, [pendingScope, props.onClose, props.onEdit, props.patient]); if (pendingScope) return null; return ; } function PatientDeferredTreatmentStopBlock(props) { const [Component, setComponent] = React.useState(() => window.TreatmentStopBlock || null); React.useEffect(() => { if (Component) return undefined; let attempts = 0; const timer = window.setInterval(() => { attempts += 1; if (window.TreatmentStopBlock) { window.clearInterval(timer); setComponent(() => window.TreatmentStopBlock); } else if (attempts >= 100) { window.clearInterval(timer); console.warn('[vasco-patient-treatment-load]', { attempts }); } }, 50); return () => window.clearInterval(timer); }, [Component]); if (!Component) { return
; } return ; } // Même ligne que dans la V6 : le geste porte sur toute la coordonnée, pas sur // un gros bouton ajouté à droite. L'adresse peut revenir à la ligne ; téléphone // et courriel gardent une ligne et une ellipse afin de ne jamais élargir la carte. function PatientContactRow({ label, value, has, enabled = true, onClick, actionLabel, title, wrap, last, icon, feedback, failed }) { const interactive = has && enabled; const { InteractiveRow } = window.VascoUI; return ( {label} {value} {feedback} {has && } ); } function PatientContactDetailsBlock({ patient, runtime }) { const [doneAction, markDone] = useActionDone(); const [failedAction, setFailedAction] = React.useState(null); const [calling, setCalling] = React.useState(false); const [callingTrusted, setCallingTrusted] = React.useState(false); const behavior = window.VascoPatientContactDetails; const capabilities = patient.capabilities || {}; const address = behavior.displayValue(patient.address); const phone = behavior.displayValue(patient.phone); const email = behavior.displayValue(patient.email); const hasPhone = behavior.canCopy(patient.phone); // Personne de confiance (article L1111-6 du code de la santé publique). La // ligne dit « Non renseigné » plutôt que de disparaître, pour que le // praticien sache que la place existe et qu'elle est vide. Dès qu'un numéro // est au dossier, elle s'appelle comme la ligne du patient : c'est la // personne qu'on joint quand le patient ne répond pas. const trusted = behavior.trustedPerson(patient); const trustedCallEnabled = behavior.canCopy(trusted?.phone) && capabilities.call_trusted_person === true; // Nom et lien sur une ligne, numéro en dessous : les trois bout à bout // coupaient le numéro en deux à la largeur d'un téléphone. const trustedValue = trusted ? ( <> {trusted.identity} {trusted.phone && {trusted.phone}} ) : behavior.displayValue(null); const callEnabled = behavior.canCall(patient.phone, capabilities); const callRefusal = patient.status === 'decede' ? 'Patient décédé' : 'Appel indisponible pour ce dossier'; const copy = async (kind, value) => { markDone(null); setFailedAction(null); const result = await behavior.copyContact(value, copyToClipboard); if (result.ok) { markDone(kind); return; } setFailedAction(kind); showToast('Copie impossible – presse-papier indisponible ou refusé', 'error'); }; const callTrusted = async () => { if (!trustedCallEnabled || callingTrusted) return; setCallingTrusted(true); const result = await behavior.openCallSheet({ patientId: patient.id, name: trusted.identity, phone: trusted.phone, action: 'call_trusted_person', authorize: runtime?.authorizeAction ? (patientId, action) => runtime.authorizeAction(patientId, action) : null, open: requestCall, }); setCallingTrusted(false); if (!result.ok) { showToast(result.error?.message || 'Appel impossible', 'error'); } }; const call = async () => { if (!callEnabled || calling) return; setCalling(true); const result = await behavior.openCallSheet({ patientId: patient.id, name: `${patient.last} ${patient.first}`, phone: patient.phone, authorize: runtime?.authorizeAction ? (patientId, action) => runtime.authorizeAction(patientId, action) : null, open: requestCall, }); setCalling(false); if (!result.ok) { showToast(result.error?.message || 'Appel impossible', 'error'); } }; return (
Coordonnées PatientSectionEditController.request(patient.id, 'contact')} />
copy('address', patient.address)} feedback={failedAction === 'address' ? 'Copie indisponible' : doneAction === 'address' ? 'Copié ✓' : ''} failed={failedAction === 'address'} icon={<>} /> } /> copy('email', patient.email)} feedback={failedAction === 'email' ? 'Copie indisponible' : doneAction === 'email' ? 'Copié ✓' : ''} failed={failedAction === 'email'} icon={<>} /> } />
); } function patientContactInsertSummaryBlocks(rendered, treatmentBlock, contactBlock) { const outer = React.Children.toArray(rendered.props.children); const summaryIndex = outer.findIndex((child) => ( React.isValidElement(child) && child.type === 'div' && patientEventsContainsText(child, 'Clinique') )); window.__VASCO_PATIENT_SECTION_INSERT_DEBUG__ = { outerCount: outer.length, summaryIndex, outerTypes: outer.map((child) => typeof child.type === 'string' ? child.type : child.type?.name || typeof child), }; if (summaryIndex < 0) { console.warn('[vasco-patient-section-insert]', window.__VASCO_PATIENT_SECTION_INSERT_DEBUG__); return rendered; } const summary = outer[summaryIndex]; const sections = React.Children.toArray(summary.props.children); const clinicalIndex = sections.findIndex((child) => patientEventsContainsText(child, 'Clinique')); const actionsIndex = sections.findIndex((child) => patientEventsContainsText(child, 'Actions')); Object.assign(window.__VASCO_PATIENT_SECTION_INSERT_DEBUG__, { sectionCount: sections.length, clinicalIndex, actionsIndex, sectionTypes: sections.map((child) => typeof child.type === 'string' ? child.type : child.type?.name || typeof child), }); if (clinicalIndex < 0 || actionsIndex < 0) { console.warn('[vasco-patient-section-insert]', window.__VASCO_PATIENT_SECTION_INSERT_DEBUG__); return rendered; } sections.splice(clinicalIndex + 1, 0, React.cloneElement(treatmentBlock, { key: 'patient-treatments' })); // L'insertion précédente décale Actions d'une place : son ancien index + 1 // est donc exactement la position où Coordonnées doit s'insérer. sections.splice(actionsIndex + 1, 0, React.cloneElement(contactBlock, { key: 'patient-contact-details' })); outer[summaryIndex] = React.cloneElement(summary, summary.props, sections); return React.cloneElement(rendered, rendered.props, outer); } // Remplace le bloc V5 par le composant partagé avec la fiche patient dépliée. function patientContactReplaceNextAppointment(rendered, props) { if (!props.p.nextRdv || !React.isValidElement(rendered)) return rendered; const isLegacyAppointment = rendered.type === 'div' && rendered.props?.style?.padding === '8px 24px 16px' && patientEventsContainsText(rendered, 'Prochain RDV') && patientEventsContainsText(rendered, props.p.nextRdv.what); if (isLegacyAppointment) { return props.onTab('agenda')} style={{ padding: '8px 24px 16px' }} />; } const content = React.Children.toArray(rendered.props?.children); if (!content.length) return rendered; return React.cloneElement( rendered, rendered.props, content.map((child) => patientContactReplaceNextAppointment(child, props)), ); } function PatientContactDetailsResume(props) { const rendered = PatientContactDetailsBaseResume(props); const withSummaryBlocks = patientContactInsertSummaryBlocks( rendered, , , ); return patientContactReplaceNextAppointment(withSummaryBlocks, props); } PatientHeader = PatientSectionalHeader; clinicalSummaryBlock = PatientSectionalClinicalSummary; ActionSheet = PatientSectionalActionSheet; TabResume = PatientContactDetailsResume;