// Panneau Rappels de Vasco. // // La référence figée porte encore la cloche « Rappels » de la V5 : un popover // dessiné à la main, avec son `datetime-local`, son menu déroulant local de // recherche patient, ses tirets cadratins et un rechargement complet à chaque // ouverture qui effaçait la liste avant de la réécrire. Ce fichier remplace ce // panneau par un écran Vasco composé des primitives officielles. La référence // n'est pas modifiée : seul le lien `RappelBell` est remplacé, comme la couche // de bureau remplace les autres écrans hérités. // // Décisions de Sfar du 15/09/2026 : // 1. le patient d'un rappel se cherche dans deux onglets, « Vivants » et // « Décédés » : on vise un dossier décédé pour finir ce qu'il reste à // écrire à son sujet. Le serveur ne le refuse plus ; // 2. plus aucun tiret cadratin visible ; // 3. plus de clignotement : la liste chargée survit à la fermeture du // panneau, et la relecture suivante se fait sans effacer l'affichage ; // 4. rien n'est éteint faute de serveur. // // La feuille retenue est `BottomSheet` et non `FormSheet` : la cloche vit dans // un en-tête, et seule `BottomSheet` se porte hors de l'arbre de l'écran (dans // `#vasco-stage`). `FormSheet` se positionne sur son parent, c'est-à-dire ici // sur la boîte de 44 px du bouton. La feuille basse apporte en plus la zone // défilante dont une liste de rappels a besoin, avec la fermeture standard : // appui à côté et touche Échap. (function installVascoRemindersPanel() { 'use strict'; const { tokens, Button, IconButton, InlineAction, ChoiceChip, BottomSheet, Field, SearchSelect, DayPicker, StatusNotice, } = window.VascoUI; // Un rappel peut viser un rendez-vous. Le panneau ne sait pas changer // d'écran – il vit dans la référence gelée, loin de la coque – donc il le // demande, comme la demande à l'Assistant juste à côté. La coque écoute, // navigue vers l'agenda et lui passe le rendez-vous à ouvrir. const OPEN_APPOINTMENT_EVENT = 'vasco:open-appointment'; // La demande est publiée : la page Rappels du Compte porte la même action sur // ses lignes liées à un rendez-vous, et elle ne doit ni recopier le nom de // l'événement ni réinventer sa forme. const openAppointmentRequest = Object.freeze({ EVENT: OPEN_APPOINTMENT_EVENT, request(appointmentId) { if (!appointmentId) return false; window.dispatchEvent(new window.CustomEvent(OPEN_APPOINTMENT_EVENT, { detail: { appointmentId }, })); return true; }, }); // La sortie vers la page Rappels du Compte. // // Le panneau est dessiné ici, mais il est monté par le tableau de bord de la // référence figée, qui ne lui passe aucune navigation. C'est `Dashboard` qui // reçoit `onOpenReminders` : il la publie donc dans ce contexte, et le // panneau la consomme. `onOpenReminders` est le jumeau d'`onOpenStats`, qui // ouvrait déjà le Compte sur une de ses pages pleines : on n'invente pas une // seconde porte pour la meme chose. const RemindersNavigation = React.createContext(null); const PRIORITIES = [ { id: 'high', label: 'Haute' }, { id: 'medium', label: 'Moyenne' }, { id: 'low', label: 'Basse' }, ]; // Les deux populations de la recherche patient. « Vivants » est le choix // d'ouverture : viser un dossier décédé est un geste délibéré. const PATIENT_SCOPES = [ { id: 'living', label: 'Vivants' }, { id: 'deceased', label: 'Décédés' }, ]; const PRIORITY_COLOR = { high: tokens.color.danger, medium: tokens.color.warning, low: tokens.color.secondary, }; const NO_PATIENT = ''; // Le jour de référence vient du serveur quand il l'a dit : le calendrier ne // dépend pas de l'horloge du navigateur pour savoir quel jour est aujourd'hui. function todayKey() { const runtime = window.VASCO_RUNTIME && window.VASCO_RUNTIME.dashboard; const served = runtime && runtime.local_date; if (typeof served === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(served)) return served; const now = new Date(); const pad = (value) => String(value).padStart(2, '0'); return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; } // Le calendrier rend « AAAA-MM-JJTHH:MM », une heure locale sans fuseau ; // l'API attend un instant. function dueToInstant(value) { if (!value) return null; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); } function reason(capability) { if (typeof dbReason === 'function') return dbReason(capability); return 'Action indisponible'; } function patientOptionLabel(item) { const family = String(item.family_name || '').toLocaleUpperCase('fr-FR'); return `${family} ${item.given_name || ''}`.trim(); } function patientOptionMeta(item) { const parts = [`IPP ${item.medical_record_number}`]; if (item.age != null) parts.push(`${item.age} ans`); if (item.clinical_status === 'deceased') parts.push('décédé'); return parts.join(' · '); } function BellIcon() { return ; } function CheckIcon({ size = 13 }) { return ; } function PlusIcon() { return ; } // La bulle de l'Assistant, au format des commandes de ligne. function AskIcon() { return ; } function ArchiveIcon() { return ; } // Une ligne de rappel : son état se change à gauche, son texte se lit au // milieu, son archivage se demande à droite. Les deux commandes portent leur // refus dans leur infobulle plutôt que de disparaître sans explication. function ReminderLine({ item, busy, onToggle, onArchive, onAsk, onOpenAppointment }) { const finished = item.done; const target = finished ? 'reopen' : 'complete'; const toggleCapability = item.capabilities?.[target]; const archiveCapability = item.capabilities?.archive; const toggleBlocked = toggleCapability?.enabled === false; const archiveBlocked = archiveCapability?.enabled === false; const accent = PRIORITY_COLOR[item.priority] || PRIORITY_COLOR.low; return
onToggle(item)} style={{ marginTop: 2, borderRadius: tokens.radius.sm, border: finished ? 'none' : `1.5px solid ${accent}`, background: finished ? tokens.color.surfaceAlt : 'transparent', color: tokens.color.ink, opacity: toggleBlocked ? 0.45 : 1, }}> {finished ? : null}
{item.text}
{item.date && {item.date}} {item.patient && {item.patient} {/* Un dossier rangé ou un patient décédé reste nommé : la pastille dit lequel des deux, sans quoi « Alice Durand » se lit comme un dossier ordinaire. */} {item.patientArchived && (dossier archivé)} {!item.patientArchived && item.patientDeceased && (décédé)} }
{/* Le rendez-vous visé, quand le rappel en désigne un : « le matériel de jeudi » se lit mieux devant le rendez-vous de jeudi. */} {item.appointmentId && onOpenAppointment(item)} style={{ marginTop: 2, minHeight: 26, justifyContent: 'flex-start' }}> Ouvrir le rendez-vous }
{/* Ce que dit un rappel est souvent ce qu'on allait demander à l’Assistant. L’action l’y emmène avec la phrase déjà dite ; elle se pose avant l’archivage, qui range la ligne au lieu de la traiter. */} onAsk(item)} style={{ marginTop: 1, color: tokens.color.secondary, opacity: 0.6 }}> onArchive(item)} style={{ marginTop: 1, color: tokens.color.secondary, opacity: archiveBlocked ? 0.35 : 0.6 }}>
; } // Le formulaire de création. Il vit dans la feuille, au-dessus de la liste, // et se replie tant qu'on ne demande pas un nouveau rappel. function ReminderDraft({ runtime, busy, onCreate }) { const [text, setText] = React.useState(''); const [due, setDue] = React.useState(null); const [priority, setPriority] = React.useState('medium'); const [patientId, setPatientId] = React.useState(NO_PATIENT); const [scope, setScope] = React.useState('living'); const [query, setQuery] = React.useState(''); // `null` tant que le serveur n'a jamais répondu : « Aucun patient » ne se // montre que devant une réponse réelle, jamais pendant l'attente. const [roster, setRoster] = React.useState(null); const [searching, setSearching] = React.useState(false); const [searchError, setSearchError] = React.useState(null); // Même prédicat et même cadence que le répertoire, la dictée et l'agenda : // le serveur cherche, l'écran affiche. L'onglet choisit la population. React.useEffect(() => { if (!runtime?.patientOptions) return undefined; let cancelled = false; setSearching(true); setSearchError(null); const term = query.trim(); const debounceMs = window.VascoApi?.search?.debounceMs ?? 250; const handle = setTimeout(() => { runtime.patientOptions(term, { clinicalState: scope }) .then((items) => { if (cancelled) return; setRoster(items); setSearching(false); }) .catch((failure) => { if (cancelled) return; setSearchError(failure?.message || 'Recherche des patients impossible'); setRoster([]); setSearching(false); }); // La première liste n'attend pas : le délai ne concerne que la frappe. }, term ? debounceMs : 0); return () => { cancelled = true; clearTimeout(handle); }; }, [runtime, query, scope]); // « Sans patient » n'est pas un résultat de recherche : c'est le choix par // défaut du rappel, toujours en tête de la liste. const options = [ { value: NO_PATIENT, label: 'Sans patient', meta: 'Rappel général' }, ...(roster || []).map((item) => ({ value: item.id, label: patientOptionLabel(item), meta: patientOptionMeta(item), })), ]; const ready = Boolean(text.trim()) && Boolean(runtime); const submit = () => { if (!ready || busy) return; onCreate({ text: text.trim(), dueAt: dueToInstant(due), patientId: patientId || null, priority, }, () => { setText(''); setDue(null); setPriority('medium'); setPatientId(NO_PATIENT); setQuery(''); }); }; return
setText(event.target.value)} aria-label="Texte du rappel" placeholder="Ex : appeler M. Martin pour son INR" style={{ width: '100%', height: tokens.control.medium, padding: '0 12px', borderRadius: tokens.radius.md, border: `1px solid ${tokens.color.borderStrong}`, background: tokens.color.surface, color: tokens.color.ink, fontFamily: tokens.type.body, fontSize: 13, fontWeight: 500, outline: 'none', }} />
{PRIORITIES.map((entry) => setPriority(entry.id)} style={{ flex: '1 1 0' }}> {entry.label} )}
; } function VascoRemindersPanel({ accent, runtime = null, capability = null, activeCount = null }) { const [open, setOpen] = React.useState(false); const [drafting, setDrafting] = React.useState(false); const openAllReminders = React.useContext(RemindersNavigation); // La liste vit dans le composant, pas dans la feuille : elle survit donc à // la fermeture du panneau, et la deuxième ouverture montre immédiatement ce // que la première avait chargé. const [reminders, setReminders] = React.useState([]); const [loaded, setLoaded] = React.useState(false); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const allowed = !capability || capability.enabled; const active = reminders.filter((item) => !item.done); const done = reminders.filter((item) => item.done); // La pastille annonce ce que le panneau va montrer. Tant que la liste // complète n'est pas chargée, c'est le décompte du serveur qui s'affiche ; // ensuite la liste à l'écran fait foi, elle seule suit les commandes. const badge = loaded || activeCount === null ? active.length : activeCount; // La relecture ne vide jamais l'affichage : elle remplace la liste quand // elle a réussi. Seule la toute première lecture montre une attente, parce // qu'il n'y a alors rien à regarder. const reload = React.useCallback(async () => { if (!runtime) return; try { const items = await runtime.reminders.load(); setReminders(items); setLoaded(true); setError(null); } catch (failure) { setError(failure?.message || 'Chargement des rappels impossible'); } }, [runtime]); React.useEffect(() => { if (open) void reload(); }, [open, reload]); // Une commande refusée ne change rien à l'écran : on ne remplace la ligne // qu'avec ce que le serveur renvoie réellement. const runCommand = async (reminder, action) => { if (!runtime) return; setBusy(true); setError(null); try { const updated = await runtime.reminders[action](reminder); setReminders((list) => (action === 'archive' ? list.filter((item) => item.id !== reminder.id) : list.map((item) => (item.id === updated.id ? updated : item)))); await runtime.refresh().catch(() => {}); } catch (failure) { setError(failure?.message || 'Commande refusée'); if (failure?.status === 409) await reload(); } finally { setBusy(false); } }; const toggle = (reminder) => { const target = reminder.done ? 'reopen' : 'complete'; const capabilities = reminder.capabilities?.[target]; if (capabilities && !capabilities.enabled) { setError(reason(capabilities)); return; } void runCommand(reminder, target); }; const archive = (reminder) => { const capabilities = reminder.capabilities?.archive; if (capabilities && !capabilities.enabled) { setError(reason(capabilities)); return; } void runCommand(reminder, 'archive'); }; // Confier un rappel à l’Assistant ferme le panneau : la demande emmène sur // un autre écran, et une feuille restée ouverte le recouvrirait à l’arrivée. // Le texte part mot pour mot, rien n’est ajouté au passage. // Ouvrir le rendez-vous ferme le panneau, comme la demande à l'Assistant : // la coque change d'écran, et une feuille restée ouverte recouvrirait // l'agenda à l'arrivée. const openAppointment = (reminder) => { if (!openAppointmentRequest.request(reminder?.appointmentId)) return; setOpen(false); setDrafting(false); }; const askAssistant = (reminder) => { if (!window.VascoAssistantAsk.ask(reminder.text, 'dashboard-reminder')) return; setOpen(false); setDrafting(false); }; const create = async (draft, clear) => { if (!runtime) return; setBusy(true); setError(null); try { const created = await runtime.reminders.create(draft); setReminders((list) => [created, ...list]); clear(); setDrafting(false); await runtime.refresh().catch(() => {}); } catch (failure) { setError(failure?.message || 'Création du rappel impossible'); } finally { setBusy(false); } }; const emptyLine = loaded ? 'Aucun rappel' : (runtime ? 'Chargement des rappels…' : 'Aucun rappel'); return
{ if (!allowed) { if (typeof showToast === 'function') showToast(reason(capability), 'error'); return; } setOpen(true); }} style={{ borderRadius: tokens.radius.lg, background: tokens.color.surface }}> {badge > 0 && } 0 ? `${badge} en cours` : 'Rien en cours'} busy={busy} headerAside={
{/* Le panneau montre le dessus de la pile dans une feuille : rien n'y disait qu'une page existait pour le reste. L'action ferme la feuille avant de changer d'écran, comme la demande à l'Assistant et l'ouverture d'un rendez-vous. Sans porte publiée elle n'est pas posée : mieux vaut pas de bouton qu'un bouton qui ne mène nulle part. */} {typeof openAllReminders === 'function' && { setOpen(false); setDrafting(false); openAllReminders(); }}>Voir tout} setDrafting((current) => !current)} style={{ background: drafting ? tokens.color.ink : tokens.color.surfaceAlt, color: drafting ? '#fff' : tokens.color.ink, border: 'none' }}>
} onClose={() => { setOpen(false); setDrafting(false); }}> {error && } {drafting && } {reminders.length === 0 ?
{emptyLine}
:
{active.map((item) => )} {done.length > 0 &&
Terminés
} {done.map((item) => )}
}
; } // La référence garde son composant ; c'est le lien qui change, comme partout // ailleurs dans la couche produit. La composition de bureau lit ce même lien : // elle reçoit donc ce panneau sans qu'aucune logique soit recopiée pour elle. RappelBell = VascoRemindersPanel; window.VascoRemindersPanel = VascoRemindersPanel; window.VascoRemindersNavigation = RemindersNavigation; window.VascoOpenAppointment = openAppointmentRequest; // Le tableau de bord publie sa sortie, le panneau la lit. La composition de // bureau enveloppe ce lien-ci : elle recevra donc la même porte le jour où // elle la passera, sans qu'aucune logique soit recopiée pour elle. if (typeof Dashboard === 'function') { const VascoDashboardBeforeRemindersDoor = Dashboard; Dashboard = function VascoDashboardWithRemindersDoor(props) { const door = typeof props.onOpenReminders === 'function' ? props.onOpenReminders : null; return ; }; window.Dashboard = Dashboard; } }());