// Extensions généralistes de l'Agenda : lecture et modification des Staff/RCP
// (la maquette n'en crée plus).
// Elles enveloppent les feuilles V5 sans modifier la référence gelée.
(function installGeneralAgenda() {
'use strict';
const contract = window.VascoAgendaGeneral;
const LegacyNewRdvSheet = NewRdvSheet;
const LegacyRdvCard = RdvCard;
const LegacyWidgetRDV = WidgetRDV;
const LegacyAgendaScreen = AgendaScreen;
const LegacyViewToggleAG = ViewToggleAG;
const AgendaChoice = window.VascoUI?.SimpleSelect;
const AgendaScopeTabs = window.VascoUI?.ScopeTabs;
const AGENDA_PATIENT_SCOPES = Object.freeze([
{ id: 'living', label: 'Vivants' },
{ id: 'deceased', label: 'Décédés' },
]);
// L'application lit toujours les tokens officiels. Le second objet ne sert
// qu'aux harnais unitaires qui montent cet écran sans charger la coque Web.
const AG_MOTION = window.VascoUI?.tokens?.motion || Object.freeze({
quick: 150, responsive: 160, selectEnter: 180,
standard: 'cubic-bezier(.2,.7,.2,1)',
});
TYPE_STYLE.staff_rcp = {
bg: 'rgba(91,69,139,.09)', border: '#6d5797', text: '#58417f', label: 'Staff / RCP',
};
// Un type historique se lit sous son propre nom : l'imagerie s'affichait
// jusqu'ici avec l'étiquette « Suivi », qui n'était pas la sienne.
TYPE_STYLE.imagerie = {
bg: 'rgba(124,58,237,.09)', border: '#7c3aed', text: '#6d28d9', label: 'Imagerie',
};
// L'intervention devient orange pour rester distincte de la priorité urgente.
// Cette même palette pilote la pastille, le fond et le bord du rendez-vous.
TYPE_STYLE.intervention = {
bg: 'rgba(249,115,22,.10)', border: '#f97316', text: '#c2410c', label: 'Intervention',
};
// « Urgence » n'est plus un type : c'est une priorité cumulable avec le type.
// Elle reste rouge partout (badge, bascule, point et bord de timeline), tandis
// que l'ambre reste réservé aux conflits d'horaire.
const URGENT_STYLE = Object.freeze({
bg: 'rgba(185,28,28,.10)', border: '#b91c1c', text: '#991b1b', label: 'Urgent',
});
// Le résumé du jour écrit l'urgence dans le type – « 1 intervention · urgent »
// – et non à côté. Le mot ne s'accorde pas : il ne qualifie pas le nom, il
// marque la pastille. Il reste donc « urgent » au féminin comme au pluriel,
// séparé du décompte par un point médian plutôt que soudé à lui.
function summaryLabel(type, label, urgent, count) {
const noun = `${count} ${label}${count > 1 ? 's' : ''}`;
// « Urgence · urgent » ne se dit pas : le type historique porte déjà le mot.
if (!urgent || type === 'urgence') return noun;
return `${noun} · urgent`;
}
// Le repère de l'heure courante est rouge, comme dans Apple Calendar. Il ne
// porte pas l'accent de l'organisation : l'accent dit « ceci est à vous », il
// ne dit pas l'heure, et il change d'un cabinet à l'autre – le trait du temps,
// lui, doit se reconnaître partout. Il n'est pas non plus l'ambre du conflit
// d'horaire (`AG_WARN`, #d97706) qui borde déjà les blocs en litige : deux
// messages différents ne partagent pas une couleur.
const AG_NOW = '#ff3b30';
// La couche de ce qui reste collé en haut d'une zone qui défile – la bande des
// jours de l'écran Agenda, l'en-tête horaire de la feuille de création. Une
// seule constante pour les deux : elle se lit entre les couches de la journée
// (voile du passé 5, repère « maintenant » 6, qui passent dessous) et les
// feuilles posées sur la couche overlay officielle (200, au-dessus de tout).
// Écrite en clair des deux côtés, elle valait 6 dans la création : à égalité
// avec le repère, et aucun des cadres de la timeline ne créant de contexte
// d'empilement, c'est l'ordre du DOM qui tranchait – le trait rouge, rendu
// après, traversait la carte DÉBUT/FIN.
const AG_STICKY_Z = 8;
// Le refus que le serveur pose sur les événements des confrères : il se lit en
// toutes lettres là où la feuille V5 affiche déjà les autres raisons.
AG_REASONS[contract.FOREIGN_REASON] = 'Événement d’un autre praticien';
const fieldStyle = {
width: '100%', minHeight: 44, borderRadius: 14, border: `1px solid ${AG_HAIR2}`,
padding: '0 12px', fontFamily: 'inherit', fontSize: 13, color: AG_INK,
background: AG_SURF, outline: 'none',
};
const buttonStyle = {
minHeight: 44, borderRadius: 14, padding: '0 14px', fontFamily: 'inherit',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
};
// Le libellé d'un champ, en petites capitales : c'est la grammaire de l'étape
// Détails de la création, et la fiche parle la même. Il ne s'écrit qu'une
// fois – il était recopié à chaque champ.
const captionStyle = {
display: 'block', fontSize: 10, fontWeight: 600, color: AG_SUB,
letterSpacing: '0.4px', textTransform: 'uppercase', marginBottom: 6,
};
function sheet(children, onClose, title, layout = null, contentRef = null, headingAside = null) {
const stableAvailability = layout === 'availability';
const { BottomSheet } = window.VascoUI;
return
{children}
;
}
// Un choix parmi quelques possibilités, dans le gabarit de la pastille V5 :
// même hauteur, même arrondi, la sélection en encre pleine.
function ChoiceChips({ legend, options, value, onChange, columns = 2, disabled = false, error = false, alert }) {
const { ChoiceChip } = window.VascoUI;
return (
);
}
// Une seule palette pour la création et la modification : le type garde la
// couleur qui le représente dans l'agenda, même lorsqu'il est sélectionné.
// Le noir plein de l'ancien éditeur effaçait le sens bleu / orange.
function typeChoiceAppearance(type, selected, disabled = false) {
const typeStyle = TYPE_STYLE[type] || TYPE_STYLE.consultation;
return {
typeStyle,
style: {
border: `1px solid ${selected ? typeStyle.border : AG_HAIR}`,
background: selected ? typeStyle.bg : AG_SURF,
color: selected ? typeStyle.text : AG_INK,
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.45 : 1,
},
};
}
// La feuille gelée conserve ses anciennes couleurs en dur. La couche active
// remet donc sur chaque choix la palette du type qui colore aussi le rendez-vous.
function restyleCreationTypeChip(chip, type) {
const selected = chip.props.style?.background && chip.props.style.background !== AG_SURF;
const appearance = typeChoiceAppearance(type, selected);
const { typeStyle } = appearance;
const children = React.Children.toArray(chip.props.children).map((child) => {
if (!React.isValidElement(child)) return child;
if (child.type === 'div') {
return React.cloneElement(child, {
style: { ...child.props.style, background: typeStyle.border },
});
}
if (child.type === 'span') {
return React.cloneElement(child, {
style: { ...child.props.style, color: selected ? typeStyle.text : AG_INK },
});
}
return child;
});
return React.cloneElement(chip, {
style: {
...chip.props.style,
...appearance.style,
},
}, ...children);
}
// Le type d'un rendez-vous existant se change entre les deux types encore
// créables – la fiche pose la même question que la création, dans le même
// geste. Un type historique (suivi, imagerie, Staff / RCP) ne se recrée pas :
// il se lit sous son nom, et `updateFields` s'abstient déjà de le réécrire.
function TypeChoice({ type, onType, disabled = false }) {
const { ChoiceChip } = window.VascoUI;
const ts = TYPE_STYLE[type] || TYPE_STYLE.consultation;
if (!contract.CREATION_TYPES.some((entry) => entry.id === type)) {
return (
Type
{ts.label}
Ce type ne se crée plus : il se lit, il ne se réécrit pas.
);
}
return (
);
}
// Une consultation ne se saisit pas en deux horodatages : le praticien pose
// un début, puis une durée. La fin en découle et reste modifiable – soit à la
// minute ici, soit par le champ « Fin » là où il existe.
function DurationField({ start, end, onEnd, label = 'Durée' }) {
const { ChoiceChip } = window.VascoUI;
const minutes = contract.durationOf(start, end);
const [free, setFree] = React.useState(() => (minutes == null ? '' : String(minutes)));
React.useEffect(() => { setFree(minutes == null ? '' : String(minutes)); }, [minutes]);
const apply = (value) => { const next = contract.endFromDuration(start, value); if (next) onEnd(next); };
return (
);
}
// ═══════════════════════════════════════════════════════════════════════
// La journée à l'échelle – timeline de l'étape « Choisir l'horaire »
//
// La grille de pilules supposait que tout est constant : trente minutes,
// toutes pareilles, toutes libres ou « occupées ». Une journée de cabinet
// n'est pas ça. On la regarde maintenant comme dans Apple Calendar – un axe
// vertical d'heures, les rendez-vous déjà posés à leur vraie hauteur, et le
// nouveau rendez-vous posé du doigt là où il reste de la place.
//
// Toute l'arithmétique temps ↔ pixels vit dans le contrat ; ce qui suit n'en
// est que l'habillage.
// ═══════════════════════════════════════════════════════════════════════
// Réglage fin, toujours disponible : le glissement est le geste naturel, mais
// il ne doit jamais être le seul moyen d'atteindre la minute voulue.
function TimeStepper({ label, value, onStep, disabled }) {
const { IconButton } = window.VascoUI;
const round = {
width: 28, height: 28, borderRadius: 10, flexShrink: 0, padding: 0,
border: `1px solid ${AG_HAIR2}`, background: AG_SURF, color: AG_INK,
fontFamily: 'inherit', fontSize: 16, fontWeight: 600, lineHeight: 1,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.35 : 1,
transition: `opacity ${AG_MOTION.quick}ms ease`,
};
const step = contract.TIMELINE_SNAP_MINUTES;
return (
{label}
onStep(-step)}>−{value} onStep(step)}>+
);
}
function WarnGlyph({ size = 13, color = AG_WARN }) {
return (
);
}
// Un rendez-vous posé sur l'axe. Le même bloc sert aux deux écrans, et c'est
// `onSelect` qui les sépare :
//
// – à la création, il n'y en a pas : le bloc laisse passer le doigt
// (`pointerEvents: none`), parce que le geste attendu là est de poser le
// nouveau rendez-vous, y compris par-dessus. Programmer en parallèle d'une
// urgence est légitime – le chevauchement se voit, il ne s'interdit pas ;
// – à l'affichage, il y en a un : le bloc devient le bouton qui ouvre la
// fiche, comme la carte de la liste qu'il remplace.
//
// Un événement de confrère reste touchable : c'est sa fiche qui dit le refus
// que le serveur y a posé, et la désaturation dit qu'il n'est pas à nous.
function TimelineEvent({ event, geometry, onSelect }) {
const ts = TYPE_STYLE[event.type] || TYPE_STYLE.consultation;
const top = contract.minutesToOffset(event.startMinutes, geometry);
const height = contract.minutesToOffset(event.endMinutes, geometry) - top;
const density = contract.blockDensity(height);
const width = 100 / event.columns;
const foreign = contract.isOtherPractitioner(event);
const clash = event.clashing;
const urgent = event.urgent === true;
// L'urgence a la priorité visuelle sur le type : sa pastille est rouge,
// donc le rendez-vous l'est aussi. Sans urgence, la couleur reste celle du type.
const eventStyle = urgent ? URGENT_STYLE : ts;
const mark = urgent ? contract.urgentMark(density) : 'none';
const line = { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' };
const edge = clash ? AG_WARN : urgent ? URGENT_STYLE.border : ts.border;
const dot = (
);
const style = {
position: 'absolute', top, height: Math.max(height, 4),
left: `${event.column * width}%`,
width: `calc(${width}% - ${contract.TIMELINE_COLUMN_GAP}px)`,
background: eventStyle.bg, borderRadius: 7,
border: `1px solid ${clash ? AG_WARN : `${eventStyle.border}30`}`,
borderLeft: `3px solid ${edge}`,
boxShadow: clash ? `0 0 0 2px ${AG_WARN}22` : 'none',
padding: density === 'bare' ? 0 : '2px 7px',
overflow: 'hidden',
opacity: foreign ? 0.7 : 1,
transition: `box-shadow ${AG_MOTION.quick}ms ease, border-color ${AG_MOTION.quick}ms ease`,
};
// Le marqueur d'urgence n'ajoute une rangée que lorsqu'il a quelque chose à
// montrer : sans urgence, le bloc garde exactement la forme qu'il avait.
const body = <>
{density === 'full' && <>
{mark === 'badge' ? (
{event.start}–{event.end}
) : (
{event.start}–{event.end}
)}
{event.patient}
{contract.appointmentReason(event) || ts.label}
>}
{density === 'single' && <>
{event.start}
{mark === 'dot' ? (
{dot}
{event.patient}
) : (
{event.patient}
)}
>}
{density === 'tight' && (
{event.patient}
)}
>;
if (typeof onSelect !== 'function') {
return
{body}
;
}
return (
);
}
// ── Ouvrir la journée au bon endroit ────────────────────────────────────
// Les deux écrans qui montrent la timeline défilent dans deux conteneurs
// différents – la zone de contenu de l'écran Agenda gelé d'un côté, le corps
// de la feuille de création de l'autre – et aucun des deux n'est le document.
// On remonte donc jusqu'à celui qui a réellement de quoi défiler, plutôt que
// d'en nommer un.
function scrollingParent(node) {
let parent = node && node.parentElement;
while (parent) {
if (parent.scrollHeight - parent.clientHeight > 1) {
const style = typeof window !== 'undefined' && typeof window.getComputedStyle === 'function'
? window.getComputedStyle(parent) : null;
const flow = style ? `${style.overflowY} ${style.overflow}` : 'auto';
if (/auto|scroll|overlay/.test(flow)) return parent;
}
parent = parent.parentElement;
}
return null;
}
// Amener un repère au centre de la zone visible. `scrollIntoView({block:
// 'center'})` semblait suffire et ne suffit pas : la maquette entière est mise
// à l'échelle par une transformation CSS (voir `index.html`), et les rectangles
// écran ne sont alors plus les pixels de mise en page dans lesquels `scrollTop`
// se compte – la même erreur avait déjà été payée sur le fil de discussion. On
// mesure donc l'échelle et on pose le défilement soi-même.
//
// Rend `false` quand rien n'a pu être mesuré : l'appelant ne retiendra pas le
// cadrage comme fait, et le prochain rendu réessaiera.
function centerOnMarker(node, topInsetNode = null) {
if (!node) return false;
if (typeof node.getBoundingClientRect === 'function') {
const view = scrollingParent(node);
if (view) {
const viewBox = view.getBoundingClientRect();
const scale = (viewBox.height / (view.clientHeight || 1)) || 1;
const offset = view.scrollTop + (node.getBoundingClientRect().top - viewBox.top) / scale;
const topInset = topInsetNode && typeof topInsetNode.getBoundingClientRect === 'function'
? topInsetNode.getBoundingClientRect().height / scale : 0;
const usableHeight = Math.max(1, view.clientHeight - topInset);
const top = contract.centeredScrollTop(offset - topInset, usableHeight,
view.scrollHeight - view.clientHeight);
if (top !== null) { view.scrollTop = top; return true; }
}
}
// Hors navigateur mis à l'échelle – et pour les contrôles, qui ne montent
// aucune géométrie – le centrage natif reste la meilleure approximation.
if (typeof node.scrollIntoView !== 'function') return false;
node.scrollIntoView({ block: 'center' });
return true;
}
// ── Le cadre de la journée, partagé ─────────────────────────────────────
// L'axe des heures, le filet des demies, le voile du passé et le repère de
// maintenant : c'est la grammaire de la journée, et elle est la même qu'on
// vienne poser un rendez-vous ou qu'on vienne lire ceux du jour. Elle vit
// donc ici une seule fois. Ce qui diffère entre les deux écrans passe en
// paramètre – la hauteur du voile, le curseur, ce qu'on empile dans la
// colonne du temps – et rien d'autre.
//
// `clockMinutes` est l'heure murale, quelle que soit la journée regardée :
// c'est elle qui dit quelles heures sont révolues. `nowMinutes` n'est posée
// que si la journée regardée est bien aujourd'hui : c'est elle qui trace le
// repère.
function TimelineCanvas({
dayKey, geometry, todayKey, clockMinutes, nowMinutes,
pastHeight = 0, pastDashed = false, empty = false, emptyLabel, emptyOffset,
gridRef, onGridClick, cursor = 'default', ariaLabel, roomGrid = false,
nowMarkerRef = null, scrollable = false, viewportStyle = null, children,
}) {
const gutter = contract.TIMELINE_GUTTER;
const hours = contract.timelineHours(geometry);
const hourRailPosition = roomGrid ? 'sticky' : 'absolute';
const timelineContent = (
{hours.map((hour) => {
return (
{hour.minutes + 30 < geometry.endMinutes && (
)}
);
})}
{/* Sur une journée de vingt-quatre heures, le milieu de l'axe n'est
plus sous les yeux : le message se pose là où la journée s'ouvre,
sans quoi il resterait à quatre heures de défilement de là. */}
{empty && (
{emptyLabel}
)}
{/* La colonne du temps : les rendez-vous, et ce que l'écran y ajoute. */}
{children}
{/* Le passé : éteint, hachuré. */}
{pastHeight > 0 && (
)}
{/* Le repère de l'heure courante, comme dans Apple Calendar : un trait
rouge et sa pastille. Il passe au-dessus du voile du passé – c'est
lui qui en marque le bord, et un repère à moitié couvert ne
repérerait plus rien. */}
{Number.isFinite(nowMinutes) && nowMinutes > geometry.startMinutes
&& nowMinutes < geometry.endMinutes && (
)}
);
return (
{scrollable ? (
{timelineContent}
) : timelineContent}
);
}
// ── La timeline elle-même ───────────────────────────────────────────────
// Le même cadre sert à poser un rendez-vous et à corriger celui qui existe :
// c'est la même journée, le même bloc, le même geste. Ce qui les sépare tient
// en quelques paramètres –
//
// – `excludeId` : la fiche d'un rendez-vous existant écarte ce rendez-vous de
// ses voisins, son bloc d'accent le représentant déjà ;
// – `onDayChange` : la création a le calendrier interne de la feuille gelée,
// la fiche n'en a pas – le jour s'y change par un contrôle de date sobre,
// posé à la place du libellé du jour ;
// – `locked` : un refus du serveur (confrère, terminé, annulé, patient
// décédé) ou un horaire déjà commencé fige le bloc. La journée reste
// montrée – c'est ce qu'on est venu lire – mais rien ne s'y règle, et la
// raison est écrite ;
// – `armSlot` : seule la création a une feuille gelée à piloter.
function DayTimeline({
dayKey, rdvs, minutes, onMinutes, startMinutes, onStart, armSlot, accent,
excludeId = null, onDayChange = null, locked = false, lockedReason = null,
title = 'Choisir l’horaire', blockLabel = 'nouveau rendez-vous', compactControls = false,
}) {
const gridRef = React.useRef(null);
const blockRef = React.useRef(null);
const dragRef = React.useRef(null);
const scrolledRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
const todayKey = agendaTodayKey();
const now = agendaNowInZone();
const nowMinutes = now && now.dayKey === dayKey ? now.minutes : null;
const posed = Number.isFinite(startMinutes);
const events = contract.dayTimelineEvents(rdvs, dayKey, excludeId);
// La journée entière, toujours : le bloc ne peut plus déborder d'un cadre
// qui s'arrêtait au soir, et il n'y a donc plus rien à élargir.
const geometry = contract.timelineGeometry();
const floor = contract.timelineFloor(dayKey, todayKey, now ? now.minutes : null, geometry);
// Une journée révolue ferme la création ; elle ne ferme pas la lecture d'un
// rendez-vous qui s'y trouve déjà.
const closed = !locked && floor === null;
const conflicts = posed ? contract.overlappingEvents(events, startMinutes, startMinutes + minutes) : [];
const clashing = new Set(conflicts.map((event) => event.id));
const laid = contract.layoutTimelineEvents(events)
.map((event) => ({ ...event, clashing: clashing.has(event.id) }));
const fits = locked
|| (!closed && contract.clampBlockStart(floor, { geometry, duration: minutes, floor }) !== null);
const spanMinutes = (px) => contract.offsetToMinutes(px, geometry) - geometry.startMinutes;
// Arrivée sur l'étape, ou changement de jour : le bloc se pose tout seul
// sur la première plage réellement libre, plutôt que d'attendre un geste
// devant une journée vide de sens. Un bloc déjà posé n'est jamais
// repositionné – revenir de l'étape suivante ne défait pas le choix. Mais
// changer de jour peut le laisser sous le plancher du nouveau jour : il y
// remonte, plutôt que de partir se faire refuser par le serveur.
React.useEffect(() => {
if (locked) return;
if (closed) { if (posed) onStart(null); return; }
if (posed) {
const bounded = contract.clampBlockStart(startMinutes, { geometry, duration: minutes, floor });
if (bounded !== null && bounded !== startMinutes) onStart(bounded);
return;
}
const proposed = contract.firstProposedStart(events, {
floor, duration: minutes, until: geometry.endMinutes,
});
onStart(contract.clampBlockStart(proposed, { geometry, duration: minutes, floor }));
}, [dayKey, closed, posed, locked]);
// La feuille gelée ne sait rien de la timeline : elle veut un créneau
// retenu pour laisser passer à l'étape suivante. On lui donne le plus
// proche ; l'horaire qui part au serveur, lui, vient d'ici. La fiche d'un
// rendez-vous existant n'a aucune feuille gelée à piloter.
React.useEffect(() => {
if (posed && typeof armSlot === 'function') armSlot(startMinutes);
}, [startMinutes, armSlot, posed]);
// Défilement initial : la journée s'ouvre sur le bloc, pas sur minuit – et
// le bloc se pose au milieu de la zone visible, comme sur l'écran Agenda.
// Les deux écrans montrent la même journée : ils doivent l'ouvrir de la
// même façon.
React.useEffect(() => {
if (!posed || scrolledRef.current === dayKey) return;
if (centerOnMarker(blockRef.current)) scrolledRef.current = dayKey;
}, [dayKey, posed, startMinutes]);
const setDuration = (value) => {
if (locked) return;
const bounded = Math.max(contract.MIN_DURATION_MINUTES,
Math.min(contract.MAX_DURATION_MINUTES, value));
onMinutes(bounded);
if (!posed) return;
// Allonger un rendez-vous du soir le remonte s'il ne tient plus avant
// minuit : le cadre ne s'ouvre plus pour lui, c'est déjà la journée.
const next = contract.clampBlockStart(startMinutes, { geometry, duration: bounded, floor });
if (next !== null && next !== startMinutes) onStart(next);
};
const stepStart = (delta) => {
if (!posed || locked) return;
const next = contract.clampBlockStart(startMinutes + delta, { geometry, duration: minutes, floor });
if (next !== null) onStart(next);
};
const beginDrag = (mode) => (event) => {
if (!posed || closed || locked) return;
event.preventDefault();
event.stopPropagation();
try { event.currentTarget.setPointerCapture?.(event.pointerId); } catch (failure) { /* capture optionnelle */ }
dragRef.current = { mode, pointerId: event.pointerId, originY: event.clientY, startMinutes, minutes };
setDragging(true);
};
const moveDrag = (event) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const span = spanMinutes(event.clientY - drag.originY);
if (drag.mode === 'move') {
const next = contract.clampBlockStart(contract.snapToStep(drag.startMinutes + span),
{ geometry, duration: minutes, floor });
if (next !== null && next !== startMinutes) onStart(next);
return;
}
if (drag.mode === 'end') {
const raw = contract.snapToStep(drag.startMinutes + drag.minutes + span);
const value = Math.max(contract.MIN_DURATION_MINUTES,
Math.min(contract.MAX_DURATION_MINUTES, raw - drag.startMinutes));
if (value !== minutes) onMinutes(value);
return;
}
// Étirement par le haut : le début recule, la fin ne bouge pas.
const highest = drag.startMinutes + drag.minutes - contract.MIN_DURATION_MINUTES;
const raw = Math.min(contract.snapToStep(drag.startMinutes + span), highest);
const next = contract.clampBlockStart(raw, { geometry, duration: contract.MIN_DURATION_MINUTES, floor });
if (next === null || next === startMinutes) return;
onStart(next);
onMinutes(drag.startMinutes + drag.minutes - next);
};
const endDrag = (event) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
dragRef.current = null;
setDragging(false);
};
const dragProps = (mode) => ({
onPointerDown: beginDrag(mode), onPointerMove: moveDrag,
onPointerUp: endDrag, onPointerCancel: endDrag,
onClick: (event) => event.stopPropagation(),
});
// Toucher un espace libre pose le bloc à cet endroit, arrondi à la
// graduation fine. Toucher le passé le ramène au plancher : la règle se
// montre par le mouvement plutôt que par un refus muet.
const placeAt = (event) => {
if (dragRef.current || closed || locked || !fits || !gridRef.current) return;
const rect = gridRef.current.getBoundingClientRect();
const raw = contract.offsetToMinutes(event.clientY - rect.top, geometry);
const next = contract.clampBlockStart(contract.snapToStep(raw), { geometry, duration: minutes, floor });
if (next !== null) onStart(next);
};
// Le passé en création n'est pas le passé en lecture : créer refuse ce qui
// est révolu et le voile tout ; relire un rendez-vous ne refuse rien, et
// seule la part écoulée d'aujourd'hui s'estompe – c'est déjà la règle de
// l'écran Agenda.
const pastHeight = locked
? contract.pastVeilHeight(dayKey, todayKey, nowMinutes, geometry)
: (closed ? geometry.height : Math.max(0, contract.minutesToOffset(floor, geometry)));
const blockTop = posed ? contract.minutesToOffset(startMinutes, geometry) : 0;
const blockHeight = posed
? contract.minutesToOffset(startMinutes + minutes, geometry) - blockTop : 0;
const posedDensity = contract.blockDensity(blockHeight);
// Sur un bloc court, la poignée d'étirement prend le pas sur le
// déplacement : c'est l'ajustement qu'on cherche à cette taille, et le
// déplacement reste accessible par un toucher ailleurs ou par les ±.
const handleHeight = Math.max(8, Math.min(16, blockHeight / 3));
const rangeLabel = posed ? contract.timelineRangeLabel(startMinutes, minutes) : '–';
const dayLabel = `${JOURS_FULL[new Date(`${dayKey}T12:00`).getDay()]} ${Number(dayKey.slice(8))} ${MOIS[Number(dayKey.slice(5, 7)) - 1].toLowerCase()}`;
const grip = {
position: 'absolute', left: '50%', marginLeft: -13, width: 26, height: 3,
borderRadius: 2, background: 'rgba(255,255,255,0.85)', pointerEvents: 'none',
};
return (
{/* Changer de jour : la création a le calendrier interne de la
feuille gelée, la fiche n'en a pas. Le geste le plus sobre est
alors le contrôle de date natif, posé exactement là où le libellé
du jour se lisait – le libellé reste à côté, parce qu'un jour de
la semaine n'est pas dans « 03/09/2026 ». */}
{typeof onDayChange === 'function' ? (
{ if (event.target.value) onDayChange(event.target.value); }}
style={{ ...fieldStyle, width: 'auto', minHeight: 34, fontSize: 12, padding: '0 8px', opacity: locked ? 0.45 : 1 }} />
{dayLabel}
) : (
{dayLabel}
)}
{events.length === 0 ? 'journée libre' : `${events.length} rendez-vous`}
{/* Ce qui part réellement au serveur reste sous les yeux pendant qu'on
fait défiler la journée : c'est la seule chose qui mérite de coller.
Fond opaque et couche collante partagée : la journée passe dessous,
repère « maintenant » compris, et ne se lit pas au travers. */}
{compactControls ? (
{rangeLabel}
) :
{/* L'horaire retenu change sous le doigt : c'est une vraie zone
vivante, pas seulement un titre. */}
{rangeLabel}
{conflicts.length > 0 && }
{conflicts.length > 0
? `avec ${conflicts[0].patient}${conflicts.length > 1 ? ` +${conflicts.length - 1}` : ''}`
: contract.formatDuration(minutes)}
}
setDuration(minutes + delta)} />
{/* La journée, à l'échelle – le cadre est celui que l'écran Agenda
dessine aussi ; ici il porte en plus le bloc qu'on pose. */}
)}
{/* Les poignées ne se dessinent que là où elles servent : un
bloc figé qui montrerait ses prises mentirait sur le geste. */}
{!locked && blockHeight >= 44 && <>
>}
{!locked && blockHeight >= 30 && (
)}
{!locked && }
)}
{/* Ce qui empêche de régler l'horaire, dit en toutes lettres et à la
place du geste – annoncer un glissement impossible serait pire que
de ne rien dire. */}
{locked && lockedReason && (
{lockedReason}
)}
{/* Le geste se dit une fois, en petit : sur écran tactile rien ne
l'annonce, et les ± de l'en-tête ne se devinent pas non plus. */}
{posed && !closed && !locked && (
Touchez la journée pour déplacer le rendez-vous, glissez ses bords pour
l’allonger. Les boutons ± règlent à {contract.TIMELINE_SNAP_MINUTES} minutes près.
)}
{/* Ce qui empêche, dit en toutes lettres. */}
{closed && (
Cette journée est révolue : revenez à l’étape précédente pour choisir une autre date.
)}
{!closed && !fits && (
{contract.formatDuration(minutes)} ne tient plus dans ce qu’il reste de la journée : raccourcissez, ou choisissez une autre date.
)}
{/* Le détail du chevauchement, comme la feuille V5 le disait déjà. */}
{conflicts.length > 0 && (
{locked
? 'Ce rendez-vous se tient en parallèle d’un autre.'
: 'Déplacez le bloc, ou continuez pour programmer en parallèle.'}
)}
);
}
// L'urgence se pose et se retire d'un geste, à côté du type et jamais à sa
// place. Le contrôle reprend la pastille de type de la V5 – même hauteur,
// même arrondi, la couleur de l'ancien type « Urgence ».
function UrgentToggle({ urgent, onToggle, label = 'Urgence', disabled = false }) {
const { InteractiveRow } = window.VascoUI;
return (
Urgent
);
}
// Le motif tient sur une ligne et part en 160 caractères ; la note du
// praticien est le texte long, celui qu'on relit avant d'entrer en salle.
// Les deux existent côté serveur depuis toujours – seule la saisie manquait.
function NotesField({ value, onChange, label = 'Notes' }) {
return (
);
}
// ── Matériel de l'intervention ───────────────────────────────
//
// La question ne se pose que pour une intervention : une consultation n'a
// pas de matériel à commander, et le champ disparaît alors entièrement.
// Disparaît, et non pas grisé : il n'y a rien à expliquer, la question n'a
// simplement pas lieu d'être.
//
// La saisie et le nombre de jours sont les primitives du socle : `Field`
// porte le libellé et l'aide, `TextField` la saisie, `ChoiceChip` les
// raccourcis. Rien de tout cela n'est redessiné ici.
function MaterialWriting({ material, onMaterial, leadDays, onLeadDays, disabled = false }) {
const { ChoiceChip, Field, TextField } = window.VascoUI;
const armed = String(material || '').trim() !== '';
// Le nombre libre ne remplace pas les raccourcis, il les prolonge : la
// rangée reste la même question – « combien de jours avant ? » – et le
// champ n'apparaît que si on sort des trois réponses courantes.
const known = contract.MATERIAL_LEAD_CHOICES.some(([value]) => value === leadDays);
const [custom, setCustom] = React.useState(false);
const showCustom = custom || (leadDays != null && !known);
return (
onLeadDays(contract.materialLeadDays(value))} />
jours avant l’intervention
}
{leadDays
? `Un rappel se posera dans vos Rappels, ${contract.materialLeadLabel(leadDays).toLowerCase()}. Il ouvrira ce rendez-vous.`
: 'Sans délai, le matériel reste écrit sur le rendez-vous mais aucun rappel n’est posé.'}
}
);
}
// ── Rappel patient ──────────────────────────────────────────────────────
// Le praticien décide trois choses : rappeler ou non, par quel canal, et
// combien de temps à l'avance. Tout le reste appartient au serveur – l'heure
// exacte de l'envoi, son résultat, et le fait qu'un canal soit branché ou
// non. L'écran ne calcule donc aucun état de rappel : il montre celui qu'on
// lui rend.
//
// La section ne propose jamais un canal que le dossier ne permet pas. Ce
// n'est pas de la prévenance : un bouton « SMS » sur un dossier sans numéro
// serait refusé par le serveur, et le praticien croirait le rappel armé.
function ReminderSection({ patientId, runtime, draft, onDraft }) {
const { InteractiveRow } = window.VascoUI;
const [capability, setCapability] = React.useState(null);
const [failure, setFailure] = React.useState(null);
const ask = runtime && runtime.reminderCapability;
React.useEffect(() => {
setCapability(null); setFailure(null);
if (!patientId || typeof ask !== 'function') return undefined;
let live = true;
ask(patientId)
.then((value) => {
if (!live) return;
setCapability(value);
// Le brouillon se pré-règle sur ce que le dossier permet – et
// s'éteint tout seul quand il ne permet rien.
onDraft(contract.reminderDraftFor(value));
})
.catch((error) => {
if (live) setFailure(contract.errorMessage(error, 'Rappel indisponible'));
});
return () => { live = false; };
}, [patientId, ask]);
const label = ;
if (!patientId) {
return (
{label}
Choisissez d’abord un patient : le rappel part sur ses coordonnées.
);
}
if (typeof ask !== 'function') {
return (
{label}
Rappels indisponibles : le serveur ne les expose pas.
);
}
if (failure) {
return (
{label}
{failure}
);
}
if (!capability) {
return (
{label}
Lecture du dossier…
);
}
// Le dossier ne permet aucun rappel : on le dit, avec sa raison, et on
// n'offre pas une bascule qui n'allumerait rien.
if (capability.reasonCode) {
return (
Le rappel ne porte que la date, l’heure, le lieu et le nom du cabinet.
>}
);
}
// Sur la fiche, le rappel ne se règle pas : il se lit. L'état vient du
// serveur – programmé, envoyé, en échec, annulé, ou en attente d'un canal
// d'envoi configuré – et la phrase est celle du contrat, pour que la fiche et
// les specs ne puissent pas diverger.
function ReminderState({ rdv, runtime }) {
const [reminder, setReminder] = React.useState(null);
const [failure, setFailure] = React.useState(null);
const read = runtime && runtime.reminder;
React.useEffect(() => {
setReminder(null); setFailure(null);
if (!rdv || typeof read !== 'function') return undefined;
let live = true;
read(rdv.id)
.then((value) => { if (live) setReminder(value); })
.catch((error) => {
if (live) setFailure(contract.errorMessage(error, 'Rappel illisible'));
});
return () => { live = false; };
}, [rdv && rdv.id, read]);
if (typeof read !== 'function') return null;
const sentence = failure || (reminder
? contract.reminderStatusLabel(reminder, reminderMoment)
: 'Lecture du rappel…');
const alarming = !!failure || (reminder && reminder.state === 'failed');
return (
);
}
// La date d'un rappel se lit dans le fuseau du navigateur, comme le reste de
// l'agenda. Un horodatage illisible n'efface pas la phrase : il la raccourcit.
function reminderMoment(value) {
if (!value) return null;
const moment = new Date(value);
if (Number.isNaN(moment.getTime())) return null;
const day = String(moment.getDate()).padStart(2, '0');
const month = String(moment.getMonth() + 1).padStart(2, '0');
const hour = String(moment.getHours()).padStart(2, '0');
const minute = String(moment.getMinutes()).padStart(2, '0');
return `${day}/${month} à ${hour}:${minute}`;
}
// Le visage d'une pièce : sa catégorie, son titre, sa date. Trois endroits le
// montrent – les pièces déjà attachées, les candidates du dossier, et celles
// retenues avant que le rendez-vous existe. Un seul rendu les sert, pour que
// la même pièce ne se lise jamais de deux façons selon l'écran.
const DOC_BADGE = {
fontSize: 9.5, border: `1px solid ${AG_HAIR2}`, borderRadius: 10, padding: '3px 7px',
maxWidth: 110, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
};
function DocumentFace({ doc }) {
return (
{doc.type}{doc.label}{doc.d}
);
}
// Les pièces du rendez-vous sont des documents DÉJÀ au dossier du patient :
// on n'importe rien ici, on désigne. Le serveur refuse tout document d'un
// autre dossier, et le refus s'affiche tel quel plutôt que d'être masqué.
//
// Le rendez-vous ne transporte pas ses pièces dans sa vue : la fiche va les
// lire quand elle s'ouvre. Chaque geste renvoie la liste à jour, si bien que
// l'écran ne recompose jamais un état qu'il aurait deviné.
function AppointmentDocuments({ rdv, runtime, canEdit, disabledReason }) {
const { Button, InteractiveRow } = window.VascoUI;
const [items, setItems] = React.useState(null);
const [choices, setChoices] = React.useState(null);
const [picking, setPicking] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
React.useEffect(() => {
let live = true;
runtime.documents(rdv.id)
.then((value) => { if (live) setItems(value); })
.catch((failure) => { if (live) setError(contract.errorMessage(failure, 'Pièces illisibles')); });
return () => { live = false; };
}, [rdv.id]);
const openPicker = async () => {
setBusy(true); setError(null);
try {
// Le choix se fait dans le dossier du patient du rendez-vous : c'est la
// seule source d'où une pièce peut venir.
setChoices(await runtime.patientDocuments(rdv.patientId));
setPicking(true);
} catch (failure) {
setError(contract.errorMessage(failure, 'Dossier illisible'));
} finally { setBusy(false); }
};
const run = async (action) => {
setBusy(true); setError(null);
try {
setItems(await action());
setPicking(false);
} catch (failure) {
setError(contract.errorMessage(failure, 'Pièce non modifiée'));
} finally { setBusy(false); }
};
// Le serveur rend une page, pas un tableau : la lecture est celle du
// contrat, la même que dans la feuille de création.
const attachable = contract.attachableDocuments(choices, items);
return (
);
}
// ═══════════════════════════════════════════════════════════════════════
// Attacher des pièces PENDANT la création du rendez-vous
//
// La contrainte de réalité : le lien `appointment_documents` porte
// l'identifiant du rendez-vous, il ne peut donc pas naître avant lui. Ce
// bloc ne fait que RETENIR des documents ; l'attache part à l'envoi, une
// fois le rendez-vous créé (voir `EnhancedNewRdvSheet`).
//
// Le dépôt d'un fichier, lui, est réel et immédiat : il importe le document
// au dossier du patient par le parcours du lot 17 – `uploadSource` (avec son
// contrôle de sécurité serveur), `create`, puis `confirm` après saisie du
// titre. Rien de ce pipeline n'est réécrit ici ; seule la façade change,
// parce que la modale du dossier n'a pas de zone de dépôt et que ses trois
// boutons de sélection sont figés par ses propres specs.
//
// Un document importé reste au dossier même si le rendez-vous n'est
// finalement pas créé : c'est un ajout au dossier, pas un brouillon.
function DropTarget({ disabled, onFiles, children }) {
const [over, setOver] = React.useState(false);
const inputRef = React.useRef(null);
const take = (files) => { if (!disabled) onFiles(Array.from(files || [])); };
return (
{ if (disabled) return; event.preventDefault(); setOver(true); }}
onDragLeave={() => setOver(false)}
onDrop={(event) => {
if (disabled) return;
event.preventDefault();
setOver(false);
take(event.dataTransfer?.files);
}}
onClick={() => { if (!disabled) inputRef.current?.click(); }}
role="button" tabIndex={disabled ? -1 : 0}
aria-label="Déposez ou choisissez un document"
onKeyDown={(event) => {
if (disabled || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
inputRef.current?.click();
}}
style={{
borderRadius: 16, padding: '16px 12px', textAlign: 'center', cursor: disabled ? 'default' : 'pointer',
border: `1px dashed ${over ? AG_INK : AG_HAIR2}`,
background: over ? 'rgba(10,10,10,0.03)' : 'transparent',
transition: `border-color ${AG_MOTION.selectEnter}ms ease, background ${AG_MOTION.selectEnter}ms ease`,
opacity: disabled ? 0.55 : 1,
}}>
{children}
{/* Le clic reste le geste universel : le glisser-déposer est un
supplément de bureau, pas la seule porte d'entrée. */}
{ take(event.target.files); event.target.value = ''; }} />
);
}
function CreationDocuments({ patientId, patientLabel, runtime, chosen, onChosen }) {
const { Button, InlineAction, InteractiveRow } = window.VascoUI;
const [capabilities, setCapabilities] = React.useState(null);
const [capabilityError, setCapabilityError] = React.useState(null);
const [review, setReview] = React.useState(null);
const [choices, setChoices] = React.useState(null);
const [picking, setPicking] = React.useState(false);
const [busy, setBusy] = React.useState(null);
const [error, setError] = React.useState(null);
const confirmAttempt = React.useRef(null);
const importRuntime = runtime?.documentImport;
React.useEffect(() => {
setCapabilities(null); setCapabilityError(null); setReview(null);
setChoices(null); setPicking(false); setError(null);
if (!patientId || !importRuntime) return undefined;
let live = true;
importRuntime.capabilities(patientId)
.then((value) => { if (live) setCapabilities(value); })
.catch((failure) => {
if (live) setCapabilityError(contract.errorMessage(failure, 'Import indisponible'));
});
return () => { live = false; };
}, [patientId, importRuntime]);
// Le refus du serveur se lit tel quel, dans les mots de l'import.
const refusal = capabilityError || (capabilities && !capabilities.enabled
? ({
storage_unavailable: 'Stockage des originaux indisponible.',
scanner_unavailable: 'Contrôle de sécurité indisponible.',
})[capabilities.reasonCode] || 'Import indisponible.'
: null);
const takeFiles = async (files) => {
if (!files.length) return;
setError(null);
const stop = window.VascoDocumentImport.checkSelection([], files, capabilities);
if (stop) { setError(stop); return; }
try {
const remoteIds = [];
for (let index = 0; index < files.length; index += 1) {
setBusy(`Transfert ${index + 1}/${files.length}…`);
const attachment = await importRuntime.uploadSource(
files[index], importRuntime.newUploadAttempt(),
(part) => setBusy(part.stage === 'scanning' ? 'Contrôle de sécurité…' : `Transfert ${index + 1}/${files.length}…`),
);
remoteIds.push(attachment.id);
}
setBusy('Préparation du dossier…');
const created = await importRuntime.create(patientId, remoteIds, importRuntime.newAttempt());
// La clé de confirmation est fixée ici et gardée : une confirmation
// reprise après un échec doit rejouer la même commande, sinon le
// serveur ne reconnaît pas ce qu'il vient de recevoir.
confirmAttempt.current = importRuntime.newAttempt();
// Le titre est proposé depuis le nom du fichier, jamais imposé : la
// confirmation humaine du lot 17 reste entière, elle est seulement
// rendue ici plutôt que dans la modale plein écran.
setReview({ current: created, title: window.VascoDocumentImport.titleFromFileName(files[0].name) });
} catch (failure) {
setError(window.VascoDocumentImport.errorMessage(failure, 'Import impossible'));
} finally { setBusy(null); }
};
const confirmImport = async () => {
if (!review.title.trim()) { setError('Le titre est obligatoire.'); return; }
setBusy('Ajout au dossier…'); setError(null);
try {
const confirmed = await importRuntime.confirm(
patientId, review.current, { title: review.title, categoryId: null },
confirmAttempt.current,
);
// On relit le dossier pour retenir la pièce telle que le dossier la
// montre : même mise en forme que partout ailleurs, pas un objet
// fabriqué à la main qui divergerait.
let added = null;
try {
const page = await runtime.documents(patientId);
added = contract.documentPageItems(page).find((doc) => doc.id === confirmed.documentId) || null;
} catch (ignored) { added = null; }
onChosen([...chosen, added
? { ...added, patientId }
: { id: confirmed.documentId, label: review.title.trim(), type: 'Document', d: '–', patientId }]);
setReview(null);
} catch (failure) {
setError(window.VascoDocumentImport.errorMessage(failure, 'Ajout au dossier impossible'));
} finally { setBusy(null); }
};
const openPicker = async () => {
setBusy('Lecture du dossier…'); setError(null);
try {
setChoices(await runtime.documents(patientId));
setPicking(true);
} catch (failure) {
setError(contract.errorMessage(failure, 'Dossier illisible'));
} finally { setBusy(null); }
};
const label = (
);
// Sans patient retenu, il n'y a pas de dossier où importer : on le dit,
// plutôt que d'offrir un geste qui ne pourrait pas aboutir.
if (!patientId) {
return (
{label}
Choisissez d’abord un patient : les documents viennent de son dossier.
{busy || refusal || (!capabilities ? 'Vérification de la disponibilité…'
: `PDF ou image – il sera ajouté au dossier de ${patientLabel || 'ce patient'}.`)}
)}
{error &&
{error}
}
{/* En second, discrètement : une pièce déjà au dossier. */}
{!review && !picking && (
void openPicker()}
style={{ padding: '8px 2px 0', fontSize: 11.5, color: AG_SUB }}>
Choisir un document déjà au dossier
)}
{!review && picking && (
);
}
// Le marqueur d'urgence, tel qu'il se lit partout où un rendez-vous s'affiche.
function UrgentBadge({ compact = false }) {
return (
Urgent
);
}
// La feuille de création V5 est gelée : on ne la modifie pas, on rejoue
// l'arbre qu'elle rend. Quatre marques y sont reposées – la timeline à la
// place de la grille de pilules, l'atténuation des jours révolus du
// calendrier interne, l'horaire annoncé par le récapitulatif (début compris,
// puisque la timeline pose la minute), et le chevauchement rappelé jusqu'au
// dernier écran.
const CAL_GRID = 'repeat(7,1fr)';
const SLOT_GRID = 'repeat(3,1fr)';
const TYPE_GRID = 'repeat(2,1fr)';
const TIME_STEP_TITLE = "Choisir l'horaire";
// La grille de pilules de la feuille gelée, retrouvée dans le sous-arbre de
// l'étape avant qu'on ne la remplace : ses gestes servent encore à dire à la
// feuille qu'un créneau est retenu.
function findSlotPills(node) {
if (Array.isArray(node)) {
for (let index = 0; index < node.length; index += 1) {
const found = findSlotPills(node[index]);
if (found) return found;
}
return null;
}
if (!node || typeof node !== 'object' || !node.props) return null;
const style = node.props.style;
if (style && style.gridTemplateColumns === SLOT_GRID && style.gap === 6) {
return Array.isArray(node.props.children) ? node.props.children : null;
}
return findSlotPills(node.props.children);
}
// L'étape « Choisir l'horaire » se reconnaît à son titre : c'est le seul
// repère stable que la feuille gelée offre sur ce sous-arbre.
function isTimeStepRoot(node) {
const children = node.props.children;
if (!Array.isArray(children)) return false;
const title = children[0];
return Boolean(title && typeof title === 'object' && title.props
&& title.props.children === TIME_STEP_TITLE);
}
function RecapConflict({ conflicts }) {
return (
En parallèle de {conflicts[0].patient}{conflicts.length > 1 ? ` +${conflicts.length - 1}` : ''}
);
}
// La salle est une ressource réservable : elle se prend, comme le praticien.
// L'avertissement porte donc la même grammaire que le chevauchement praticien
// – même pastille, même ton, même place dans le récapitulatif – parce que
// c'est le même geste qu'on prévient. Ce qui change est la ressource, et le
// texte le dit.
//
// Il prévient, il ne refuse pas : le serveur garde le dernier mot, et il ne
// refuse pas encore un conflit de salle. Annoncer « impossible » ici serait
// promettre un refus qui n'existe pas.
function RecapRoomConflict({ message }) {
return (
{message}
);
}
// Le bloc « Motif » de la feuille gelée : son unique zone de texte part en
// `reason`. On le reconnaît à son étiquette pour poser la note juste dessous,
// sans toucher à l'arbre figé.
function isReasonBlock(node) {
if (node.type !== 'div' || node.props?.style?.marginBottom !== 12) return false;
const children = node.props.children;
const label = Array.isArray(children) ? children[0] : null;
return label?.type === 'label' && label.props?.children === 'Motif';
}
// Le premier texte lisible d'une ligne de résultat : le nom du patient, tel
// que la feuille gelée l'écrit. Sert à nommer le dossier dans lequel le
// document va être importé, rien de plus.
function firstText(node) {
if (typeof node === 'string') return node.trim() || null;
const children = node?.props?.children;
if (children === null || children === undefined) return null;
for (const child of Array.isArray(children) ? children : [children]) {
const found = firstText(child);
if (found) return found;
}
return null;
}
// Le bloc « Lieu » de la feuille gelée : cinq pastilles écrites en dur
// (« Salle 1 », « Bloc A », « Urgences »…) dont aucune n'a jamais existé côté
// serveur. On le reconnaît à son étiquette, comme le bloc « Motif ».
function isLocationBlock(node) {
if (node.type !== 'div' || node.props?.style?.marginBottom !== 12) return false;
const children = node.props.children;
const label = Array.isArray(children) ? children[0] : null;
return label?.type === 'label' && label.props?.children === 'Lieu';
}
// ═══════════════════════════════════════════════════════════════════════
// Les salles du service actif
//
// Les pastilles de la feuille gelée sont remplacées par les salles que le
// serveur sert pour le service actif de la session. Trois états, tous dits :
// les salles réelles ; « aucun service actif », avec sa raison ; et le lieu
// en texte libre, qui reste offert pour ce qui n'est pas une salle du groupe
// (un domicile, un autre établissement). Choisir une salle vide le texte
// libre et l'inverse : le serveur refuse les deux à la fois, parce que le
// libellé d'un rendez-vous à salle EST le nom de sa salle.
// ═══════════════════════════════════════════════════════════════════════
function RoomChoice({ rooms, reasonCode, roomId, onRoom, location, onOtherPlace }) {
const { ChoiceChip } = window.VascoUI;
const chip = (selected) => ({
padding: '7px 14px', borderRadius: 999,
background: selected ? AG_INK : AG_SURF,
color: selected ? '#fff' : AG_INK,
border: `1px solid ${selected ? AG_INK : AG_HAIR}`,
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
});
// Le lieu libre se choisit comme une salle, pas dans un champ posé à côté :
// c'est la même question – « où ? » – et elle mérite une seule rangée de
// réponses. La pastille porte le lieu retenu quand il y en a un, pour que
// le rendez-vous se relise sans rouvrir quoi que ce soit.
const other = (location || '').trim();
return
{reasonCode && (
Aucun service actif : aucune salle à proposer. Choisissez un service depuis votre compte.
;
}
// ═══════════════════════════════════════════════════════════════════════
// « Autre » : un lieu qui ne vaut que pour ce rendez-vous
//
// Les salles proposées sont celles du service actif. Un praticien qui n'a
// plus de bloc chez lui peut en emprunter un ailleurs le jour même – le bloc
// libre du service d'orthopédie. Ce lieu-là n'a pas à entrer dans le
// référentiel des salles : il ne vaudra qu'une fois, et une liste qui
// grossirait de tous les emprunts finirait par ne plus rien proposer d'utile.
//
// La feuille le dit en toutes lettres, parce que c'est exactement ce qu'on
// pourrait croire l'inverse : un nom qu'on saisit, d'ordinaire, on le
// retrouve. Celui-ci part avec le rendez-vous et nulle part ailleurs.
// ═══════════════════════════════════════════════════════════════════════
function OtherPlaceChoice({ open, value, onClose, onSubmit }) {
// La feuille officielle du design system, pas une surface de plus écrite
// ici : elle porte déjà le fond, le mouvement, la croix, la fermeture
// extérieure et la touche Échap. Elle se pose au-dessus de la feuille de
// rendez-vous, qui occupe la couche overlay, d'où la couche imbriquée.
const { Button, FormSheet, InlineAction, tokens } = window.VascoUI;
const [draft, setDraft] = React.useState(value || '');
// La primitive reste montée pour garder son mouvement de sortie : la saisie
// se recale sur le lieu retenu à chaque ouverture, et jamais pendant la
// fermeture – sinon le champ se viderait sous les yeux du praticien.
React.useEffect(() => { if (open) setDraft(value || ''); }, [open]);
const retained = (value || '').trim();
const trimmed = draft.trim();
const submit = (event) => {
event?.preventDefault?.();
if (!trimmed) return;
onSubmit(trimmed);
};
// La feuille de rendez-vous se pose déjà hors de l'écran, sur la scène ; y
// rester rendrait celle du lieu invisible, quelle que soit sa couche, parce
// que l'écran porte ses propres contextes d'empilement. Elle rejoint donc la
// même racine d'overlay, une couche au-dessus.
return ReactDOM.createPortal(
>}>
{/* Le formulaire ne sert qu'à la touche Entrée, que le champ unique
déclenche seul. Les deux boutons vivent dans le pied de la primitive,
hors du formulaire : un clic n'envoie donc jamais deux fois. */}
, document.getElementById('vasco-stage') || document.body);
}
// Les salles se lisent une fois par service actif : c'est une liste courte et
// stable, pas une recherche à chaque frappe.
function useRooms(runtime) {
const [state, setState] = React.useState({ items: [], reasonCode: null });
React.useEffect(() => {
let alive = true;
if (typeof runtime?.rooms !== 'function') return undefined;
runtime.rooms()
.then((result) => { if (alive) setState(result); })
// Une lecture de salles qui échoue ne doit pas empêcher de prendre un
// rendez-vous : le texte libre reste, et il reste vrai.
.catch(() => { if (alive) setState({ items: [], reasonCode: null }); });
return () => { alive = false; };
}, [runtime]);
return state;
}
function DecoratedCreationSheet({ mode, onMode, minutes, onMinutes, urgent, onUrgent,
day, onDay, start, onStart,
reminder, onReminder,
notes, onNotes, documents, onDocuments, patientChoice, onPatientChoice,
roomId, onRoom, location, onOtherPlace, rooms, roomsReason, roomAvailability,
createType, onCreateType, material, onMaterial, materialLead, onMaterialLead,
createdPatientId, onCreatePatient, onPatientArmed,
patientScope, onPatientScope, ...props }) {
// Les pilules d'horaire de la feuille gelée, capturées au rejeu : la
// timeline s'en sert pour lui signaler qu'un créneau est retenu.
const pillsRef = React.useRef(null);
// Les lignes de résultat de la recherche patient, capturées de la même
// façon : leur clic est le geste par lequel la feuille retient un dossier.
const rosterRef = React.useRef(null);
const armSlot = React.useCallback((minutesOfDay) => {
const pills = pillsRef.current;
if (!Array.isArray(pills)) return;
let best = null;
let closest = Infinity;
pills.forEach((pill) => {
if (!pill || typeof pill.props?.onClick !== 'function') return;
const label = pill.props.children;
const value = contract.minutesOfDay(Array.isArray(label) ? label[0] : label);
if (value === null) return;
const gap = Math.abs(value - minutesOfDay);
if (gap < closest) { closest = gap; best = pill; }
});
if (best) best.props.onClick();
}, []);
// La porte patient se referme sur l'arbre gelé avant toute décoration :
// sans saisie, le répertoire cède sa place à l'invitation à chercher. Une
// fois la recherche lancée, l'action de création se pose sous ses
// résultats, et ceux-ci sont relus pour pouvoir être rejoués.
rosterRef.current = null;
const tree = contract.decoratePatientStep(React, LegacyNewRdvSheet(props), {
hint: ,
// Décision de Sfar du 15/09/2026 : un rendez-vous peut viser un dossier
// décédé, pour ce qu'il reste à faire à son sujet. Les deux populations
// ne se mélangent pas dans une liste : l'onglet dit laquelle on cherche,
// et c'est la barre officielle du socle, celle de `SearchSelect`.
tabs: AgendaScopeTabs ? : null,
footer: (query, armed) => (
),
onRoster: (entries) => { rosterRef.current = entries; },
});
// Le dossier qui vient de naître est le seul résultat que la recherche
// rend pendant ce temps : son bouton est celui de la feuille gelée, et son
// clic pose le patient retenu. On ne fait que jouer son geste pour elle,
// comme la timeline arme la pilule d'horaire la plus proche.
React.useEffect(() => {
if (!createdPatientId) return;
const entry = contract.patientRosterEntry(rosterRef.current, createdPatientId);
if (!entry) return;
entry.props.onClick();
onPatientArmed();
});
const todayKey = agendaTodayKey();
// Les rendez-vous de la journée sont ceux que l'écran connaît déjà : aucune
// lecture serveur de plus. Hors connexion, la maquette montre les siens,
// comme la feuille gelée le fait pour ses pastilles d'occupation.
const rdvs = sheetRdvs(props);
const posed = Number.isFinite(start);
const conflicts = posed
? contract.overlappingEvents(contract.dayTimelineEvents(rdvs, day), start, start + minutes)
: [];
// La salle prise : même fenêtre, autre ressource. L'occupation vient du
// serveur – c'est la seule chose que l'écran ne peut pas déduire de ce
// qu'il a déjà, une salle étant occupée par n'importe quel praticien.
const roomTaken = posed
? contract.roomConflictsAt(roomAvailability, day, roomId, start, minutes)
: [];
const roomTakenMessage = contract.roomConflictMessage(
roomTaken,
(roomAvailability || []).find((room) => String(room.id) === String(roomId))?.name || null,
);
const startLabel = posed ? contract.minuteLabel(start) : null;
// Le titre du mois précède sa grille dans l'arbre : il est lu avant d'en
// avoir besoin.
let shown = null;
const visit = (node) => {
const style = node.props.style;
if (!shown && node.type === 'span') {
shown = contract.monthTitleParts(node.props.children, MOIS);
}
// La bascule « Rendez-vous / Congé » se pose juste au-dessus des trois
// traits d’étape, entre le titre et la première question : c’est le
// premier choix de la feuille, avant même la date.
if (style && style.gap === 6 && style.padding === '0 20px 16px' && onMode) {
return (
{/* La feuille gelée pose ses marges elle-même : la bascule reprend
les siennes plutôt que d’en inventer. */}
{node}
);
}
if (style && style.gridTemplateColumns === CAL_GRID && style.gap === 2) {
if (!shown) return node;
const keys = contract.monthGridDayKeys(shown.year, shown.month);
const cells = node.props.children.map((child, index) => {
const key = keys[index];
if (!key || !child?.props?.onClick) return child;
return React.cloneElement(child, {
onClick: (event) => { onDay(key); child.props.onClick(event); },
});
});
return React.cloneElement(node, {}, ...contract.markUnavailableCells(
cells, (child, index) => contract.isPastDayKey(keys[index], todayKey),
React.cloneElement,
));
}
// L'étape entière cède la place à la timeline : les pilules d'horaire ne
// sont plus rendues, seulement relues pour piloter la feuille gelée.
if (isTimeStepRoot(node)) {
pillsRef.current = findSlotPills(node);
return (
);
}
// La grille des types garde les deux types encore créables ; l'urgence,
// qui n'en est plus un, se pose juste en dessous.
if (style && style.gridTemplateColumns === TYPE_GRID && style.gap === 6) {
const kept = contract.keepCreationTypeChips(node.props.children);
const aligned = kept.map((chip, index) => {
const id = contract.CREATION_TYPES[index]?.id;
const restyled = restyleCreationTypeChip(chip, id);
// Le type reste la propriété de la feuille gelée : on ne le lui
// reprend pas, on écoute son clic pour savoir ce qu'elle vient de
// choisir. C'est ce qui décide si le matériel a lieu d'être, quelques
// champs plus bas – le même procédé que le jour du calendrier.
if (!id || typeof restyled.props.onClick !== 'function') return restyled;
return React.cloneElement(restyled, {
onClick: (event) => { onCreateType(id); restyled.props.onClick(event); },
});
});
return (
{React.cloneElement(node, {}, ...aligned)}
onUrgent(!urgent)} />
);
}
// Le motif reste ce qu'il est – une ligne courte. La note du praticien se
// saisit juste en dessous, dans la même étape et sans la surcharger.
if (isReasonBlock(node)) {
return (
{node}
{/* Le matériel vient juste sous le motif, parce qu'il répond à la
même question posée autrement : « qu'est-ce qu'on fait, et avec
quoi ? ». Une consultation ne voit rien du tout. */}
{createType === 'intervention' && (
)}
{/* Les pièces viennent juste après la note : c'est le même geste de
préparation de la consultation, et le dossier du patient déjà
retenu quelques champs plus haut en est la seule source. */}
{/* Le rappel ferme l'étape : c'est la dernière chose qu'on décide
pour un rendez-vous, et la seule qui sorte de l'application. */}
);
}
// La ligne de résultat que le praticien touche est celle qui retient le
// dossier. On écoute son geste au passage – sans le remplacer – pour
// savoir dans quel dossier importer : la feuille gelée garde ce choix
// pour elle, et rien ne le demande deux fois au praticien.
if (Array.isArray(rosterRef.current) && rosterRef.current.includes(node)) {
return React.cloneElement(node, {
onClick: (event) => {
onPatientChoice({ id: String(node.key), label: firstText(node) });
node.props.onClick(event);
},
});
}
// Les pastilles écrites en dur cèdent la place aux salles réelles du
// service actif : le sous-arbre entier est remplacé, il ne reste rien du
// « Salle 1 / Bloc A / Urgences » qui ne désignait rien.
if (isLocationBlock(node)) {
return (
);
}
// Le récapitulatif : sa ligne de chevauchement est refaite sur l'horaire
// réel, pas sur la pilule que la feuille gelée croit retenue.
if (style && style.background === 'rgba(10,10,10,0.03)' && style.borderRadius === 16) {
const inner = contract.mapElementTree(node.props.children, visit, React.cloneElement);
const kept = Array.isArray(inner) ? inner : [inner];
return React.cloneElement(node, {}, ...kept,
conflicts.length ? : null,
roomTakenMessage ? : null);
}
if (style && style.gap === 7 && style.marginTop === 8 && style.borderTop) return null;
// L'écran de réussite annonce l'heure envoyée, pas celle de la pilule.
const kids = node.props.children;
if (startLabel && Array.isArray(kids) && kids.length === 5
&& kids[1] === ' · ' && kids[3] === ' à ') {
return React.cloneElement(node, {}, kids[0], kids[1], kids[2], kids[3], startLabel);
}
const retimed = typeof kids === 'string'
? contract.retimeSlotRange(kids, minutes, startLabel) : null;
return retimed ? React.cloneElement(node, {}, retimed) : node;
};
return contract.mapElementTree(tree, visit, React.cloneElement);
}
// ═══════════════════════════════════════════════════════════════════════
// Les congés du praticien
//
// Un praticien pose ses vacances trois fois par an et un rendez-vous dix
// fois par jour : le « + » garde donc son sens, et le choix se fait DANS la
// feuille, en haut, comme les bascules que l’agenda emploie déjà. Le geste
// quotidien ne paie pas un tap de plus, et la première question – quelle
// date – est de toute façon la même pour les deux.
//
// Un congé se lit en GRIS HACHURÉ, la même encre que les heures révolues :
// « rien à faire ici » se dit d’une seule façon dans cet agenda. Jamais en
// rouge, qui est réservé au danger – partir en vacances n’en est pas un.
//
// Décision de Sfar du 20/09/2026 : un congé est un objet du praticien,
// visible sur son agenda comme ses rendez-vous, donc lisible par son service
// comme le reste de l’agenda. Les rendez-vous déjà posés sur la période
// restent en place et sont annoncés, jamais annulés d’office.
// ═══════════════════════════════════════════════════════════════════════
// Les rendez-vous que la feuille connaît : ceux que l’écran lui passe, sinon
// ceux que la maquette tient déjà. C’est la source de l’étape horaire, et
// c’est celle que le congé relit pour annoncer ce qui est déjà posé.
function sheetRdvs(props) {
return props.rdvs || (typeof AGENDA_DATA !== 'undefined' ? AGENDA_DATA : []);
}
const AG_ABSENCE_TINT = 'rgba(10,10,10,0.06)';
const AG_ABSENCE_HATCH = 'repeating-linear-gradient(135deg, rgba(10,10,10,0.05) 0 5px, rgba(10,10,10,0) 5px 11px)';
// Les congés affichés suivent le périmètre choisi, exactement comme les
// rendez-vous : « Mon agenda » ne grise pas la journée d’un confrère.
function visibleAbsences(state) {
const all = Array.isArray(state?.absences) ? state.absences : [];
return state?.scope === 'team' ? all : all.filter((absence) => absence.owned);
}
// La coque porte le toast ; les harnais unitaires montent cet écran sans
// elle. Une réussite ne doit pas dépendre de la présence de la coque.
function agendaToast(message, tone) {
if (typeof showToast === 'function') showToast(message, tone);
}
// Une date se lit en français, pas en clé de serveur : « jeu. 10 sept. » et
// non « 2026-09-10 ». La forme courte suffit sur une ligne de liste.
function absenceDayLabel(dayKey) {
try {
return new Intl.DateTimeFormat('fr-FR', {
weekday: 'short', day: 'numeric', month: 'short',
}).format(new Date(`${dayKey}T12:00:00`));
} catch (failure) { return String(dayKey); }
}
function absencePeriodLabel(absence) {
if (!absence) return '';
return absence.startsOn === absence.endsOn
? `le ${absenceDayLabel(absence.startsOn)}`
: `du ${absenceDayLabel(absence.startsOn)} au ${absenceDayLabel(absence.endsOn)}`;
}
function absenceDaysLabel(total) {
if (total <= 0) return null;
return total === 1
? 'Une journée entière. Touchez un second jour pour prolonger.'
: `${total} journées entières.`;
}
// Le choix se fait sur deux pastilles de même largeur, dans le gabarit des
// bascules de l’agenda : le rendez-vous d’abord, parce que c’est lui qu’on
// vient faire neuf fois sur dix.
function CreationModeChoice({ mode, onMode }) {
const { ChoiceChip } = window.VascoUI;
const option = (value, label) => (
onMode(value)} style={{
...buttonStyle, flex: '1 1 0', minHeight: 38,
}}>{label}
);
return (
);
}
// Ce qui est déjà posé sur la période. L’écran ne supprime rien et ne cache
// rien : il le dit avant, pendant qu’on peut encore changer d’avis.
function AbsenceConflictNotice({ conflicts }) {
if (!conflicts.length) return null;
return (
{conflicts.length === 1
? '1 rendez-vous est posé sur cette période. Il reste en place.'
: `${conflicts.length} rendez-vous sont posés sur cette période. Ils restent en place.`}
);
}
// La période et le motif : les deux seules questions d’un congé. Le
// calendrier est celui du design system, en mode période – un tap pose le
// début, un second la fin, et toucher avant le début rouvre une période au
// lieu de la retourner.
function AbsenceFields({ startsOn, endsOn, onRange, reason, onReason, minKey, idPrefix }) {
const { Field, DayPicker } = window.VascoUI;
const total = contract.absenceDays({ startsOn, endsOn });
return (
<>
onReason(event.target.value)} style={fieldStyle} />
>
);
}
function absenceError(failure) {
return contract.errorMessage(failure, 'Congé indisponible');
}
// Le formulaire du congé, dans la feuille du « + ». Il écrit réellement :
// « Poser le congé » appelle le serveur, et la journée se hachure aussitôt.
function AbsenceForm({ day, rdvs, runtime, onClose, onPosed }) {
const { Button, StatusNotice } = window.VascoUI;
const todayKey = agendaTodayKey();
const first = day && day >= todayKey ? day : todayKey;
const [startsOn, setStartsOn] = React.useState(first);
const [endsOn, setEndsOn] = React.useState(first);
const [reason, setReason] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
const attempt = React.useRef(runtime?.absences?.newAttempt?.() || {});
const draft = { startsOn, endsOn, reason };
const errors = contract.absenceErrors(draft);
const conflicts = contract.absenceConflicts(rdvs, draft);
const unavailable = runtime?.absences ? null : 'Congés indisponibles sur cette session';
const submit = async () => {
if (busy || unavailable) return;
setBusy(true); setError(null);
try {
const result = await runtime.absences.create(draft, attempt.current);
agendaToast('Congé posé', 'success');
onPosed(result);
} catch (failure) {
// La clé de la tentative est conservée : réessayer ne pose pas un
// second congé, le serveur rejoue le premier.
setError(absenceError(failure));
setBusy(false);
}
};
return (
);
}
// La fiche d’un congé posé : on y corrige ses dates et son motif, on l’y
// retire. Le retrait passe par la confirmation officielle – une journée
// rouverte par mégarde est un rendez-vous accepté par mégarde.
function AbsenceDetail({ absence, rdvs, runtime, onClose, onChanged }) {
const { Button, FormSheet, ConfirmDialog, StatusNotice } = window.VascoUI;
const todayKey = agendaTodayKey();
const [startsOn, setStartsOn] = React.useState(absence.startsOn);
const [endsOn, setEndsOn] = React.useState(absence.endsOn);
const [reason, setReason] = React.useState(absence.reason || '');
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
const [confirming, setConfirming] = React.useState(false);
const updateAttempt = React.useRef(runtime?.absences?.newAttempt?.() || {});
const removeAttempt = React.useRef(runtime?.absences?.newAttempt?.() || {});
const draft = { startsOn, endsOn, reason };
const errors = contract.absenceErrors(draft);
const conflicts = contract.absenceConflicts(rdvs, draft);
const changed = Object.keys(contract.absenceChanges(draft, absence)).length > 0;
const run = async (action, message) => {
if (busy) return;
setBusy(true); setError(null);
try {
await action();
agendaToast(message, 'success');
onChanged();
} catch (failure) {
setError(absenceError(failure));
setBusy(false);
setConfirming(false);
}
};
return (
<>
>}>
{ setReason(value); setError(null); }}
onRange={(from, to) => { setStartsOn(from); setEndsOn(to); setError(null); }} />
{error && (
)}
setConfirming(false)}
onConfirm={() => run(
() => runtime.absences.remove(absence, removeAttempt.current),
'Congé retiré',
)} />
>
);
}
// La bande de la journée : le congé se pose sur toute la hauteur de l’axe,
// sous les rendez-vous qui restent lisibles par-dessus.
function DayAbsenceBand({ absences }) {
if (!absences.length) return null;
return (
);
}
// Et son libellé se lit sans défiler, là où le résumé du jour se lit déjà :
// l’axe fait vingt-quatre heures, une étiquette posée dessus serait hors de
// vue au premier mouvement. Le sien ouvre sa fiche – c’est de là qu’on
// corrige ou retire le congé.
function DayAbsenceLabel({ absences, onOpen }) {
if (!absences.length) return null;
const { Button } = window.VascoUI;
const absence = absences[0];
const body = (
CONGÉ
{absence.reason && (
· {absence.reason}
)}
{!absence.owned && absence.practitionerName && (
· {absence.practitionerName}
)}
);
const shell = { padding: '8px 12px', borderRadius: 12, background: AG_ABSENCE_TINT };
return (
{absence.owned ? (
) : (
{body}
)}
);
}
// L'étape patient de la feuille V5 s'ouvre maintenant sur son seul champ de
// recherche : le répertoire ne se déroule qu'une fois la recherche lancée.
function PatientSearchHint() {
return
Tapez pour chercher un patient : nom, prénom ou IPP.
;
}
// ═══════════════════════════════════════════════════════════════════════
// Créer le dossier sans quitter la prise de rendez-vous
//
// Quand la recherche ne rend rien – ou pas le bon dossier –, le praticien
// n'a plus à sortir du parcours. Il crée le patient ici, réellement :
// `POST /api/v1/patients`, le numéro de dossier est attribué par le serveur,
// la clé de reprise rend une coupure réseau inoffensive, et l'homonymie est
// annoncée avant d'être confirmée.
//
// Le dossier créé est ensuite donné à la feuille gelée par le seul geste
// qu'elle connaisse : le clic sur sa propre ligne de résultat. Le temps
// qu'elle le retienne, la recherche n'épingle que lui.
// ═══════════════════════════════════════════════════════════════════════
const AG_DANGER = (window.VT && window.VT.danger) || '#b91c1c';
const newPatientAttempt = () => contract.newAttempt(
() => window.crypto?.randomUUID?.() || `vasco-patient-${Date.now()}`,
);
function PatientCreateField({ id, label, required = false, full = false, error, hint, children }) {
const describedBy = [hint ? `${id}-hint` : null, error ? `${id}-error` : null].filter(Boolean).join(' ');
return (
);
}
function PatientCreateSheet({ query, runtime, onClose, onCreated }) {
const { Button } = window.VascoUI;
const spec = window.VascoAgendaPatientCreate;
// Ne jamais resynchroniser cet état sur la recherche : une frappe de plus
// dans le champ ne doit pas effacer la saisie déjà faite ici.
const [form, setForm] = React.useState(() => spec.initialForm(query));
const [fieldErrors, setFieldErrors] = React.useState({});
const [error, setError] = React.useState(null);
const [duplicate, setDuplicate] = React.useState(false);
const [saving, setSaving] = React.useState(false);
// Une coupure réseau rejoue exactement la même création : le serveur rend
// alors le dossier déjà né, jamais un second. Toute correction de la
// saisie ouvre une autre clé – ce n'est plus la même création.
const [attempt, setAttempt] = React.useState(newPatientAttempt);
const set = (key, value) => {
setForm((current) => ({ ...current, [key]: value }));
setAttempt(newPatientAttempt());
setDuplicate(false);
setError(null);
setFieldErrors((current) => {
if (!current[key]) return current;
const next = { ...current };
delete next[key];
return next;
});
};
const submit = async (event, confirmPotentialDuplicate = false) => {
event?.preventDefault?.();
if (saving) return;
const analysis = spec.buildDraft(form);
if (!analysis.body) {
setFieldErrors(analysis.errors);
setError('Le dossier ne peut pas être créé : corrigez les champs indiqués.');
return;
}
setSaving(true);
setFieldErrors({});
setError(null);
try {
// La réussite ne s'annonce pas en bas de l'écran : le dossier créé
// devient le patient retenu du rendez-vous, avec son nom et son IPP.
// C'est l'écran qui change qui le dit (`docs/product/ACTION_FEEDBACK.md`).
onCreated(await runtime.createPatient(analysis.body, attempt, { confirmPotentialDuplicate }));
} catch (failure) {
setDuplicate(spec.isDuplicateRefusal(failure));
setError(spec.createErrorMessage(failure, contract.errorMessage));
setSaving(false);
}
};
const input = (key, props = {}) => (
set(key, event.target.value)} style={fieldStyle} />
);
return sheet(
,
saving ? undefined : onClose,
'Nouveau dossier patient',
);
}
// L'action posée sous les résultats de la feuille gelée. Elle n'apparaît
// qu'une fois la recherche lancée : c'est devant une liste vide – ou sans le
// bon dossier – que créer a un sens. La feuille de saisie, elle, s'ouvre au
// niveau de la prise de rendez-vous et non dans l'arbre gelé : imbriquée,
// elle se poserait dans le panneau au lieu de le recouvrir.
function PatientCreateAction({ query, armed, runtime, onOpen }) {
const { Button } = window.VascoUI;
// Sans serveur, la feuille filtre les patients de démonstration sur place :
// il n'y a rien à créer, et rien à faire semblant de créer.
if (!runtime || !armed) return null;
if (typeof runtime.createPatient !== 'function') {
return (
Création de dossier indisponible : le serveur ne l’expose pas.
);
}
return (
);
}
// Le « + » de l'Agenda ouvre la prise de rendez-vous, sans écran de choix
// préalable : la maquette ne crée plus de Staff / RCP. Les événements de ce
// type déjà posés par un autre client restent lus et modifiés normalement.
function EnhancedNewRdvSheet(props) {
// Le brouillon horaire vit ici, entier : le jour, la minute de début et la
// durée. La feuille gelée n'en connaît qu'une approximation – la pilule la
// plus proche –, mais c'est ce brouillon-ci qui part au serveur.
const [minutes, setMinutes] = React.useState(contract.DEFAULT_DURATION_MINUTES);
// L'urgence est un attribut du rendez-vous : elle vit à côté du type, et
// part avec lui.
const [urgent, setUrgent] = React.useState(false);
// La note du praticien : la feuille gelée n'en a pas, son unique zone de
// texte étant le motif. Elle vit donc ici, comme l'urgence et la durée.
const [notes, setNotes] = React.useState('');
// Le type choisi dans la feuille gelée, recopié ici au clic. La feuille
// reste maîtresse du sien – celui-ci ne sert qu'à savoir s'il faut poser la
// question du matériel. Son défaut est celui de la feuille : consultation.
const [createType, setCreateType] = React.useState(contract.CREATION_TYPES[0].id);
// Le matériel de l'intervention et le délai du rappel qu'il déclenche.
// Le matériel partira dans la note ; le délai, lui, ne part pas au serveur
// avec le rendez-vous : il devient un rappel, une fois le rendez-vous né.
const [material, setMaterial] = React.useState('');
const [materialLead, setMaterialLead] = React.useState(null);
// Le lieu : soit une salle réelle du service actif, soit un texte libre.
// Jamais les deux – choisir l'un efface l'autre, comme le serveur l'exige.
// `seed` vient de la vue des disponibilités : un creux touché y désigne
// déjà le jour, l'heure et la salle. La feuille s'ouvre donc pré-réglée, et
// le praticien n'a plus qu'à dire pour qui – sans repasser par des choix
// qu'il vient de faire à l'écran d'à côté.
const [roomId, setRoomId] = React.useState(() => props.seed?.roomId ?? null);
const [location, setLocation] = React.useState('');
// Ce que la feuille pose : un rendez-vous, ou un congé. Le rendez-vous est
// le choix ouvert, c’est lui qu’on vient faire neuf fois sur dix.
const [mode, setMode] = React.useState('rdv');
// Le congé se lit sur l’écran Agenda, pas dans le dossier patient : c’est
// là que la liste des congés est tenue, et c’est elle qu’il faut relire.
const agendaState = React.useContext(ScopeContext);
// La feuille du lieu libre vit ici, à côté de la feuille de rendez-vous et
// jamais dans l'arbre gelé : imbriquée, elle se poserait dans le panneau au
// lieu de le recouvrir – la même raison que la création de dossier.
const [otherPlace, setOtherPlace] = React.useState(false);
const chooseRoom = React.useCallback((value) => { setRoomId(value); setLocation(''); }, []);
const chooseLocation = React.useCallback((value) => {
setLocation(value);
if (value.trim()) setRoomId(null);
}, []);
const roomList = useRooms(props.runtime);
const [day, setDay] = React.useState(() => props.seed?.dayKey || fmtDate(props.date));
// L'occupation des salles du jour choisi : c'est elle qui arme
// l'avertissement « salle déjà prise ». Elle se relit quand le jour change ;
// hors connexion elle reste vide, et l'écran ne prévient de rien plutôt que
// de prévenir à tort.
const dayOccupancy = useRoomAvailability(props.runtime, day);
const [start, setStart] = React.useState(
() => (Number.isFinite(props.seed?.startMinutes) ? props.seed.startMinutes : null),
);
// Changer de jour rouvre la question de l'horaire : la timeline reposera le
// bloc sur la première plage libre du nouveau jour.
const chooseDay = React.useCallback((key) => { setDay(key); setStart(null); }, []);
// Le dossier tout juste créé, épinglé le temps que la feuille gelée le
// retienne : la recherche ne rend que lui, la feuille en fait sa seule
// ligne de résultat, et le clic joué à sa place l'inscrit au rendez-vous.
// Une fois retenu, l'épingle tombe – la recherche redevient la recherche.
const [created, setCreated] = React.useState(null);
// La feuille de saisie du nouveau dossier vit ici, à côté de la feuille de
// rendez-vous, jamais dans l'arbre gelé.
const [creating, setCreating] = React.useState(null);
// Les pièces retenues avant que le rendez-vous existe, et le dossier dans
// lequel elles ont été importées : le lien serveur porte le patient, une
// pièce d'un autre dossier serait refusée en base.
const [documents, setDocuments] = React.useState([]);
// Le rappel voulu pour ce rendez-vous. `null` tant qu'aucun dossier n'est
// retenu : rien ne part alors au serveur, qui applique son propre défaut
// plutôt que de recevoir un refus que personne n'a exprimé.
const [reminder, setReminder] = React.useState(null);
const [patientChoice, setPatientChoice] = React.useState(null);
// La population cherchée. « Vivants » à l'ouverture : viser un dossier
// décédé est un geste délibéré, jamais un défaut.
const [patientScope, setPatientScope] = React.useState('living');
// Le rendez-vous déjà obtenu du serveur. La feuille gelée tire une clé
// d'idempotence NEUVE à chaque reprise : sans cette mémoire, un second
// envoi après une attache ratée créerait un second rendez-vous.
const createdAppointment = React.useRef(null);
// Le rappel de matériel déjà posé. Une reprise après une attache ratée
// rejoue l'envoi entier : sans cette mémoire, le praticien se retrouverait
// avec deux rappels pour un seul rendez-vous.
const postedMaterialReminder = React.useRef(false);
// Le rappel de matériel ne peut naître qu'après le rendez-vous, comme les
// pièces : il porte son patient et sa date. S'il échoue, le rendez-vous
// reste créé et on le dit – on ne prétend jamais qu'un rappel est posé.
const postMaterialReminder = async (body, appointment) => {
if (postedMaterialReminder.current) return null;
// Le type retenu est celui qui part au serveur, pas la copie que l'écran
// garde pour décider de l'affichage : un rappel de matériel ne doit pas
// survivre à un retour vers la consultation.
const draft = contract.materialReminderDraft({
material, leadDays: materialLead, type: body.type,
startsAtLocal: body.startsAtLocal, patientId: body.patientId,
appointmentId: appointment?.id || null,
});
if (!draft) return null;
const create = props.reminderRuntime?.create
|| (typeof window !== 'undefined' ? window.VascoApi?.dashboard?.reminders?.create : null);
if (typeof create !== 'function') {
return 'Le rendez-vous est créé. Le rappel de matériel n’a pas été posé : les rappels sont indisponibles.';
}
try {
await create(draft);
postedMaterialReminder.current = true;
return null;
} catch (error) {
return `Le rendez-vous est créé. Le rappel de matériel n’a pas été posé : ${contract.errorMessage(error, 'le serveur l’a refusé')}.`;
}
};
// L'envoi, en trois temps : le rendez-vous d'abord – rien ne peut naître
// avant lui –, les pièces ensuite, le rappel de matériel enfin. Un échec
// en aval ne défait pas la création : il se dit, et la reprise ne rejoue
// que ce qui n'est pas passé.
const send = async (body, attempt) => {
const result = await contract.createWithDocuments({
create: props.runtime.create, attach: props.runtime.attachDocument,
body, attempt, documents, already: createdAppointment.current,
});
createdAppointment.current = result.appointment;
// Ne restent retenues que les pièces encore à attacher : une reprise ne
// rejoue pas celles qui sont déjà passées.
if (result.pending.length !== documents.length) setDocuments(result.pending);
const reminderMessage = await postMaterialReminder(body, result.appointment);
const message = [result.message, reminderMessage].filter(Boolean).join(' ');
if (message) {
throw Object.assign(new Error(message), { code: 'appointment_documents_partial' });
}
};
const wrappedRuntime = props.runtime ? {
...props.runtime,
// Rien n'a été demandé : on n'interroge pas le serveur pour rien.
...(props.runtime.patientOptions ? {
patientOptions: (term) => {
if (created) return Promise.resolve([created]);
return contract.patientSearchArmed(term)
? props.runtime.patientOptions(term, { clinicalState: patientScope })
: Promise.resolve([]);
},
} : {}),
create: (draft, attempt) => {
// La feuille gelée fabrique un début calé sur sa pilule et une fin à
// trente minutes : c'est le bloc posé sur la timeline qui décide.
const startsAtLocal = Number.isFinite(start)
? `${day}T${contract.minuteLabel(start)}` : draft.startsAtLocal;
const body = {
...draft,
urgent,
notes: contract.appointmentNotes(notes),
// Le matériel part sous son nom, dans sa colonne. Le type qui décide
// est celui de la feuille gelée, pas la copie que l'écran garde pour
// afficher : si elle dit consultation, aucun matériel ne suit.
material: draft.type === 'intervention' ? material : '',
// Le lieu de la feuille gelée est écarté : il vient de pastilles qui
// ne désignaient rien. C'est la salle choisie – ou le texte libre –
// qui part, et les deux ne partent jamais ensemble.
roomId,
location: roomId ? null : (location.trim() || null),
reminder,
startsAtLocal,
endsAtLocal: contract.endFromDuration(startsAtLocal, minutes) || draft.endsAtLocal,
};
return send(body, attempt);
},
} : null;
// Le congé remplace le corps de la feuille, pas la feuille : même titre,
// même fermeture, même bascule en haut. Changer d’avis ne coûte qu’un tap
// et ne perd rien de l’autre côté – les deux états vivent ici.
if (mode === 'absence') {
return sheet(<>
{
props.onClose();
if (agendaState?.reloadAbsences) agendaState.reloadAbsences();
}} />
>, props.onClose, 'Nouveau');
}
return <>
setOtherPlace(true)}
rooms={roomList.items} roomsReason={roomList.reasonCode}
roomAvailability={dayOccupancy.status === 'ready' ? dayOccupancy.data.rooms : null}
reminder={reminder} onReminder={setReminder}
day={day} onDay={chooseDay} start={start} onStart={setStart}
documents={documents} onDocuments={setDocuments}
patientChoice={patientChoice} onPatientChoice={setPatientChoice}
patientScope={patientScope} onPatientScope={setPatientScope}
createdPatientId={created ? created.id : null}
onCreatePatient={(query) => setCreating({ query })}
onPatientArmed={() => setCreated(null)} />
{creating && setCreating(null)}
onCreated={(patient) => { setCreating(null); setCreated(patient); }} />}
setOtherPlace(false)}
onSubmit={(place) => { chooseLocation(place); setOtherPlace(false); }} />
>;
}
// ═══════════════════════════════════════════════════════════════════════
// La fiche d'un rendez-vous existant – le même langage que la création
//
// Question de Sfar : « pourquoi l'affichage d'un rendez-vous déjà créé n'est
// pas équivalent à un rendez-vous créé ? » Il ne l'était pas : la création
// montre la journée à l'échelle et pose le bloc au doigt, la fiche montrait
// deux `datetime-local` et des pilules de durée. Deux langages pour un seul
// objet, et le second ne disait ni les voisins, ni le chevauchement.
//
// La fiche reprend donc le cadre de la création – `DayTimeline`, le même –
// avec le rendez-vous ouvert en bloc d'accent, ses voisins autour, et le
// chevauchement visible avant l'envoi. Ce qui ne change pas : la mécanique
// serveur (modification versionnée et idempotente, capacités, annulation,
// décision de staff) et les refus honnêtes, qui figent la timeline au lieu
// de la retirer.
// ═══════════════════════════════════════════════════════════════════════
function GeneralDetailSheet({ rdv, runtime, accent, onClose, onChanged }) {
const { Button } = window.VascoUI;
const [reason, setReason] = React.useState(contract.appointmentReason(rdv));
// `rdv.notes` est le motif sous l'arbre gelé : la vraie note du praticien
// arrive sous `practitionerNotes`.
const [notes, setNotes] = React.useState(rdv.practitionerNotes || '');
// Le matériel du bloc, relu de sa colonne. Il n'existe que pour une
// intervention : c'est le serveur qui tient cette règle, l'écran la suit.
const [material, setMaterial] = React.useState(rdv.material || '');
// Une salle n'est pas du texte libre : l'identifiant lu du serveur reste
// sélectionné, et son nom ne remplit jamais le champ « Autre lieu ».
const [roomId, setRoomId] = React.useState(rdv.roomId ?? null);
const [location, setLocation] = React.useState(
rdv.roomId == null && rdv.lieu !== 'Lieu non renseigné' ? rdv.lieu : '',
);
// La feuille du lieu libre se pose par-dessus la fiche, comme à la création.
const [otherPlace, setOtherPlace] = React.useState(false);
const chooseRoom = React.useCallback((value) => { setRoomId(value); setLocation(''); }, []);
const chooseLocation = React.useCallback((value) => {
setLocation(value);
if (value.trim()) setRoomId(null);
}, []);
const roomList = useRooms(runtime);
// L'horaire vit dans le vocabulaire de la timeline – un jour, une minute,
// une durée – et se retraduit en horodatages au moment de l'envoi.
const schedule = contract.appointmentSchedule(rdv);
const [day, setDay] = React.useState(schedule ? schedule.dayKey : rdv.date);
const [startMinutes, setStartMinutes] = React.useState(schedule ? schedule.startMinutes : null);
const [minutes, setMinutes] = React.useState(
schedule ? schedule.minutes : contract.DEFAULT_DURATION_MINUTES,
);
const [type, setType] = React.useState(rdv.type);
const [urgent, setUrgent] = React.useState(rdv.urgent === true);
const [decision, setDecision] = React.useState(rdv.staffRcpDecision || '');
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
const [saved, setSaved] = React.useState(rdv);
const [cancelMode, setCancelMode] = React.useState(false);
const [cancelReason, setCancelReason] = React.useState('');
const [overlap, setOverlap] = React.useState(null);
// Ouvrir un rendez-vous sert d'abord à le lire. L'éditeur complet – utile
// mais dense – ne se monte qu'après une intention explicite.
const [editing, setEditing] = React.useState(false);
const attempt = React.useRef({ idempotencyKey: window.crypto?.randomUUID?.() || `vasco-agenda-${Date.now()}` });
const isStaff = rdv.type === 'staff_rcp';
const sendChanges = async (changes, retry = attempt.current) => {
setBusy(true); setError(null);
try {
const result = await runtime.update(saved, changes, retry);
setSaved(result.appointment);
setOverlap(null);
attempt.current = { idempotencyKey: window.crypto?.randomUUID?.() || `vasco-agenda-${Date.now()}` };
await onChanged?.();
onClose();
} catch (failure) {
if (failure?.code === 'appointment_overlap' && failure.detail?.confirmation_token) {
setOverlap({ changes, retry: { ...retry, confirmationToken: failure.detail.confirmation_token } });
} else if (failure?.status === 409) {
try {
const fresh = (await runtime.reload()).find((item) => item.id === rdv.id);
if (fresh) {
setSaved(fresh);
attempt.current = { idempotencyKey: window.crypto?.randomUUID?.() || `vasco-agenda-${Date.now()}` };
setError('Cet événement a changé ailleurs. Données relues ; votre saisie est conservée, réessayez.');
} else setError('Cet événement n’existe plus.');
} catch (reloadFailure) { setError(contract.errorMessage(reloadFailure, 'Relecture impossible')); }
} else {
setError(contract.errorMessage(failure, 'Modification impossible'));
if (failure?.code !== 'network_error' && failure?.code !== 'request_timeout') {
attempt.current = { idempotencyKey: window.crypto?.randomUUID?.() || `vasco-agenda-${Date.now()}` };
}
}
} finally { setBusy(false); }
};
const updateCapability = saved.capabilities?.update;
const canUpdate = !updateCapability || updateCapability.enabled !== false;
// Le serveur exige un début à venir dès qu'on touche au bloc temporel
// (`appointment_in_past`) : un rendez-vous déjà commencé ne se déplace donc
// plus. Le reste de la fiche, lui, reste modifiable – et c'est précisément
// pourquoi l'horaire inchangé ne repart pas dans la modification.
const started = contract.isPastSlot(
rdv.date, rdv.start, agendaTodayKey(), agendaNowInZone()?.minutes,
);
const timelineLocked = !canUpdate || started || !schedule;
const timelineReason = !canUpdate ? `Modifier – ${agReason(updateCapability)}`
: !schedule ? 'L’horaire reçu du serveur n’a pas pu être placé sur la journée : il ne se déplace pas ici.'
: 'Cet horaire a commencé : il ne se déplace plus. Le reste de la fiche reste modifiable.';
const stamps = contract.scheduleStamps(day, startMinutes, minutes);
const scheduleChanged = contract.scheduleMoved(saved, stamps);
const blocked = busy || !canUpdate || !reason.trim() || !stamps;
const save = () => {
if (blocked) return;
void sendChanges({
// L'horaire ne repart que s'il a bougé : renvoyer un bloc temporel
// inchangé ferait refuser toute correction sur un rendez-vous passé.
...(scheduleChanged
? runtime.temporal(stamps.startsAtLocal, stamps.endsAtLocal) : {}),
reason: reason.trim(),
// Une note vidée part en `null` : c'est ce qui permet de l'effacer.
notes: contract.appointmentNotes(notes),
// Le matériel suit la même règle, et disparaît de lui-même quand le
// rendez-vous cesse d'être une intervention.
material: contract.appointmentMaterial(type, material),
// Une salle ou un lieu libre, jamais les deux. `room_id: null` est
// volontaire : il détache une ancienne salle lorsqu'on passe au texte.
...(roomId ? { room_id: roomId } : {
room_id: null, location: location.trim() || null,
}),
...contract.updateFields(saved, {
type, urgent, staffRcpDecision: decision,
}),
});
};
const cancel = async () => {
if (busy) return;
setBusy(true); setError(null);
try {
await runtime.cancel(saved, cancelReason.trim() || null);
await onChanged?.(); onClose();
} catch (failure) { setError(contract.errorMessage(failure, 'Annulation impossible')); }
finally { setBusy(false); }
};
const cancelCapability = saved.capabilities?.cancel;
const canCancel = !cancelCapability || cancelCapability.enabled !== false;
const leaveEditing = () => {
const currentSchedule = contract.appointmentSchedule(saved);
setReason(contract.appointmentReason(saved));
setNotes(saved.practitionerNotes || '');
setMaterial(saved.material || '');
setRoomId(saved.roomId ?? null);
setLocation(saved.roomId == null && saved.lieu !== 'Lieu non renseigné' ? (saved.lieu || '') : '');
setDay(currentSchedule ? currentSchedule.dayKey : saved.date);
setStartMinutes(currentSchedule ? currentSchedule.startMinutes : null);
setMinutes(currentSchedule ? currentSchedule.minutes : contract.DEFAULT_DURATION_MINUTES);
setType(saved.type);
setUrgent(saved.urgent === true);
setDecision(saved.staffRcpDecision || '');
setError(null);
setOverlap(null);
setEditing(false);
};
const typeAppearance = TYPE_STYLE[type] || TYPE_STYLE.consultation;
const rawDateLabel = (() => {
try {
return new Intl.DateTimeFormat('fr-FR', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
}).format(new Date(`${day}T12:00:00`));
} catch (failure) { return day; }
})();
const dateLabel = rawDateLabel
? rawDateLabel.charAt(0).toUpperCase() + rawDateLabel.slice(1) : day;
const readRange = Number.isFinite(startMinutes)
? contract.timelineRangeLabel(startMinutes, minutes) : `${saved.start} – ${saved.end}`;
// La fiche V6 est la référence visuelle de cette lecture : trois lignes
// iconées dans une seule carte, puis les textes longs dans leurs propres
// cartes. On garde ses dimensions exactes, sans mélanger notre motif et la
// note médicale du praticien.
const readRows = [
{
label: 'Horaire', value: readRange,
icon: <>>,
},
{
label: 'Date', value: dateLabel,
icon: <>>,
},
{
label: 'Lieu', value: saved.lieu === 'Lieu non renseigné' ? 'Non renseigné' : saved.lieu,
icon: <>>,
},
];
const headingBadges = (
{typeAppearance.label}
{urgent && Urgent}
);
return <>{sheet(<>
{!editing ? <>
{readRows.map((row, index) => (
{row.label}
{row.value}
))}
Motif
{reason.trim() || 'Non renseigné'}
{/* Le matériel se lit là où se lisent le motif et la note, et nulle
part ailleurs : c'est la même question posée autrement. */}
{saved.type === 'intervention' && material.trim() &&
{contract.MATERIAL_LABEL}
{material.trim()}
}
{notes.trim() &&
Notes
{notes.trim()}
}
> : <>
{/* L'horaire d'origine n'apparaît que lorsqu'il devient utile : après
un déplacement. */}
{scheduleChanged && (
setReason(e.target.value)} aria-label="Motif" maxLength={160} placeholder="Ce que dit le rendez-vous" style={{ ...fieldStyle, marginBottom: 12 }} />
{/* Le matériel se modifie là où le rendez-vous se modifie. Le délai
du rappel, lui, appartient à la création : un rappel déjà posé se
tient dans le panneau Rappels, pas ici. */}
{type === 'intervention' && }
setOtherPlace(true)} />
}
>, onClose, rdv.patient, null, null, headingBadges)}
setOtherPlace(false)}
onSubmit={(place) => { chooseLocation(place); setOtherPlace(false); }} />
>;
}
// ───────────────────────────────────────────────────────────────────────────
// Les disponibilités par salle.
//
// La question n'est plus « qu'ai-je à faire aujourd'hui ? » mais « où puis-je
// poser ce rendez-vous ? ». Les salles du service actif s'affichent côte à
// côte sur la journée, dans le cadre de la timeline du jour – même échelle,
// même passé hachuré, même repère rouge – parce qu'un praticien ne doit pas
// réapprendre à lire une journée sous prétexte qu'il la regarde par les
// salles. Les trous se voient alors sans les chercher.
//
// L'accès est un contrôle discret, à côté de la bascule de périmètre : c'est
// une autre lecture de la même journée, pas un troisième mode de calendrier
// – le sélecteur gelé Semaine / Mois parle de granularité, et le lui faire
// dire « salles » aurait mélangé deux questions.
//
// Le créneau d'un confrère dit quand et par qui, jamais pour qui : le serveur
// ne l'envoie pas, et cet écran n'a donc rien à cacher lui-même.
// ───────────────────────────────────────────────────────────────────────────
const AVAILABILITY_HEADER_HEIGHT = 34;
const AVAILABILITY_ROOM_MIN_WIDTH = 90;
function RoomSlot({ event, geometry }) {
const top = contract.minutesToOffset(event.startMinutes, geometry);
const height = contract.minutesToOffset(event.endMinutes, geometry) - top;
const density = contract.blockDensity(height);
const range = contract.timelineRangeLabel(
event.startMinutes, event.endMinutes - event.startMinutes,
);
// Le mien porte son patient, celui du confrère porte son nom : c'est tout
// ce que la vue en sait, et tout ce qu'elle a besoin d'en dire.
const who = event.owned ? (event.patient || 'Mon rendez-vous') : event.practitioner;
return (
{density !== 'bare' && (
{range}
)}
{(density === 'full' || density === 'single') && (
);
}
// Les noms de salle, alignés sur leurs colonnes et collants : sur une journée
// de vingt-quatre heures on défile beaucoup, et une colonne dont on ne sait
// plus quelle salle elle est ne sert plus à rien.
function RoomHeaders({ columns }) {
const roomHeaderRailPosition = 'sticky';
return (
{columns.map((room) => (
{room.name}
))}
);
}
// Une lecture par journée. Elle se refait quand le jour change : l'occupation
// d'hier ne dit rien de celle de demain, et la garder afficherait un plan
// faux plutôt qu'un plan absent.
// Le jour voyage AVEC la réponse, et rien ne s'écrit avant qu'elle arrive :
// l'attente et l'indisponibilité se déduisent, elles ne se posent pas en
// état. Un `setState` synchrone dans l'effet coûterait un rendu de plus à
// toute feuille qui monte ce crochet – y compris la feuille gelée, qui n'a
// rien demandé et qu'on ne rejoue pas pour rien.
function useRoomAvailability(runtime, dayKey) {
const usable = typeof runtime?.availability === 'function';
const [state, setState] = React.useState({ dayKey: null, status: 'loading', data: null, error: null });
React.useEffect(() => {
if (!usable) return undefined;
let alive = true;
runtime.availability(dayKey)
.then((data) => { if (alive) setState({ dayKey, status: 'ready', data, error: null }); })
.catch((failure) => {
if (alive) {
setState({
dayKey, status: 'failed', data: null,
error: contract.errorMessage(failure, 'Disponibilités indisponibles'),
});
}
});
return () => { alive = false; };
}, [runtime, dayKey, usable]);
if (!usable) return { status: 'unavailable', data: null, error: null };
// Tant que la réponse en main ne parle pas du jour regardé, la vue est en
// lecture : montrer l'occupation d'hier sur aujourd'hui serait un plan faux.
if (state.dayKey !== dayKey) return { status: 'loading', data: null, error: null };
return state;
}
function AvailabilityBody({
state, dayKey, todayKey, onPick, columns, roomTrackMinWidth,
roomGridScrollRef, onRoomGridScroll, nowMarkerRef,
}) {
const geometry = contract.timelineGeometry();
const now = agendaNowInZone();
const nowMinutes = now && now.dayKey === dayKey ? now.minutes : null;
const floor = contract.timelineFloor(dayKey, todayKey, nowMinutes, geometry);
const gridRef = React.useRef(null);
// Toucher un trou libre enchaîne sur la prise de rendez-vous : la salle
// vient de la colonne touchée, l'heure de la hauteur touchée. Un créneau
// déjà pris ne propose rien – le geste n'aurait pas de suite honnête.
const place = React.useCallback((occasion) => {
const node = gridRef.current;
if (!node || typeof onPick !== 'function' || !columns.length) return;
const box = node.getBoundingClientRect();
const usable = box.width - contract.TIMELINE_GUTTER;
if (usable <= 0) return;
const across = occasion.clientX - box.left - contract.TIMELINE_GUTTER;
if (across < 0) return;
const index = Math.min(columns.length - 1, Math.floor((across / usable) * columns.length));
const room = columns[index];
const raw = contract.offsetToMinutes(occasion.clientY - box.top, geometry);
const start = contract.clampBlockStart(
contract.snapToStep(raw, contract.TIMELINE_SNAP_MINUTES),
{ geometry, duration: contract.DEFAULT_DURATION_MINUTES, floor: floor ?? geometry.startMinutes },
);
if (start === null || floor === null) return;
const taken = contract.overlappingEvents(
room.events, start, start + contract.DEFAULT_DURATION_MINUTES,
);
if (taken.length) return;
onPick({ dayKey, startMinutes: start, roomId: room.id, roomName: room.name });
}, [columns, geometry, floor, dayKey, onPick]);
if (state.status === 'loading') {
return
Lecture des disponibilités…
;
}
if (state.status === 'unavailable') {
return
Les disponibilités ne sont pas lisibles hors connexion au serveur.
;
}
if (state.status === 'failed') {
return
{state.error}
;
}
if (state.data.reasonCode) {
return
Aucun service actif : aucune salle dont montrer les disponibilités.
Choisissez un service depuis votre compte.
;
}
if (!columns.length) {
return
Le service « {state.data.department?.name || 'actif'} » n’a aucune salle enregistrée.
;
}
const taken = columns.reduce((total, room) => total + room.events.length, 0);
return <>
{columns.map((room) => room.events.map((event) => (
)))}
{/* Les traits qui séparent les salles : sans eux, deux blocs voisins de
deux salles différentes se lisent comme un seul créneau coupé. */}
{columns.slice(1).map((room) => (
))}
{floor === null
? 'Journée révolue : on n’y pose plus de rendez-vous.'
: 'Touchez un creux pour y programmer un rendez-vous dans cette salle.'}
>, onClose, 'Disponibilités', 'availability', sheetScrollRef);
}
function AvailabilityButton({ onOpen }) {
const { Button } = window.VascoUI;
return (
);
}
// ───────────────────────────────────────────────────────────────────────────
// Périmètre de l'Agenda : mes événements, ou ceux de tout le cabinet.
//
// La bascule partage la ligne du sélecteur Semaine / Mois de la feuille V5 –
// même gabarit, même grammaire, à droite de lui. Elle n'ajoute donc aucune
// hauteur à l'écran : elle n'occupe que la place laissée libre sur une rangée
// qui existait déjà. L'écran gelé n'en sait rien : il reçoit une liste déjà
// cadrée par le serveur, et il est remonté à chaque changement de périmètre
// pour que son bandeau, sa grille et sa feuille de création parlent tous du
// même agenda. Le prix assumé : la bascule ramène à aujourd'hui.
// ───────────────────────────────────────────────────────────────────────────
const ScopeContext = React.createContext(null);
// Le bureau recompose le corps des vues sans recopier leur logique : il lit
// ce même contexte, et la même règle de périmètre, plutôt que d’en tenir
// une seconde copie. Aucune branche bureau n’entre dans ce fichier.
window.VascoAgendaScope = Object.freeze({
Context: ScopeContext,
visibleAbsences,
});
function ScopeToggle({ scope, choose, busy, unavailable }) {
const { ChoiceChip } = window.VascoUI;
return (
);
}
// Les deux seuls messages qui méritent de coûter une ligne, et ils ne
// paraissent qu'en cas de refus : l'attente se dit en estompant le contrôle.
function ScopeNotice({ error, unavailable, absencesError }) {
if (!error && !unavailable && !absencesError) return null;
return (
{unavailable &&
Toute l’équipe – {unavailable}
}
{error &&
{error}
}
{absencesError &&
{absencesError}
}
);
}
function EnhancedViewToggleAG(props) {
const state = React.useContext(ScopeContext);
if (!state) return ;
return <>
>;
}
function EnhancedAgendaScreen(props) {
const runtime = props.runtime;
const [scope, setScope] = React.useState(contract.DEFAULT_SCOPE);
// Le remontage de l'écran gelé : il relit `AGENDA_DATA`, que le client d'API
// vient de remplir avec le périmètre demandé.
const [pass, setPass] = React.useState(0);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
const unavailable = runtime && !runtime.scoped ? 'Fonction indisponible' : null;
const choose = React.useCallback(async (next) => {
if (busy || next === scope || !contract.isScope(next) || !runtime?.scoped) return;
setBusy(true); setError(null);
try {
await runtime.scoped(next);
setScope(next);
setPass((value) => value + 1);
} catch (failure) {
setError(contract.errorMessage(failure, 'Agenda indisponible'));
} finally { setBusy(false); }
}, [busy, scope, runtime]);
// Après une création, une modification ou une annulation, l'écran V5 recharge
// par `reload()`. Il doit revenir avec le périmètre affiché, pas avec l'autre.
const scopedRuntime = React.useMemo(() => {
if (!runtime) return runtime;
if (!runtime.scoped) return runtime;
return { ...runtime, reload: () => runtime.scoped(scope) };
}, [runtime, scope]);
// La vue des salles et la création qu'elle amorce vivent ici, à côté de
// l'écran gelé plutôt que dedans : l'écran V5 ne sait pas ouvrir une
// feuille qu'il ne connaît pas, et le pilotage à sa place serait un
// enchaînement fragile. Ce qu'il sait faire, il le fait – recharger.
const [showRooms, setShowRooms] = React.useState(false);
const [seed, setSeed] = React.useState(null);
// Un rappel du tableau de bord peut désigner un rendez-vous : la coque
// amène ici, et la fiche s'ouvre sur le rendez-vous demandé. Elle se pose
// au niveau de l'écran, comme la feuille de création des salles, parce que
// l'écran gelé ne sait pas ouvrir une fiche qu'on ne lui a pas fait toucher.
const [opened, setOpened] = React.useState(null);
const [openingError, setOpeningError] = React.useState(null);
React.useEffect(() => {
const wanted = props.openAppointmentFor;
if (!wanted || typeof runtime?.get !== 'function') return undefined;
let alive = true;
setOpeningError(null);
runtime.get(wanted)
.then((appointment) => {
if (!alive) return;
// L'agenda va d'abord sur la journée du rendez-vous : la fiche se pose
// sur ce qu'elle raconte, pas sur une semaine étrangère.
showAgendaDay(appointment.date);
setOpened(appointment);
})
// Un rendez-vous annulé, supprimé ou hors périmètre ne s'ouvre pas :
// l'agenda reste affiché et le dit, plutôt qu'une fiche vide.
.catch((failure) => {
if (alive) setOpeningError(contract.errorMessage(failure, 'Ce rendez-vous ne peut pas être ouvert'));
})
.finally(() => { if (alive) props.onAppointmentOpened?.(); });
return () => { alive = false; };
}, [props.openAppointmentFor, runtime]);
const openAvailability = React.useCallback(() => setShowRooms(true), []);
// Les congés du service, sur la même fenêtre que les rendez-vous. Ils ne
// vivent pas dans l’écran gelé : ils sont lus ici, et les vues Semaine,
// Mois et Journée les relisent par le contexte.
const [absences, setAbsences] = React.useState([]);
const [absencesError, setAbsencesError] = React.useState(null);
const reloadAbsences = React.useCallback(async () => {
if (!runtime?.absences) return;
const today = agendaTodayKey();
try {
setAbsences(await runtime.absences.list(
contract.shiftDayKey(today, -21), contract.shiftDayKey(today, 21),
));
setAbsencesError(null);
} catch (failure) {
// Un agenda sans ses congés reste un agenda : il montre les
// rendez-vous, et dit que les congés manquent plutôt que de les taire.
setAbsences([]);
setAbsencesError(contract.errorMessage(failure, 'Congés indisponibles'));
}
}, [runtime]);
React.useEffect(() => { reloadAbsences(); }, [reloadAbsences]);
const state = React.useMemo(
() => ({
scope, choose, busy, error, unavailable, openAvailability,
runtime: scopedRuntime, absences, absencesError, reloadAbsences,
}),
[scope, choose, busy, error, unavailable, openAvailability,
scopedRuntime, absences, absencesError, reloadAbsences],
);
return (
{showRooms && (
setShowRooms(false)}
onPick={(picked) => { setShowRooms(false); setSeed(picked); }} />
)}
{/* La feuille de création, pré-réglée sur le creux touché : le jour,
l'heure et la salle sont déjà posés, il reste le patient. */}
{openingError &&
{openingError}
}
{opened && (
setOpened(null)}
onChanged={async () => {
try { await scopedRuntime?.reload?.(); } catch (failure) { /* la liste reste celle d'avant */ }
setPass((value) => value + 1);
}} />
)}
{seed && (
setSeed(null)}
onCreated={async () => {
setSeed(null);
// Le rendez-vous vient de naître hors de l'écran gelé : on relit
// avant de le remonter, sinon il rejouerait la liste d'avant et
// le créneau paraîtrait encore libre.
try { await scopedRuntime?.reload?.(); } catch (failure) { /* la liste reste celle d'avant */ }
setPass((value) => value + 1);
}} />
)}
);
}
// La carte de la grille vient de la référence gelée : elle porte déjà la
// pastille du type. Son fond reprend désormais la même teinte, et son bord la
// même couleur pleine. L'urgence s'ajoute juste à côté sans remplacer le type.
function EnhancedRdvCard(props) {
const typeStyle = TYPE_STYLE[props.rdv?.type] || TYPE_STYLE.consultation;
const cardStyle = props.rdv?.urgent === true ? URGENT_STYLE : typeStyle;
const legacy = LegacyRdvCard(props);
const card = React.cloneElement(legacy, {
style: {
...legacy.props.style,
background: cardStyle.bg,
border: `1px solid ${cardStyle.border}30`,
borderLeft: `3px solid ${cardStyle.border}`,
},
});
if (props.rdv?.urgent !== true) return card;
return contract.mapElementTree(card, (node) => (
node.props.style && node.props.style.borderRadius === 999
? {node}
: node
), React.cloneElement);
}
// Tous les événements passent par la fiche généraliste : c'est là que se
// pose et se retire l'urgence, quel que soit le type. La fiche V5 gelée ne
// sait pas la montrer, et on ne la modifie pas.
function EnhancedRdvDetailSheet(props) {
return ;
}
function EnhancedPatientAgenda({ p, accent, runtime }) {
const { Button, InteractiveRow } = window.VascoUI;
const [appointments, setAppointments] = React.useState(p.rdvs || []);
const [error, setError] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [openingId, setOpeningId] = React.useState(null);
const [selected, setSelected] = React.useState(null);
const [form, setForm] = React.useState(null);
const canCreate = (p.capabilities || {}).create_appointment !== false;
// Un rendez-vous en cours appartient encore aux rendez-vous à venir : il
// ne passe dans l'historique qu'après son heure de fin. Les instants non
// reconnus restent visibles dans « À venir » au lieu d'être perdus.
const appointmentGroups = React.useMemo(() => {
const now = Date.now();
const time = (item, field) => {
const value = new Date(item[field] || '').getTime();
return Number.isFinite(value) ? value : null;
};
const past = [];
const upcoming = [];
appointments.forEach((item) => {
const end = time(item, 'endsAt');
(end !== null && end <= now ? past : upcoming).push(item);
});
upcoming.sort((left, right) => (
(time(left, 'startsAt') ?? Number.MAX_SAFE_INTEGER)
- (time(right, 'startsAt') ?? Number.MAX_SAFE_INTEGER)
));
past.sort((left, right) => (
(time(right, 'startsAt') ?? Number.MIN_SAFE_INTEGER)
- (time(left, 'startsAt') ?? Number.MIN_SAFE_INTEGER)
));
return { upcoming, past };
}, [appointments]);
const reload = React.useCallback(async () => {
if (!runtime) return;
const items = await runtime.appointments(p.id);
setAppointments(items);
}, [runtime, p.id]);
React.useEffect(() => {
let cancelled = false;
runtime?.appointments(p.id).then((items) => { if (!cancelled) setAppointments(items); })
.catch((failure) => { if (!cancelled) setError(contract.errorMessage(failure, 'Rendez-vous indisponibles')); });
return () => { cancelled = true; };
}, [runtime, p.id]);
const openAppointment = async (item) => {
const agendaRuntime = window.VascoApi?.agenda;
if (openingId || typeof agendaRuntime?.get !== 'function') {
if (!openingId) setError('La fiche Agenda est indisponible.');
return;
}
setOpeningId(item.id); setError(null);
try {
const appointment = await agendaRuntime.get(item.id);
// La route Agenda porte le patient : on refuse une fiche qui ne serait
// pas celle du dossier ouvert, plutôt que de naviguer sur un autre nom.
if (String(appointment.patientId) !== String(p.id)) {
throw new Error('Ce rendez-vous ne correspond plus à ce dossier.');
}
setSelected(appointment);
} catch (failure) {
setError(contract.errorMessage(failure, 'Ouverture impossible'));
} finally { setOpeningId(null); }
};
const openForm = () => {
const pad = (n) => String(n).padStart(2, '0');
const start = new Date(Date.now() + 3600000); start.setMinutes(0, 0, 0);
const end = new Date(start.getTime() + 1800000);
const stamp = (value) => `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`;
setForm({ start: stamp(start), end: stamp(end), reason: '', location: '', type: 'consultation', urgent: false });
};
const submit = async () => {
if (!form || !form.reason.trim() || form.end <= form.start) return;
setBusy(true); setError(null);
try {
await runtime.createAppointment(p.id, {
startsAtLocal: form.start, endsAtLocal: form.end, reason: form.reason.trim(),
location: form.location.trim() || null, type: form.type, urgent: form.urgent === true,
});
setForm(null); await reload();
} catch (failure) { setError(contract.errorMessage(failure, 'Création impossible')); }
finally { setBusy(false); }
};
const appointmentDate = (item) => {
const value = new Date(item.startsAt || '');
if (!Number.isFinite(value.getTime())) return { day: '–', month: '', time: item.start || '–' };
const timeZone = window.VASCO_RUNTIME?.dashboard?.time_zone;
const format = (options) => new Intl.DateTimeFormat('fr-FR', {
...options, ...(timeZone ? { timeZone } : {}),
}).format(value);
return {
day: format({ day: '2-digit' }),
month: format({ month: 'short' }).replace('.', '').toLocaleUpperCase('fr-FR'),
time: item.start || format({ hour: '2-digit', minute: '2-digit', hour12: false }),
};
};
const appointmentRow = (item, isPast, index) => {
const date = appointmentDate(item);
return openAppointment(item)} style={{
width: '100%', minHeight: 76, padding: 0, border: 'none',
borderTop: index ? `1px solid ${P_HAIR}` : 'none', background: 'transparent',
color: P_INK, fontFamily: 'inherit', textAlign: 'left',
cursor: openingId ? 'default' : 'pointer',
display: 'flex', alignItems: 'stretch',
opacity: openingId && openingId !== item.id ? 0.55 : 1,
}}>
{date.day}{date.month}{date.time}{item.what}
{item.urgent === true && }
{openingId === item.id ? 'Ouverture…' : item.lieu}
{item.type === 'staff_rcp' &&
{item.staffRcpDecision || 'Décision non renseignée'}
{item.staffRcpDecidedAt && · {new Intl.DateTimeFormat('fr-FR', { dateStyle: 'short' }).format(new Date(item.staffRcpDecidedAt))}}
}
;
};
const appointmentGroup = (label, ariaLabel, items, isPast) =>
;
}
function EnhancedWidgetRDV(props) {
if (props.data?.appointment_type !== 'procedure' || !props.data?._vasco_widget_id) {
return ;
}
return ;
}
// ── Jours révolus dans les vues calendrier ──────────────────────────────
// La référence gelée dessine ses cellules avec sa date de démonstration. On
// rejoue son arbre et on repose deux marques dessus : le passé est atténué,
// et le jour courant – celui du fuseau du praticien, dit par le serveur –
// reprend sa pastille d'accent quand il n'est pas déjà le jour choisi.
const LegacyWeekView = WeekView;
const LegacyMonthView = MonthView;
// L'heure murale du praticien, dans le fuseau que le serveur annonce. Elle se
// relit à chaque rendu : une feuille ouverte à cheval sur un créneau ne
// continue pas de proposer ce qui vient de passer. Sans fuseau connu, le
// navigateur fait foi – jamais la date figée de la démonstration.
// Le calcul est passé au contrat, pour que la grille de bureau lise la même
// heure murale que cette timeline. L'écran garde sa porte d'entrée.
function agendaNowInZone() {
// L'instant et le formateur sont fournis par l'écran : le contrat peut vivre
// dans un autre contexte d'exécution que lui, et c'est l'horloge de l'écran
// qui fait foi.
const timeZone = window.VASCO_RUNTIME?.dashboard?.time_zone || undefined;
let formatter = null;
try {
formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
});
} catch (failure) { return null; }
return contract.nowInZone(timeZone, new Date(), formatter);
}
function agendaTodayKey() {
return contract.todayKeyFrom(
agendaNowInZone()?.dayKey,
contract.todayKeyFrom(window.VASCO_RUNTIME?.dashboard?.local_date, fmtDate(TODAY)),
);
}
function withMarkedDays(tree, gridIndex, dayKeyAt, options) {
const children = tree?.props?.children;
const grid = Array.isArray(children) ? children[gridIndex] : null;
if (!grid?.props?.children) return tree;
const marked = contract.markDayCells(grid.props.children, dayKeyAt, {
...options, cloneElement: React.cloneElement,
});
if (marked === grid.props.children) return tree;
const next = children.slice();
next[gridIndex] = React.cloneElement(grid, {}, ...marked);
return React.cloneElement(tree, {}, ...next);
}
// ═══════════════════════════════════════════════════════════════════════
// L'écran Agenda montre la journée comme la feuille de création la montre
//
// La pile de cartes disait « qui, et à quelle heure ». Elle ne disait ni
// combien de temps, ni ce qu'il reste entre deux, ni ce qui se chevauche –
// trois choses qu'un praticien lit d'un coup d'œil sur un agenda, et qui
// n'apparaissaient jusqu'ici qu'au moment de créer. C'est la même journée :
// elle se regarde de la même façon.
//
// Ce qui change par rapport à la création : il n'y a pas de bloc à poser,
// donc pas de plancher ni de conflit à annoncer – et les rendez-vous, eux,
// se touchent pour ouvrir leur fiche.
// ═══════════════════════════════════════════════════════════════════════
// Le résumé du jour de la référence gelée est conservé : au-dessus d'un axe
// de treize heures, il reste la seule chose qui se lise sans défiler. Il
// gagne l'urgence, qui n'est plus un type et ne se comptait donc plus.
//
// L'urgence se dit DANS la pastille du type, pas à côté : « 1 intervention
// · urgent », en rouge. Comptée séparément, elle faisait paraître la journée
// deux fois plus chargée qu'elle ne l'est – deux rendez-vous urgents, l'un
// consultation et l'autre intervention, tenaient quatre pastilles – sans
// jamais dire lequel des deux était urgent.
function DaySummary({ events }) {
const counts = contract.dayTypeCounts(events);
if (!counts.length) return null;
const chip = (key, style, text) => (
);
}
function AgendaDayTimeline({ date, rdvs, accent, onSelectRdv, viewportMode = 'week' }) {
const markerRef = React.useRef(null);
const scrolledRef = React.useRef(null);
const dayKey = fmtDate(date);
const todayKey = agendaTodayKey();
const now = agendaNowInZone();
const nowMinutes = now && now.dayKey === dayKey ? now.minutes : null;
// Les rendez-vous sont ceux que l'écran gelé tient déjà, cadrés par le
// serveur selon le périmètre choisi : aucune lecture de plus.
const { timed, untimed } = contract.splitDayEvents(rdvs, dayKey);
const geometry = contract.timelineGeometry();
const laid = contract.layoutTimelineEvents(timed);
const target = contract.timelineScrollTarget({
events: timed, geometry, dayKey, todayKey, nowMinutes,
});
// La fiche reçoit le rendez-vous de la liste, pas la copie que la timeline
// a enrichie de minutes et de colonnes : la fiche parle au serveur, elle
// n'a que faire des pixels.
const source = new Map();
(Array.isArray(rdvs) ? rdvs : []).forEach((rdv) => { if (rdv) source.set(rdv.id, rdv); });
const select = typeof onSelectRdv === 'function'
? (event) => onSelectRdv(source.get(event.id) || event) : null;
// La journée s'ouvre là où elle a un sens : sur l'heure qu'il est
// aujourd'hui, sur son premier rendez-vous sinon, sur l'heure de cabinet si
// elle est vide. Elle ne s'ouvre jamais sur minuit – c'est tout l'enjeu
// depuis qu'elle s'affiche entière. Et la cible se pose au **milieu** de la
// zone visible : au sommet, le praticien perdait la matinée qu'il vient de
// faire. Changer de jour rejoue le cadrage ; rester sur le même jour ne le
// rejoue pas, sinon relire le matin ramènerait sans cesse à maintenant.
React.useEffect(() => {
if (scrolledRef.current === dayKey) return;
if (centerOnMarker(markerRef.current)) scrolledRef.current = dayKey;
}, [dayKey, target]);
const weekViewport = viewportMode === 'week';
// Les congés du jour viennent du contexte de l’écran Agenda : la fiche d’un
// dossier patient monte la même journée sans contexte, et n’en montre aucun.
const agendaState = React.useContext(ScopeContext);
const dayAbsences = contract.absencesOnDay(visibleAbsences(agendaState), dayKey);
const [openAbsence, setOpenAbsence] = React.useState(null);
return (
{/* Le congé passe SOUS les rendez-vous : ceux qui restent posés
pendant l’absence doivent se lire, pas se deviner. */}
{laid.map((event) => (
))}
{/* Un horaire que le client ne sait pas lire n'est pas une raison de
cacher le rendez-vous : la pile de cartes le montrait, la timeline
le montre encore – sous l'axe, avec la raison. */}
{untimed.length > 0 && (
Horaire illisible : {untimed.length === 1 ? 'ce rendez-vous n’a pas pu être placé' : 'ces rendez-vous n’ont pas pu être placés'} sur la journée.
)}
{/* La fiche d’un congé posé : on y corrige ses dates, on l’y retire. */}
{openAbsence && (
setOpenAbsence(null)}
onChanged={() => {
setOpenAbsence(null);
if (agendaState?.reloadAbsences) agendaState.reloadAbsences();
}} />
)}
);
}
// La liste de cartes du jour de la référence gelée cède sa place à la
// timeline. Elle est retrouvée par son type, pas par sa position : la bande
// de jours, le filet et l'ordre restent ceux de la référence, et une forme
// inattendue est rendue inchangée plutôt que cassée.
const LegacyDayView = typeof DayView === 'function' ? DayView : null;
function withDayTimeline(tree, props, viewportMode = 'week') {
const children = tree?.props?.children;
if (!LegacyDayView || !Array.isArray(children)) return tree;
const index = children.findIndex((child) => child && child.type === LegacyDayView);
if (index < 0) return tree;
const next = children.slice();
next[index] = (
);
return React.cloneElement(tree, {}, ...next);
}
function fillAgendaViewport(tree) {
if (!React.isValidElement(tree)) return tree;
return React.cloneElement(tree, {
style: {
...(tree.props.style || {}), height: '100%', minHeight: 0,
display: 'flex', flexDirection: 'column',
},
});
}
// ── Ce qui reste en haut pendant que la journée défile ──────────────────
// Les vingt-quatre heures défilent maintenant dans leur propre cadre. En
// Semaine, la bande des jours reste donc naturellement en place pendant que
// le praticien parcourt l'axe. Le collage reste utile en Mois : la grille du
// mois défile encore dans l'écran avant d'arriver au jour et à sa timeline ;
// on y colle seulement le libellé du jour, jamais toute la grille.
//
// `position: sticky` et non `fixed` : la maquette entière est mise à
// l'échelle par une transformation CSS (voir `index.html`), et une
// transformation fait du parent le bloc conteneur de tout `fixed` – un
// élément fixé se serait accroché au cadre mis à l'échelle, pas à l'écran.
// Le collage, lui, se calcule dans la boîte de défilement la plus proche –
// ici la zone de contenu de l'écran Agenda gelé – et la mise à l'échelle
// s'applique après coup, uniformément : rien à corriger à la main, à la
// différence du défilement programmé de `centerOnMarker`.
//
// L'en-tête « Agenda » et les sélecteurs vivent déjà hors de cette zone :
// ils ne bougeaient pas et ne bougent toujours pas.
//
// La couche `AG_STICKY_Z` est déclarée une seule fois, près de la palette :
// l'en-tête horaire de la feuille de création se colle sur la même.
function StickyAgendaHeader({ children }) {
const ref = React.useRef(null);
const [stuck, setStuck] = React.useState(false);
// Le filet et l'ombre ne se justifient que lorsque la bande recouvre
// quelque chose : posée au repos sur le fond de l'écran, elle doit rester
// aussi plate que dans la référence. On relit donc le conteneur qui défile
// réellement – le même que celui du cadrage de la journée.
React.useEffect(() => {
const view = scrollingParent(ref.current);
if (!view) return undefined;
const read = () => setStuck(view.scrollTop > 1);
read();
view.addEventListener('scroll', read, { passive: true });
return () => view.removeEventListener('scroll', read);
}, []);
return (
{children}
);
}
// Ce qui reste en haut est une tranche des enfants de la vue gelée, prise à
// sa place : la bande de jours et son filet en Semaine, le libellé du jour en
// Mois. Les enfants collés repartent en arguments et non dans un tableau – la
// référence les écrit sans clé. Une forme inattendue est rendue inchangée
// plutôt que cassée, comme pour la timeline.
function withStickyHeader(tree, start, count) {
const children = tree?.props?.children;
if (!Array.isArray(children) || children.length <= start + count) return tree;
const stuck = children.slice(start, start + count);
if (!stuck.every((child) => React.isValidElement(child))) return tree;
return React.cloneElement(tree, {},
...children.slice(0, start),
React.createElement(StickyAgendaHeader, { key: 'vasco-agenda-sticky-head' }, ...stuck),
...children.slice(start + count));
}
function DimmedWeekView(props) {
rememberDayControl(props);
const week = startOfWeek(props.date);
const absentKeys = contract.absenceDayKeySet(
visibleAbsences(React.useContext(ScopeContext)),
);
const marked = withMarkedDays(LegacyWeekView(props), 0, (index) => fmtDate(addDays(week, index)), {
todayKey: agendaTodayKey(), selectedKey: fmtDate(props.date), accent: props.accent, circleIndex: 1,
absentKeys,
});
return fillAgendaViewport(withStickyHeader(withDayTimeline(marked, props, 'week'), 0, 2));
}
// La vue Mois montre la même journée que la vue Semaine : c'est le même jour
// choisi, il se lit donc de la même façon – la timeline y remplace la pile de
// cartes exactement comme en Semaine, par le même remplacement, sans seconde
// mécanique. Seule la grille du mois change au-dessus, et elle continue de
// choisir le jour.
//
// Ce qui se colle, en revanche, n'est pas ce qui se colle en Semaine : la
// grille du mois fait six rangées, la coller figerait la moitié de l'écran.
// Mais le cadrage de la journée l'emporte hors de vue dès l'ouverture, et
// sans rien en haut on lirait vingt-quatre heures sans savoir de quel jour.
// C'est donc le libellé du jour choisi – la ligne que la référence dessine
// entre le filet et la journée, quatrième de ses enfants – qui reste sous les
// yeux, dans le même collage.
const AG_MONTH_LABEL_INDEX = 3;
// La journée affichée reste la propriété de l'écran gelé : il confie son
// `setDate` aux vues Semaine et Mois, et c'est cette main tendue qu'on
// retient ici. Ouvrir un rendez-vous depuis un rappel amène donc l'agenda sur
// sa journée, au lieu de poser une fiche sur la semaine courante.
const agendaDayControl = { setDate: null };
function rememberDayControl(props) {
if (typeof props?.setDate === 'function') agendaDayControl.setDate = props.setDate;
}
function showAgendaDay(dayKey) {
if (!contract.isDayKey(dayKey) || typeof agendaDayControl.setDate !== 'function') return false;
agendaDayControl.setDate(new Date(`${dayKey}T12:00:00`));
return true;
}
function DimmedMonthView(props) {
rememberDayControl(props);
const keys = contract.monthGridDayKeys(props.date.getFullYear(), props.date.getMonth());
const absentKeys = contract.absenceDayKeySet(
visibleAbsences(React.useContext(ScopeContext)),
);
const marked = withMarkedDays(LegacyMonthView(props), 1, (index) => keys[index] || null, {
todayKey: agendaTodayKey(), selectedKey: fmtDate(props.date), accent: props.accent, circleIndex: 0,
absentKeys,
});
return withStickyHeader(withDayTimeline(marked, props, 'month'), AG_MONTH_LABEL_INDEX, 1);
}
WeekView = DimmedWeekView;
MonthView = DimmedMonthView;
NewRdvSheet = EnhancedNewRdvSheet;
RdvCard = EnhancedRdvCard;
RdvDetailSheet = EnhancedRdvDetailSheet;
TabAgenda = EnhancedPatientAgenda;
WidgetRDV = EnhancedWidgetRDV;
ViewToggleAG = EnhancedViewToggleAG;
AgendaScreen = EnhancedAgendaScreen;
window.AgendaScreen = EnhancedAgendaScreen;
})();