// 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 (
{child && ↳ }
{item.label || meta.label}
{patientEventDate(item.eventDate)}
{item.actCode && {item.actCode} }
{item.practitioner}
MODIFIÉ LE {patientEventTimestamp(item.updatedAt)}
onEdit(item, hasChildren)} disabled={Boolean(refusal)}
label={refusal || `Modifier ${PE_TYPE_LABELS[item.eventType].toLowerCase()}`}
size={32} style={{ borderRadius: 999, color: refusal ? P_SUB : P_INK }}>
{refusal &&
{refusal}
}
);
}
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 (
);
}
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 &&
Charger la suite }
>}
;
}
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;