// Agenda — vue Jour (défaut), Semaine, Mois. // Style Vasco (crème/noir/accent) + inspiration Apple Calendar. const AG_VT = window.VT || {}; const AG_INK = AG_VT.ink || '#0a0a0a'; const AG_SUB = AG_VT.sub || '#6b7280'; const AG_BG = AG_VT.paper || '#f6f5f1'; const AG_SURF = AG_VT.surface || '#ffffff'; const AG_HAIR = AG_VT.hair || 'rgba(10,10,10,0.06)'; const AG_HAIR2 = AG_VT.hair2 || 'rgba(10,10,10,0.10)'; const AG_OK = AG_VT.success || '#059669'; const AG_WARN = AG_VT.warning || '#d97706'; // conflit d'horaire : on avertit, on n'interdit pas const AG_MED = AG_VT.medBlue || '#3d6b8f'; const AG_HINT_KEY = 'vasco_agenda_swipe_hint'; const AG_OK_TINT = AG_VT.successTint || 'rgba(5,150,105,0.10)'; const AG_OK_TINT_SOFT = AG_VT.successTintSoft || 'rgba(5,150,105,0.06)'; const JOURS = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']; const JOURS_FULL = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']; const MOIS = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; const MOIS_SHORT = ['Jan', 'Fév', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sep', 'Oct', 'Nov', 'Déc']; // Type colors — muted, premium const TYPE_STYLE = { consultation: { bg: 'rgba(59,130,246,0.08)', border: '#3b82f6', text: '#1d4ed8', label: 'Consultation' }, intervention: { bg: 'rgba(185,28,28,0.08)', border: '#b91c1c', text: '#991b1b', label: 'Intervention' }, urgence: { bg: 'rgba(249,115,22,0.08)', border: '#f97316', text: '#c2410c', label: 'Urgence' }, suivi: { bg: AG_OK_TINT_SOFT, border: AG_OK, text: AG_OK, label: 'Suivi' }, }; // ── Mock data ── const AGENDA_DATA = [ // Aujourd'hui (30 avril 2026) { id: 1, date: '2026-04-30', start: '08:30', end: '09:15', patient: 'M. Martin, Jean', type: 'consultation', lieu: 'Salle 1', notes: 'Suivi AOMI stade IIb' }, { id: 2, date: '2026-04-30', start: '09:30', end: '12:00', patient: 'Mme Durand, Hélène', type: 'intervention', lieu: 'Bloc A', notes: 'Endartériectomie carotide D' }, { id: 3, date: '2026-04-30', start: '10:00', end: '10:30', patient: 'M. Roux, Pierre', type: 'urgence', lieu: 'Urgences', notes: 'Ischémie aiguë MI G' }, { id: 4, date: '2026-04-30', start: '14:00', end: '14:45', patient: 'Mme Petit, Aline', type: 'suivi', lieu: 'Salle 2', notes: 'Contrôle FAV J+30' }, { id: 5, date: '2026-04-30', start: '15:00', end: '17:00', patient: 'M. Bernard, Paul', type: 'intervention', lieu: 'Bloc B', notes: 'EVAR — AAA 54mm' }, { id: 6, date: '2026-04-30', start: '17:30', end: '18:00', patient: 'Mme Lefèvre, Denise', type: 'suivi', lieu: 'Salle 1', notes: 'Post-op J+5 pontage' }, // Demain { id: 7, date: '2026-05-01', start: '09:00', end: '09:45', patient: 'M. Garnier, Robert', type: 'consultation', lieu: 'Salle 2', notes: 'Bilan AOMI' }, { id: 8, date: '2026-05-01', start: '10:00', end: '12:30', patient: 'M. Lemaire, Christian', type: 'intervention', lieu: 'Bloc A', notes: 'Création FAV radio-céphalique' }, { id: 9, date: '2026-05-01', start: '14:00', end: '14:30', patient: 'Mme Nguyen, Linh', type: 'consultation', lieu: 'Salle 1', notes: 'Bilan varices' }, // 2 mai { id: 10, date: '2026-05-02', start: '08:00', end: '10:00', patient: 'M. Mercier, François', type: 'intervention', lieu: 'Bloc B', notes: 'Pontage aorto-bifémoral' }, { id: 11, date: '2026-05-02', start: '11:00', end: '11:30', patient: 'Mme Lambert, Catherine', type: 'suivi', lieu: 'Salle 2', notes: 'Doppler contrôle' }, { id: 12, date: '2026-05-02', start: '14:00', end: '14:45', patient: 'M. Simon, André', type: 'consultation', lieu: 'Salle 1', notes: 'Épreuve de marche' }, // 3 mai { id: 13, date: '2026-05-03', start: '09:00', end: '09:30', patient: 'Mme Bonnet, Marie', type: 'suivi', lieu: 'Salle 2', notes: 'Echo-doppler veineux' }, { id: 14, date: '2026-05-03', start: '10:00', end: '12:00', patient: 'M. Fournier, Jacques', type: 'intervention', lieu: 'Bloc A', notes: 'Stent iliaque G' }, // 4 mai { id: 15, date: '2026-05-04', start: '09:00', end: '09:45', patient: 'Mme Henry, Françoise', type: 'consultation', lieu: 'Salle 1', notes: 'Post-op endartériectomie' }, { id: 16, date: '2026-05-04', start: '14:00', end: '16:00', patient: 'M. Thomas, Georges', type: 'intervention', lieu: 'Bloc A', notes: 'Chirurgie anévrysme poplité' }, // 5-6 mai { id: 17, date: '2026-05-05', start: '09:00', end: '09:30', patient: 'Mme Morel, Isabelle', type: 'suivi', lieu: 'Salle 2', notes: 'Contrôle anticoagulation' }, { id: 18, date: '2026-05-06', start: '08:30', end: '10:30', patient: 'M. Chevalier, René', type: 'intervention', lieu: 'Bloc B', notes: 'Pontage fémoro-poplité D' }, { id: 19, date: '2026-05-06', start: '14:00', end: '14:30', patient: 'Mme Martinez, Carmen', type: 'consultation', lieu: 'Salle 1', notes: 'Bilan veineux' }, ]; // ── Helpers ── function fmtDate(d) { const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, '0'), day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } function addDays(d, n) { const r = new Date(d); r.setDate(r.getDate() + n); return r; } function isSameDay(a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } function parseTime(t) { const [h, m] = t.split(':').map(Number); return h * 60 + m; } function startOfWeek(d) { const r = new Date(d); const day = r.getDay(); r.setDate(r.getDate() - (day === 0 ? 6 : day - 1)); return r; } // Motifs de refus de l'agenda, formulés comme dans l'application native. const AG_REASONS = { role_not_supported: 'Rôle non pris en charge', feature_unavailable: 'Fonction indisponible', patient_archived: 'Patient archivé', patient_deceased: 'Patient décédé', appointment_cancelled: 'Rendez-vous annulé', appointment_completed: 'Rendez-vous terminé', }; const agReason = (capability) => AG_REASONS[capability?.reason_code] || 'Action indisponible'; const TODAY = new Date(2026, 3, 30); // 30 avril 2026 // ═════════════════════════════════════════════════════════════ // RDV Card — used in Day and Month detail // ═════════════════════════════════════════════════════════════ function RdvCard({ rdv, accent, compact = false, onClick }) { const ts = TYPE_STYLE[rdv.type] || TYPE_STYLE.consultation; return (
{/* Time column */}
{rdv.start}
{rdv.end}
{/* Content */}
{rdv.patient}
{!compact && (
{rdv.notes}
)}
{ts.label} {rdv.lieu}
); } // ═════════════════════════════════════════════════════════════ // 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. */}
{/* Drag handle */}
{/* Header — patient name + type badge */}
{rdv.patient}
{ts.label}
{/* Info rows */}
{[ { icon: <>, label: 'Horaire', value: `${rdv.start} — ${rdv.end}` }, { icon: <>, label: 'Date', value: dateLabel }, { icon: <>, label: 'Lieu', value: rdv.lieu }, ].map((row, i, arr) => (
{row.icon}
{row.label}
{row.value}
))}
{/* Notes */} {rdv.notes && (
Notes
{rdv.notes}
)} {error && (
{error}
)} {pendingOverlap && (
Créneau déjà occupé
{pendingOverlap.conflicts.length} rendez-vous se chevauche(nt) avec cet horaire. Programmer en parallèle reste possible.
)} {mode === 'edit' && (
Modifier
editDraft(() => setEditStart(e.target.value))} aria-label="Début" style={{ flex: 1, minWidth: 0, height: 38, borderRadius: 12, border: `1px solid ${AG_HAIR2}`, padding: '0 10px', fontFamily: "'JetBrains Mono', monospace", fontSize: 11, color: AG_INK, background: AG_BG, outline: 'none' }} /> editDraft(() => setEditEnd(e.target.value))} aria-label="Fin" style={{ flex: 1, minWidth: 0, height: 38, borderRadius: 12, border: `1px solid ${AG_HAIR2}`, padding: '0 10px', fontFamily: "'JetBrains Mono', monospace", fontSize: 11, color: AG_INK, background: AG_BG, outline: 'none' }} />
editDraft(() => setEditReason(e.target.value))} maxLength={160} placeholder="Motif" aria-label="Motif" style={{ width: '100%', height: 38, borderRadius: 12, border: `1px solid ${AG_HAIR2}`, padding: '0 12px', fontFamily: 'inherit', fontSize: 13, color: AG_INK, background: AG_BG, outline: 'none', marginBottom: 8 }} /> editDraft(() => setEditLocation(e.target.value))} maxLength={160} placeholder="Lieu (optionnel)" aria-label="Lieu" style={{ width: '100%', height: 38, borderRadius: 12, border: `1px solid ${AG_HAIR2}`, padding: '0 12px', fontFamily: 'inherit', fontSize: 13, color: AG_INK, background: AG_BG, outline: 'none' }} />
)} {mode === 'cancel' && (
Annuler ce rendez-vous ?
{/* L'API annule, elle ne supprime pas : le rendez-vous reste dans l'historique avec son motif. Le bouton disait « Supprimer ». */}
Il passe en « annulé » et reste consultable dans l'historique. Aucune suppression.
setCancelReason(e.target.value)} maxLength={500} placeholder="Motif d'annulation (optionnel)" aria-label="Motif d'annulation" style={{ width: '100%', height: 38, borderRadius: 12, border: `1px solid ${AG_HAIR2}`, padding: '0 12px', fontFamily: 'inherit', fontSize: 13, color: AG_INK, background: AG_BG, outline: 'none', marginTop: 10 }} />
)} {/* Actions */} {!mode && (
{/* 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 (
{/* Day summary */} {dayRdvs.length > 0 && (
{Object.entries( dayRdvs.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {}) ).map(([type, count]) => { const ts = TYPE_STYLE[type]; return (
{count} {ts.label.toLowerCase()}{count > 1 ? 's' : ''}
); })}
)} {/* Timeline */}
{dayRdvs.length === 0 ? (
📋
Aucun rendez-vous
) : dayRdvs.map(r => ( onSelectRdv && onSelectRdv(r)} /> ))}
); } // ═════════════════════════════════════════════════════════════ // Vue Semaine — bande horizontale des 7 jours + liste du jour sélectionné // ═════════════════════════════════════════════════════════════ function WeekView({ date, setDate, rdvs, accent, onSelectRdv }) { const week = startOfWeek(date); const days = Array.from({ length: 7 }, (_, i) => addDays(week, i)); return (
{/* Week strip */}
{days.map((d, i) => { const isToday_ = isSameDay(d, TODAY); const isSelected = isSameDay(d, date); const dayRdvs = rdvs.filter(r => r.date === fmtDate(d)); const types = [...new Set(dayRdvs.map(r => r.type))]; return ( ); })}
{/* Separator */}
{/* Day list below */}
); } // ═════════════════════════════════════════════════════════════ // Vue Mois — calendrier grille + liste du jour sélectionné // ═════════════════════════════════════════════════════════════ function MonthView({ date, setDate, rdvs, accent, onSelectRdv }) { const year = date.getFullYear(), month = date.getMonth(); const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); let startDow = firstDay.getDay() - 1; if (startDow < 0) startDow = 6; const cells = []; for (let i = 0; i < startDow; i++) cells.push(null); for (let d = 1; d <= lastDay.getDate(); d++) cells.push(d); while (cells.length % 7 !== 0) cells.push(null); return (
{/* Day headers */}
{['L', 'M', 'M', 'J', 'V', 'S', 'D'].map((d, i) => (
{d}
))}
{/* Grid */}
{cells.map((day, i) => { if (day == null) return
; const d = new Date(year, month, day); const isToday_ = isSameDay(d, TODAY); const isSelected = isSameDay(d, date); const dayRdvs = rdvs.filter(r => r.date === fmtDate(d)); const types = [...new Set(dayRdvs.map(r => r.type))]; return ( ); })}
{/* Separator */}
{/* Day detail below */}
{JOURS_FULL[date.getDay()]} {date.getDate()} {MOIS[date.getMonth()].toLowerCase()}
); } // ═════════════════════════════════════════════════════════════ // View toggle — Jour / Semaine / Mois (Apple Calendar style) // ═════════════════════════════════════════════════════════════ function ViewToggleAG({ mode, onChange }) { const modes = [ { id: 'week', label: 'Semaine' }, { id: 'month', label: 'Mois' }, ]; return (
{modes.map(m => ( ))}
); } // ═════════════════════════════════════════════════════════════ // 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 ( ); })}
) : step === 1 ? ( /* Step 1: Time */
Choisir l'horaire
{JOURS_FULL[selDate.getDay()]} {selDate.getDate()} {MOIS[selDate.getMonth()].toLowerCase()} {busyCount > 0 && ( {busyCount} occupé{busyCount > 1 ? 's' : ''} )}
{slots.map(s => { const isSel = s === selTime; const busy = conflictsAt(s).length > 0; return ( ); })}
{/* Détail du conflit sur le créneau retenu */} {selConflicts.length > 0 && (
Créneau déjà occupé
{selConflicts.map((r) => (
{r.patient}
{r.start}–{r.end} · {r.lieu} · {(TYPE_STYLE[r.type] || {}).label || r.type}
))}
Choisissez un autre horaire, ou continuez pour programmer en parallèle.
)}
) : ( /* Step 2: Details */
Détails
{/* Patient */}
{/* Un rendez-vous se rattache à une clé patient. Le champ libre de la maquette ne pouvait désigner personne. */}
{/* Type */}
{typeOptions.map(t => { const isSel = t.id === type; return ( ); })}
{/* Lieu */}
{lieux.map(l => ( ))}
{/* Motif — c'est ce que le serveur stocke et ce que la fiche du rendez-vous affiche ; le champ s'appelait « Notes ». */}