);
}
// ═════════════════════════════════════════════════════════════
// Bottom sheet — RDV detail
// ═════════════════════════════════════════════════════════════
function RdvDetailSheet({ rdv, onClose, accent, runtime = null, capabilities = null, onChanged }) {
const ts = TYPE_STYLE[rdv.type] || TYPE_STYLE.consultation;
const rdvDate = new Date(rdv.date);
const dateLabel = `${JOURS_FULL[rdvDate.getDay()]} ${rdvDate.getDate()} ${MOIS[rdvDate.getMonth()].toLowerCase()} ${rdvDate.getFullYear()}`;
// « Modifier » et « Supprimer » n'avaient aucun gestionnaire dans la maquette :
// les deux boutons étaient purement décoratifs. Ils agissent maintenant, et
// chacun lit la capacité que le serveur attache au rendez-vous.
const [mode, setMode] = React.useState(null); // null | 'edit' | 'cancel'
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
const [editStart, setEditStart] = React.useState(`${rdv.date}T${rdv.start}`);
const [editEnd, setEditEnd] = React.useState(`${rdv.date}T${rdv.end}`);
const [editReason, setEditReason] = React.useState(rdv.notes || '');
const [editLocation, setEditLocation] = React.useState(rdv.lieu === 'Lieu non renseigné' ? '' : rdv.lieu);
const [cancelReason, setCancelReason] = React.useState('');
const updateCapability = rdv.capabilities?.update || capabilities?.update || null;
const cancelCapability = rdv.capabilities?.cancel || capabilities?.cancel || null;
const canUpdate = !updateCapability || updateCapability.enabled;
const canCancel = !cancelCapability || cancelCapability.enabled;
// Un chevauchement n'est pas une erreur : un chirurgien intercale une urgence
// en connaissance de cause. Le serveur refuse une première fois et remet un
// jeton ; la reprise rejoue la même commande avec le jeton. On garde donc de
// quoi rejouer, et on demande à l'écran plutôt que par une boîte native, qui
// bloquerait le fil du navigateur.
const [pendingOverlap, setPendingOverlap] = React.useState(null);
const buildChanges = () => {
// Les heures saisies sont des heures murales du fuseau de l'agenda : la
// conversion vers l'offset attendu est faite par le client d'API.
if (editEnd <= editStart) return null;
return {
...runtime.temporal(editStart, editEnd),
reason: editReason.trim(),
location: editLocation.trim() || null,
};
};
const sendEdit = async (changes, attempt) => {
setBusy(true);
setError(null);
try {
await runtime.update(rdv, changes, attempt);
setPendingOverlap(null);
// La feuille se ferme sur un agenda rechargé : le rendez-vous déplacé s'y
// lit à sa nouvelle place, ce qu'aucune notification ne dirait mieux.
if (onChanged) await onChanged();
onClose();
} catch (failure) {
if (failure?.code === 'appointment_overlap' && failure.detail?.confirmation_token) {
setPendingOverlap({
changes,
attempt: { ...attempt, confirmationToken: failure.detail.confirmation_token },
conflicts: failure.detail.conflicts || [],
});
setError(null);
return;
}
setError(failure?.message || 'Modification impossible');
} finally {
setBusy(false);
}
};
// Un refus décrit les horaires envoyés. Dès qu'on les retouche il ne décrit
// plus rien : il s'efface à la correction, au lieu de rester affiché à
// contredire la saisie jusqu'à la soumission suivante. Le chevauchement en
// attente n'est pas un refus mais une question posée au praticien : il garde
// sa propre remise à zéro.
const editDraft = (apply) => { setError(null); apply(); };
const submitEdit = () => {
if (!runtime || !editReason.trim()) return;
let changes;
try { changes = buildChanges(); } catch (failure) { setError(failure?.message || 'Horaires invalides'); return; }
if (!changes) { setError('La fin doit être postérieure au début'); return; }
// La clé d'idempotence est fixée ici : la reprise après conflit doit
// présenter la même commande, sinon le jeton n'est pas reconnu.
void sendEdit(changes, { idempotencyKey: window.crypto?.randomUUID?.() || `vasco-agenda-${Date.now()}` });
};
const submitCancel = async () => {
if (!runtime) return;
setBusy(true);
setError(null);
try {
await runtime.cancel(rdv, cancelReason.trim() || null);
// Le rendez-vous annulé disparaît de la grille au rechargement.
if (onChanged) await onChanged();
onClose();
} catch (failure) {
setError(failure?.message || 'Annulation impossible');
} finally {
setBusy(false);
}
};
return (
{/* Même géométrie que la feuille « Nouveau RDV » plus bas : la feuille est
bornée à 85 % de l'écran et son contenu défile à l'intérieur. Elle
poussait auparavant jusqu'en bas d'une liste haute de six rendez-vous,
et ses commandes — avec les raisons de refus qui les accompagnent —
tombaient sous le pli : il fallait défiler la liste, sous la feuille,
pour les atteindre. */}
{/* Un bouton éteint dit pourquoi à l'écran, pas au survol : la raison
vivait dans le seul attribut `title`, invisible au doigt. Elle se
lit maintenant sous les commandes, chacune avec la sienne — le
rendez-vous d'un patient archivé et un rendez-vous terminé ne se
refusent pas pour le même motif, et les deux commandes peuvent se
refuser séparément. Le `title` reste en plus, pour la souris. */}
{(!canUpdate || !canCancel) && (
{!canUpdate && (
Modifier — {agReason(updateCapability)}
)}
{!canCancel && (
Annuler le RDV — {agReason(cancelCapability)}
)}
)}
)}
);
}
// ═════════════════════════════════════════════════════════════
// Vue Jour
// ═════════════════════════════════════════════════════════════
// La feuille de détail n'est plus posée ici : elle vit au niveau de l'écran,
// avec celle de création (cf. `AgendaScreen`). Cette liste ne fait plus que
// désigner le rendez-vous choisi.
function DayView({ date, rdvs, accent, onSelectRdv }) {
const dayKey = fmtDate(date);
const dayRdvs = rdvs.filter(r => r.date === dayKey).sort((a, b) => parseTime(a.start) - parseTime(b.start));
const isToday = isSameDay(date, TODAY);
// `position: relative` n'avait de raison d'être que pour la feuille de
// détail, qui n'est plus posée ici.
return (
);
}
// ═════════════════════════════════════════════════════════════
// AgendaScreen — main component
// ═════════════════════════════════════════════════════════════
// ═════════════════════════════════════════════════════════════
// Bottom sheet — New RDV form (3 steps like web frontend)
// ═════════════════════════════════════════════════════════════
function NewRdvSheet({ date, onClose, onCreated, accent, runtime = null, rdvs = null,
initialPatientId = null }) {
const [step, setStep] = React.useState(0); // 0=date, 1=heure, 2=details
const [selDate, setSelDate] = React.useState(date);
const [selTime, setSelTime] = React.useState(null);
const [selEndTime, setSelEndTime] = React.useState(null);
// Le patient peut arriver déjà désigné, quand la création part de son dossier.
const [patient, setPatient] = React.useState(initialPatientId || '');
const [type, setType] = React.useState('consultation');
const [lieu, setLieu] = React.useState('Salle 1');
const [notes, setNotes] = React.useState('');
const [done, setDone] = React.useState(false);
const [patients, setPatients] = React.useState([]);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
const [pendingOverlap, setPendingOverlap] = React.useState(null);
React.useEffect(() => {
if (!runtime?.patients) return;
let cancelled = false;
runtime.patients()
.then((list) => { if (!cancelled) setPatients(list); })
.catch(() => { if (!cancelled) setPatients([]); });
return () => { cancelled = true; };
}, [runtime]);
// Generate month calendar for step 0
const calYear = selDate.getFullYear(), calMonth = selDate.getMonth();
const firstDay = new Date(calYear, calMonth, 1);
const lastDay = new Date(calYear, calMonth + 1, 0);
let startDow = firstDay.getDay() - 1; if (startDow < 0) startDow = 6;
const calCells = [];
for (let i = 0; i < startDow; i++) calCells.push(null);
for (let d = 1; d <= lastDay.getDate(); d++) calCells.push(d);
while (calCells.length % 7 !== 0) calCells.push(null);
// Time slots for step 1
const slots = [];
for (let h = 8; h <= 18; h++) { slots.push(`${String(h).padStart(2,'0')}:00`); if (h < 18) slots.push(`${String(h).padStart(2,'0')}:30`); }
// ── Détection de conflit ──
// Un créneau neuf dure 30 min (cf. selEndTime plus bas). Deux rendez-vous se
// chevauchent si l'un commence avant que l'autre finisse, dans les deux sens.
// On n'interdit pas : un chirurgien surbooke sciemment (urgence intercalée).
// On montre, et il tranche.
// Cet aperçu ne remplace pas le contrôle serveur : il n'inspecte que la
// fenêtre chargée. Le refus qui compte vient de l'API, sur toute la base.
const SLOT_MIN = 30;
const source = rdvs || AGENDA_DATA;
const rdvsOfDay = React.useMemo(
() => source.filter((r) => r.date === fmtDate(selDate)),
[source, selDate]);
const conflictsAt = React.useCallback((slot) => {
const s = parseTime(slot), e = s + SLOT_MIN;
return rdvsOfDay.filter((r) => s < parseTime(r.end) && e > parseTime(r.start));
}, [rdvsOfDay]);
const selConflicts = selTime ? conflictsAt(selTime) : [];
const busyCount = slots.filter((s) => conflictsAt(s).length > 0).length;
const typeOptions = [
{ id: 'consultation', label: 'Consultation', color: '#3b82f6' },
{ id: 'intervention', label: 'Intervention', color: '#b91c1c' },
{ id: 'urgence', label: 'Urgence', color: '#f97316' },
{ id: 'suivi', label: 'Suivi', color: AG_OK },
];
const lieux = ['Salle 1', 'Salle 2', 'Bloc A', 'Bloc B', 'Urgences'];
// Le rendez-vous n'était jamais envoyé : la maquette affichait un écran de
// succès pendant une seconde et demie, puis se fermait. Rien n'existait.
const buildDraft = () => {
// Le créneau est choisi en heure locale du cabinet ; il part tel quel,
// avec le décalage du fuseau de l'agenda, pas converti en UTC.
const day = fmtDate(selDate);
let end = selEndTime;
if (!end || parseTime(end) <= parseTime(selTime)) {
const total = parseTime(selTime) + SLOT_MIN;
end = `${String(Math.floor(total / 60) % 24).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
}
return {
patientId: patient,
startsAtLocal: `${day}T${selTime}`,
endsAtLocal: `${day}T${end}`,
type,
reason: notes.trim() || (typeOptions.find((t) => t.id === type)?.label ?? 'Rendez-vous'),
location: lieu || null,
};
};
const sendCreate = async (draft, attempt) => {
setBusy(true);
setError(null);
try {
await runtime.create(draft, attempt);
setPendingOverlap(null);
setDone(true);
// Le jour choisi remonte avec la réussite : créer un rendez-vous un autre jour
// que celui affiché ne montrait rien: la grille rechargeait le jour courant, et
// le seul indice était un point de plus sous une date qu'on ne regardait pas.
setTimeout(() => { onCreated && onCreated(new Date(selDate)); }, 900);
} catch (failure) {
if (failure?.code === 'appointment_overlap' && failure.detail?.confirmation_token) {
setPendingOverlap({
draft,
attempt: { ...attempt, confirmationToken: failure.detail.confirmation_token },
conflicts: failure.detail.conflicts || [],
});
return;
}
setError(failure?.message || 'Création du rendez-vous impossible');
} finally {
setBusy(false);
}
};
// Même règle que dans la fiche du rendez-vous, plus haut : un refus porte sur
// le brouillon qui l'a provoqué — « Le début doit être dans le futur » vise
// l'horaire envoyé. Il restait pourtant affiché jusqu'à la soumission
// suivante, à contredire un créneau déjà corrigé ; toute retouche du
// brouillon l'efface. Le chevauchement en attente, lui, n'est pas un refus
// mais une question posée au praticien : il garde sa propre remise à zéro.
const editDraft = (apply) => { setError(null); apply(); };
const handleCreate = () => {
if (!runtime || !patient || !selTime) return;
let draft;
try { draft = buildDraft(); } catch (failure) { setError(failure?.message || 'Horaires invalides'); return; }
void sendCreate(draft, {
idempotencyKey: window.crypto?.randomUUID?.() || `vasco-agenda-${Date.now()}`,
});
};
return (
{/* Backdrop */}
{/* Sheet */}
{/* Drag handle */}
{/* Header */}
Nouveau RDV
{/* Step indicator */}
{[0,1,2].map(i => (
))}
{/* Content */}
{done ? (
RDV créé
{patient || 'Patient'} · {fmtDate(selDate)} à {selTime}
) : step === 0 ? (
/* Step 0: Date */
Choisir la date
{/* Month nav */}
{MOIS[calMonth]} {calYear}
{/* Day headers */}
{['L','M','M','J','V','S','D'].map((d,i) => (
{d}
))}
{/* Calendar grid */}
{calCells.map((day, i) => {
if (!day) return ;
const d = new Date(calYear, calMonth, day);
const isSel = isSameDay(d, selDate);
const isT = isSameDay(d, TODAY);
// On ne planifie pas dans le passé : les jours révolus sont
// grisés et non cliquables (aujourd'hui reste ouvert).
const isPast = d < TODAY && !isT;
return (
);
})}
))}
{/* Le conflit remonte jusqu'ici : on ne valide pas un
chevauchement en l'ayant oublié deux écrans plus tôt. */}
{selConflicts.length > 0 && (
En parallèle de {selConflicts[0].patient}
{selConflicts.length > 1 ? ` +${selConflicts.length - 1}` : ''}
)}
)}
{/* Refus de créneau renvoyé par le serveur */}
{(error || pendingOverlap) && !done && (
{error && (
{error}
)}
{pendingOverlap && (
Créneau déjà occupé
{pendingOverlap.conflicts.length} rendez-vous se chevauche(nt) avec cet horaire.
)}
)}
{/* Footer buttons */}
{!done && (
{step > 0 && (
)}
)}
);
}
function AgendaScreen({ accent, onTabChange, runtime = null,
newAppointmentFor = null, onNewAppointmentHandled = null }) {
const runtimeToday = React.useMemo(() => {
const value = window.VASCO_RUNTIME?.dashboard?.local_date;
return value ? new Date(`${value}T12:00:00`) : new Date(TODAY);
}, []);
const [date, setDate] = React.useState(new Date(runtimeToday));
const [viewMode, setViewMode] = React.useState('week'); // week | month
const [showNewRdv, setShowNewRdv] = React.useState(false);
// Arrivée depuis un dossier patient : l'agenda ouvre sa feuille de création
// avec ce patient déjà désigné. Le patient demandé est gardé en état pour ne
// pas rouvrir la feuille si le praticien la referme sans créer.
const [newFor, setNewFor] = React.useState(null);
const clearNewFor = React.useCallback(() => {
setNewFor(null);
if (onNewAppointmentHandled) onNewAppointmentHandled();
}, [onNewAppointmentHandled]);
React.useEffect(() => {
if (!newAppointmentFor) return;
setNewFor(newAppointmentFor);
setShowNewRdv(true);
}, [newAppointmentFor]);
// Le tableau `AGENDA_DATA` est réécrit sur place par le client d'API ; le
// garder en état permet de le rafraîchir après une mutation sans remonter
// l'écran entier.
const [rdvs, setRdvs] = React.useState(AGENDA_DATA);
const [capabilities, setCapabilities] = React.useState(null);
const [error, setError] = React.useState(null);
// Le rendez-vous ouvert est tenu ici, et non dans la liste du jour : sa
// feuille se pose au niveau de l'écran, comme celle de création, pour être
// bornée par l'écran et non par la hauteur de la liste.
const [selectedRdv, setSelectedRdv] = React.useState(null);
const dayKey = fmtDate(date);
// La journée affichée peut changer sans que la liste soit démontée : semaine
// suivante, mois suivant, autre jour choisi dans la bande ou dans la grille,
// retour à aujourd'hui. La feuille de détail restait alors ouverte sur un
// rendez-vous absent de la journée affichée — un panneau orphelin, posé sur
// une grille qui ne le contient plus. Elle se referme donc avec la journée
// qui l'avait ouverte, comme les feuilles du dossier patient se referment
// avec le dossier. Le passage semaine ↔ mois la referme aussi : il démontait
// la liste, donc la feuille, du temps où elle y vivait.
React.useEffect(() => { setSelectedRdv(null); }, [dayKey, viewMode]);
React.useEffect(() => {
if (!runtime) return;
let cancelled = false;
runtime.capabilities()
.then((view) => { if (!cancelled) setCapabilities(view.capabilities); })
.catch((failure) => { if (!cancelled) setError(failure?.message || 'Capacités de l’agenda indisponibles'); });
return () => { cancelled = true; };
}, [runtime]);
const reload = React.useCallback(async () => {
if (!runtime) return;
try {
setRdvs([...(await runtime.reload())]);
setError(null);
} catch (failure) {
setError(failure?.message || 'Rechargement de l’agenda impossible');
}
}, [runtime]);
// Navigation
const goPrev = () => {
if (viewMode === 'week') setDate(d => addDays(d, -7));
else setDate(d => new Date(d.getFullYear(), d.getMonth() - 1, 1));
};
const goNext = () => {
if (viewMode === 'week') setDate(d => addDays(d, 7));
else setDate(d => new Date(d.getFullYear(), d.getMonth() + 1, 1));
};
const goToday = () => setDate(new Date(runtimeToday));
// Date label
const label = (() => {
if (viewMode === 'week') {
const ws = startOfWeek(date);
const we = addDays(ws, 6);
if (ws.getMonth() === we.getMonth()) {
return `${ws.getDate()} – ${we.getDate()} ${MOIS_SHORT[ws.getMonth()]}`;
}
return `${ws.getDate()} ${MOIS_SHORT[ws.getMonth()]} – ${we.getDate()} ${MOIS_SHORT[we.getMonth()]}`;
}
return `${MOIS[date.getMonth()]} ${date.getFullYear()}`;
})();
const isToday = isSameDay(date, runtimeToday);
const todayRdvCount = rdvs.filter(r => r.date === fmtDate(date)).length;
const canCreate = !capabilities || capabilities.create.enabled;
// Le « + » est une icône seule, et il est désactivé quand la création est
// refusée : son `onClick` ne peut donc jamais poser la raison à l'écran, qui
// ne vivait que dans son `title`. Elle se lit maintenant sous le bandeau, à
// la place et à la mesure du refus de l'agenda juste en dessous.
const createReason = canCreate ? null : agReason(capabilities?.create);
// Le balayage ne se devine pas : on le montre une fois, à la première
// ouverture de l'agenda, puis plus jamais. Mémorisé comme l'onboarding.
const [showHint, setShowHint] = React.useState(() => {
try { return !window.localStorage.getItem(AG_HINT_KEY); } catch (e) { return false; }
});
const dismissHint = React.useCallback(() => {
setShowHint(false);
try { window.localStorage.setItem(AG_HINT_KEY, '1'); } catch (e) { /* sans mémoire */ }
}, []);
React.useEffect(() => {
if (!showHint) return;
const t = setTimeout(dismissHint, 4500);
return () => clearTimeout(t);
}, [showHint, dismissHint]);
// Swipe to navigate
const startX = React.useRef(null);
const onTouchStart = (e) => { startX.current = e.touches[0].clientX; };
const onTouchEnd = (e) => {
if (startX.current == null) return;
const dx = e.changedTouches[0].clientX - startX.current;
startX.current = null;
// Le premier balayage vaut démonstration : le hint n'a plus lieu d'être.
if (Math.abs(dx) > 80) { dismissHint(); dx < 0 ? goNext() : goPrev(); }
};
return (