// 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
{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',
}} />
;
}
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 && {badge}}
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' }}>
;
}
// 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;
}
}());