// Extension runtime du dossier : événements généralistes, sans modifier la V5 figée. const PatientEventsBaseResume = DocumentImportBaseResume; const PatientEventChoice = window.VascoUI?.SimpleSelect; const PE_TYPE_LABELS = Object.freeze({ HOSP: 'Séjour', INT: 'Intervention', CONS: 'Consultation' }); const PE_FILTERS = Object.freeze([ ['all', 'Tous'], ['HOSP', 'Séjours'], ['INT', 'Gestes'], ['CONS', 'Consultations'], ]); const PE_EVENT_STYLES = Object.freeze({ HOSP: { label: 'Hospitalisation', color: '#d97706', tint: 'rgba(217,119,6,0.10)' }, INT: { label: 'Intervention', color: '#059669', tint: 'rgba(5,150,105,0.10)' }, CONS: { label: 'Consultation', color: VT.medBlue || '#3d6b8f', tint: VT.medBlueTint || 'rgba(61,107,143,0.10)' }, }); const PE_TRUNCATE = { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }; function patientEventDate(value) { if (!value) return '–'; return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC', }).format(new Date(`${value}T12:00:00Z`)); } function patientEventTimestamp(value) { if (!value) return '–'; return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: '2-digit', }).format(new Date(value)); } function PatientEventRow({ item, child = false, hasChildren = false, onEdit, last = false }) { const { IconButton } = window.VascoUI; const refusal = VascoPatientEvents.updateReason(item.capabilities); const meta = PE_EVENT_STYLES[item.eventType] || PE_EVENT_STYLES.CONS; return (
); } function patientEventRows(items, maxRoots = null) { const groups = VascoPatientEvents.group(items); const selected = Number.isInteger(maxRoots) ? groups.slice(0, maxRoots) : groups; return selected.flatMap((group) => [ { item: group.event, child: false, hasChildren: group.children.length > 0 }, ...group.children.map((item) => ({ item, child: true, hasChildren: false })), ]); } function PatientEventGroups({ items, onEdit, maxRoots = null }) { const rows = patientEventRows(items, maxRoots); return <>{rows.map((row, index) => ( ))}; } function PatientEventEditor({ patient, runtime, initial, stays, hasChildren, onSaved }) { const { Button, ChoiceChip } = window.VascoUI; const randomUUID = React.useCallback( () => runtime.events.newAttempt().idempotencyKey, [runtime], ); const [editor, setEditor] = React.useState(() => VascoPatientEvents.openEditor(initial, randomUUID)); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const [errors, setErrors] = React.useState({}); const built = VascoPatientEvents.buildPatch(editor.record, editor.form, { hasChildren }); const refusal = VascoPatientEvents.updateReason(editor.record.capabilities); const parentPresent = stays.some((stay) => stay.id === editor.form.parent_event_id); const change = (field, value) => { setEditor((current) => ({ ...current, form: { ...current.form, [field]: value } })); setErrors((current) => ({ ...current, [field]: null })); }; const submit = async (event) => { event.preventDefault(); const patch = VascoPatientEvents.buildPatch(editor.record, editor.form, { hasChildren }); setErrors(patch.errors); if (!patch.body || refusal || busy) return; setBusy(true); setError(null); try { const updated = await runtime.events.update( patient.id, editor.record, patch.body, editor.attempt, ); onSaved(updated); } catch (failure) { if (VascoPatientEvents.shouldRebase(failure)) { try { const freshPage = await runtime.events.list(patient.id, { eventType: 'all', page: 1, pageSize: 100, }); const fresh = freshPage.items.find((item) => item.id === editor.record.id); if (fresh) { setEditor((current) => VascoPatientEvents.rebaseEditor(current, fresh, randomUUID)); setError('L’événement a changé. La version récente est chargée et votre saisie est conservée.'); } else { setError('L’événement a changé mais sa version récente est hors de cette page. Votre saisie est conservée.'); } } catch (reloadFailure) { setError(VascoPatientEvents.errorMessage(reloadFailure, 'Rechargement impossible.')); } } else { setError(VascoPatientEvents.errorMessage(failure, 'Modification impossible.')); } } finally { setBusy(false); } }; const fieldStyle = { width: '100%', height: VT.hMd || 40, border: `1px solid ${P_HAIR2}`, borderRadius: VT.rMd || 12, background: '#fff', padding: '0 12px', fontFamily: 'inherit', fontSize: 14, color: P_INK, outline: 'none', }; const captionStyle = { display: 'block', margin: '16px 0 8px', color: P_SUB, fontSize: 9.5, fontWeight: 600, letterSpacing: '.5px', textTransform: 'uppercase', }; return (
{error &&
{error}
} {refusal &&
{refusal}
}
Type
{Object.entries(PE_EVENT_STYLES).map(([value, meta]) => { const active = editor.form.event_type === value; const typeBlocked = Boolean(refusal) || (hasChildren && value !== 'HOSP'); return change('event_type', value)} style={active ? { borderColor: meta.color, background: meta.tint, color: meta.color, } : undefined}>{meta.label}; })}
{errors.event_type && {errors.event_type}} {editor.form.event_type === 'INT' && <> }
Dernière modification : {patientEventTimestamp(editor.record.updatedAt)}. Elle sera mise à jour à l’enregistrement.
); } function PatientEventsSheet({ patient, runtime, initialEvent = null, initialHasChildren = false, onSaved, onClose }) { const { DetailSheet, IconButton, ChoiceChip, Button, InlineAction, icons } = window.VascoUI; const [filter, setFilter] = React.useState('all'); const [page, setPage] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const [reloadToken, setReloadToken] = React.useState(0); const [editor, setEditor] = React.useState( initialEvent ? { event: initialEvent, hasChildren: initialHasChildren } : null, ); const [stays, setStays] = React.useState( (patient.recentEvents || []).filter((item) => item.eventType === 'HOSP'), ); const closeRef = React.useRef(null); React.useEffect(() => { closeRef.current?.focus(); }, [editor]); React.useEffect(() => { const controller = new AbortController(); let active = true; setLoading(true); setPage(null); setError(null); runtime.events.list(patient.id, { eventType: filter, page: 1, pageSize: 20, signal: controller.signal, }).then((result) => { if (active) setPage(result); }) .catch((failure) => { if (active) setError(VascoPatientEvents.errorMessage(failure, 'Événements indisponibles.')); }) .finally(() => { if (active) setLoading(false); }); return () => { active = false; controller.abort(); }; }, [runtime, patient.id, filter, reloadToken]); React.useEffect(() => { if (stays.length) return undefined; const controller = new AbortController(); runtime.events.list(patient.id, { eventType: 'HOSP', page: 1, pageSize: 100, signal: controller.signal, }).then((result) => setStays(result.items)) .catch(() => {}) ; return () => controller.abort(); }, [runtime, patient.id]); const loadMore = async () => { if (!page || loading || page.page >= page.totalPages) return; setLoading(true); setError(null); try { const next = await runtime.events.list(patient.id, { eventType: filter, page: page.page + 1, pageSize: page.pageSize, }); setPage(VascoPatientEvents.mergePage(page, next)); } catch (failure) { setError(VascoPatientEvents.errorMessage(failure, 'Événements indisponibles.')); } finally { setLoading(false); } }; const saved = (updated) => { setPage((current) => current ? { ...current, items: current.items.map((item) => item.id === updated.id ? updated : item), } : current); setStays((current) => { const without = current.filter((item) => item.id !== updated.id); return updated.eventType === 'HOSP' ? [...without, updated] : without; }); setEditor(null); onSaved(updated); setReloadToken((value) => value + 1); }; const items = page?.items || []; const state = VascoPatientEvents.listState({ items, loading, error }); const editorMeta = editor ? (PE_EVENT_STYLES[editor.event.eventType] || PE_EVENT_STYLES.CONS) : null; const total = page?.counts?.all ?? patient.eventCount ?? 0; const headerClose = editor ? () => setEditor(null) : onClose; return { if (editor) setEditor(null); else closeSheet(); }}>
{editor ? editorMeta.label : 'Évènements'}
{patient.last} {patient.first} · {editor ? patientEventDate(editor.event.eventDate) : `${total} au total`}
{editor ? ( ) : <>
{PE_FILTERS.map(([value, label]) => { const active = filter === value; const count = page?.counts?.[value]; return setFilter(value)} style={{ flexShrink: 0 }}>{label} ({Number.isInteger(count) ? count : 0}); })}
{error &&
{error}
} {state === 'loading' &&
Chargement des événements…
} {state === 'error' && setReloadToken((value) => value + 1)}> Réessayer } {state === 'empty' &&
Aucun évènement de ce type.
} {items.length > 0 &&
setEditor({ event, hasChildren })} />
} {page && page.page < page.totalPages && }
}
; } function PatientEventsRecentSection({ patient, runtime }) { const { InlineAction } = window.VascoUI; const [recent, setRecent] = React.useState(patient.recentEvents || []); const [sheet, setSheet] = React.useState(null); React.useEffect(() => setRecent(patient.recentEvents || []), [patient.recentEvents]); const saved = (updated) => { setRecent((items) => items.map((item) => item.id === updated.id ? updated : item)); runtime.load(patient.id) .then((fresh) => setRecent(fresh.recentEvents)) .catch(() => {}); }; const rows = patientEventRows(recent, 3); return (
Évènements récents setSheet({ event: null, hasChildren: false })} style={{ color: P_SUB, fontSize: 11, letterSpacing: '.3px' }}> TOUT AFFICHER ({patient.eventCount || 0})
{rows.length === 0 ?
Aucun évènement enregistré.
:
setSheet({ event, hasChildren })} />
} {sheet && setSheet(null)} />}
); } function patientEventsContainsText(node, text) { if (typeof node === 'string') return node.includes(text); if (Array.isArray(node)) return node.some((child) => patientEventsContainsText(child, text)); if (!React.isValidElement(node)) return false; return patientEventsContainsText(node.props?.children, text); } function patientEventsReplaceSummarySection(base, replacement) { const target = 'Derniers comptes ' + 'rendus'; const children = React.Children.toArray(base.props.children); const index = children.findIndex((child) => patientEventsContainsText(child, target)); if (index < 0) return base; children[index] = React.cloneElement(replacement, { key: 'patient-events-recent' }); return React.cloneElement(base, base.props, children); } // La V5 figée place trois points à droite du titre sans action associée. Comme // ils ressemblent à un menu, le résumé actif retire uniquement ce PCaption et // conserve le titre ainsi que tout le contenu des alertes. function patientEventsRemoveSummaryDecoration(node) { if (Array.isArray(node)) { return node .map(patientEventsRemoveSummaryDecoration) .filter((child) => child !== null) .map((child, index) => ( React.isValidElement(child) ? React.cloneElement(child, { key: child.key || `patient-events-clean-${index}` }) : child )); } if (!React.isValidElement(node)) return node; if (node.type === PCaption && node.props?.children === '· · ·') return null; if (node.props?.children === undefined) return node; return React.cloneElement( node, node.props, patientEventsRemoveSummaryDecoration(node.props.children), ); } function PatientEventsResume(props) { const original = PatientEventsBaseResume(props); const summary = patientEventsReplaceSummarySection( original, , ); const cleanedSummary = patientEventsRemoveSummaryDecoration(summary); // Le scanner appartient à la grille Actions du résumé V6. Le fournisseur // connecté pose ce geste autour du résumé déjà décoré avec les événements, // sans recréer une entrée au-dessus de la fiche. return {cleanedSummary}; } TabResume = PatientEventsResume;