// La page Rappels du Compte, à côté des Statistiques. // // Le panneau de la cloche montre les tout premiers rappels et rien d'autre : // il répond à « qu'est-ce qui m'attend là ? ». Cette page-ci répond à une autre // question – « où j'en suis de tout ce que j'ai à faire ? » – et elle demande // donc ce que le panneau n'a jamais eu : la liste entière, regroupée par // échéance, filtrable, et travaillable sur place. // // Une seule couleur parle, et elle parle de l'ÉCHÉANCE. Le rouge est gardé pour // le retard, seul cas où quelque chose est réellement en défaut. La priorité, // qui est l'autre axe, se dit en toutes lettres : deux systèmes de couleur sur // le même écran se battraient et le praticien ne saurait plus lequel lire. // // Ce que le serveur sait faire et ce qu'il ne sait pas faire est dit sans // détour : il ne filtre que « à faire » contre « fait ». Tout le reste est // trié par cette page sur ce qu'elle a chargé – c'est pourquoi elle charge // tout, et pourquoi elle l'avoue quand elle n'y arrive pas. (function vascoAccountRemindersScreen(global) { const contract = global.VascoAccountRemindersContract; if (!contract) throw new Error('Le contrat de la page Rappels doit être chargé avant l’écran.'); const STATUS_FILTERS = Object.freeze([ ['open', 'À faire'], ['completed', 'Faites'], ]); // La teinte de chaque groupe, prise au contrat visuel. Aucune valeur écrite // en dur : le jour où la palette bouge, cette page bouge avec elle. function groupAppearance(tone, tokens) { const tones = { danger: { bar: tokens.color.danger, text: tokens.color.danger, tint: tokens.color.dangerTint }, warning: { bar: tokens.color.warning, text: tokens.color.warning, tint: tokens.color.warningTint }, accent: { bar: tokens.color.accent, text: tokens.color.accent, tint: tokens.color.accentTint }, neutral: { bar: tokens.color.borderStrong, text: tokens.color.ink, tint: tokens.color.surfaceAlt }, muted: { bar: tokens.color.border, text: tokens.color.secondary, tint: tokens.color.surfaceAlt }, }; return tones[tone] || tones.neutral; } function FilterChips({ label, choices, value, onChange, allLabel }) { const { ChoiceChip, tokens } = global.VascoUI; const chipStyle = (selected) => ({ padding: '7px 12px', borderRadius: tokens.radius.pill, background: selected ? tokens.color.ink : tokens.color.surface, color: selected ? tokens.color.canvas : tokens.color.ink, border: `1px solid ${selected ? tokens.color.ink : tokens.color.border}`, fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer', }); return
{label}
onChange(null)} style={chipStyle(value === null)}>{allLabel} {choices.map(({ id, label: text, count }) => ( onChange(value === id ? null : id)} style={chipStyle(value === id)}> {text} {count} ))}
; } // La barre de priorité, sur la tranche gauche de la ligne : rouge, orange, // gris. Les teintes viennent du contrat visuel, jamais écrites en dur. // // Elles sont aussi celles du retard et d'aujourd'hui, en titre de section : // c'est assumé, et c'est la position qui les sépare. À gauche d'une ligne, // la couleur dit toujours la priorité ; en tête d'une section, toujours // l'échéance. function priorityBar(priority, tokens) { const scale = { danger: tokens.color.danger, warning: tokens.color.warning, // Le gris clair du contrat, pas le gris de texte : une priorité basse se // remarque sans se disputer l'attention avec les deux autres. muted: tokens.color.muted, }; return scale[contract.PRIORITY_BARS[priority]] || scale.muted; } // Une ligne de rappel. Elle ne cache aucune de ses commandes derrière un // geste : le praticien voit ce qu'il peut faire, et ce qu'il ne peut pas // faire est éteint avec sa raison en infobulle, jamais absent. function ReminderRow({ reminder, busy, onComplete, onReopen, onArchive, onOpenAppointment }) { const { IconButton, InlineAction, tokens, icons } = global.VascoUI; const capability = (name) => reminder.capabilities?.[name] || { enabled: false, reason_code: null }; const complete = capability(reminder.done ? 'reopen' : 'complete'); const archive = capability('archive'); const priorityLabel = contract.PRIORITY_LABELS[reminder.priority] || null; return
(reminder.done ? onReopen(reminder) : onComplete(reminder))}>
{reminder.text}
{reminder.date && {reminder.date}} {reminder.patient && {reminder.patient}{reminder.patientArchived ? ' · dossier rangé' : ''} } {/* La priorité se voit sur la barre de gauche. Elle reste écrite ici pour qui ne lit pas les couleurs : une barre seule ne dit rien à un lecteur d'écran, ni à un œil qui ne distingue pas ces gris. */} {priorityLabel && {priorityLabel} }
{/* Le rendez-vous visé, quand le rappel en désigne un : « le matériel de jeudi » se lit devant le rendez-vous de jeudi. C'est le même geste et le même transport que dans le panneau de la cloche ; la coque écoute et change d'écran. Sans transport, pas de bouton mort. */} {reminder.appointmentId && onOpenAppointment && onOpenAppointment(reminder)} style={{ marginTop: 2, minHeight: 26, justifyContent: 'flex-start' }}> Ouvrir le rendez-vous }
onArchive(reminder)}>
; } // ── Poser un rappel depuis la page ────────────────────────────────────── // // La même question que le panneau de la cloche, posée dans la feuille du // design system plutôt que dans un formulaire déplié sous un bouton. // // Un formulaire, pas une feuille : la feuille est `FormSheet`, et elle vient // du socle. Le nom le dit, pour qu'on ne cherche pas ici une primitive. // // L'échéance se choisit dans le calendrier de l'app – la même primitive que // les écrans de rappel du tableau de bord –, jamais dans celui du navigateur. // Il s'ouvre par-dessus l'écran au lieu de pousser le formulaire vers le bas. // // Les pastilles de priorité portent les couleurs de la page : rouge, orange, // gris clair. Ce sont celles qu'on retrouvera sur la tranche du rappel une // fois posé – on choisit la couleur qu'on verra. function NewReminderForm({ open, onClose, onCreate, busy, error, todayKey, defaultDue, patientOptions }) { const { FormSheet, Button, ChoiceChip, DayPicker, SearchSelect, TextField, tokens } = global.VascoUI; const [text, setText] = React.useState(''); const [due, setDue] = React.useState(defaultDue); const [priority, setPriority] = React.useState('medium'); // Le dossier visé, retenu avec son libellé : la recherche repart de zéro à // chaque frappe, et le nom retenu doit survivre à une liste qui se vide. const [patient, setPatient] = React.useState(null); const [query, setQuery] = React.useState(''); const [found, setFound] = React.useState({ items: [], loading: false, failure: null }); const armed = String(text).trim() !== ''; // La feuille reste montée pour jouer son animation de sortie : elle ne se // vide donc plus en disparaîtrant. C'est l'ouverture qui la remet à neuf, // et non la fermeture, sans quoi le formulaire se viderait sous les yeux du // praticien pendant que la feuille descend. React.useEffect(() => { if (!open) return; setText(''); setDue(defaultDue); setPriority('medium'); setPatient(null); setQuery(''); setFound({ items: [], loading: false, failure: null }); }, [open]); // La recherche est celle du serveur, et elle ne part qu'à partir de deux // caractères. Une réponse qui arrive après une frappe plus récente est // jetée : sans cela, la liste clignoterait entre deux états périmés. React.useEffect(() => { if (typeof patientOptions !== 'function' || !contract.patientSearchArmed(query)) { setFound({ items: [], loading: false, failure: null }); return undefined; } let live = true; setFound((current) => ({ ...current, loading: true, failure: null })); const timer = window.setTimeout(() => { patientOptions(query) .then((items) => { if (live) setFound({ items, loading: false, failure: null }); }) .catch((searchError) => { if (live) { setFound({ items: [], loading: false, failure: searchError?.message || 'Recherche indisponible', }); } }); }, 220); return () => { live = false; window.clearTimeout(timer); }; }, [query, patientOptions]); const chip = (selected) => ({ padding: '7px 12px', borderRadius: tokens.radius.pill, background: selected ? tokens.color.ink : tokens.color.surface, color: selected ? tokens.color.canvas : tokens.color.ink, border: `1px solid ${selected ? tokens.color.ink : tokens.color.border}`, fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer', }); // Une pastille de priorité porte sa couleur : teintée et bordée quand elle // est retenue, un simple point quand elle ne l'est pas. const priorityChip = (id, selected) => { const teinte = priorityBar(id, tokens); return { display: 'inline-flex', alignItems: 'center', gap: 7, padding: '7px 12px', borderRadius: tokens.radius.pill, background: selected ? `${teinte}1A` : tokens.color.surface, color: selected ? teinte : tokens.color.ink, border: `1px solid ${selected ? teinte : tokens.color.border}`, fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer', }; }; const caption = { ...tokens.type.caption, color: tokens.color.secondary, textTransform: 'uppercase', display: 'block', marginBottom: 6 }; return }> Échéance {/* Un rappel peut n'avoir aucune échéance – « ranger les consentements » n'a pas de date. La retirer est un geste, pas un état caché. */}
setDue(due ? null : defaultDue)} style={chip(!due)}>Sans échéance
Priorité
{contract.PRIORITIES.map((id) => ( setPriority(id)} style={priorityChip(id, priority === id)}> ))}
Patient {typeof patientOptions === 'function' ? <> option.id} getOptionLabel={(option) => contract.patientOptionLabel(option)} getOptionMeta={(option) => contract.patientOptionMeta(option)} onChange={(id) => { const chosen = found.items.find((option) => option.id === id); setPatient(chosen ? { id: chosen.id, label: contract.patientOptionLabel(chosen) } : null); }} /> {/* Se détacher d'un dossier est un geste, pas un état caché. */} {patient &&
{ setPatient(null); setQuery(''); }} style={chip(false)}>Retirer le patient
} :
La recherche de dossiers est indisponible : ce rappel ne visera personne.
} {error &&
{error}
}
; } function VascoAccountRemindersScreen({ runtime, onBack, now = null }) { const { tokens, ScreenHeader, StatusNotice, Button, ChoiceChip, IconButton } = global.VascoUI; const [status, setStatus] = React.useState('open'); const [filters, setFilters] = React.useState(contract.DEFAULT_FILTERS); const [state, setState] = React.useState({ kind: 'loading' }); const [busy, setBusy] = React.useState(false); const [failure, setFailure] = React.useState(null); const [creating, setCreating] = React.useState(false); const [createError, setCreateError] = React.useState(null); // L'instant de référence est figé au chargement : sans cela, une liste // relue une minute plus tard pourrait faire basculer un rappel de // « aujourd'hui » à « en retard » sous les yeux du praticien. const reference = React.useMemo( () => (now ? new Date(now) : new Date()), [now, state.kind === 'ready' ? state.at : null], ); const timeZone = global.VASCO_RUNTIME?.dashboard?.time_zone || 'Europe/Paris'; const load = React.useCallback(async () => { setState({ kind: 'loading' }); setFailure(null); try { const page = await runtime.listAll(status); setState({ kind: 'ready', items: page.items, complete: page.complete, at: Date.now() }); } catch (error) { setState({ kind: 'failed', message: error?.message || 'Rappels indisponibles' }); } }, [runtime, status]); React.useEffect(() => { void load(); }, [load]); // Une commande qui échoue ne fait pas disparaître la liste : elle se dit, // et ce qui est à l'écran reste vrai jusqu'à la relecture. const run = async (action, reminder, label) => { setBusy(true); setFailure(null); try { await action(reminder); await load(); } catch (error) { setFailure(`${label} : ${error?.message || 'le serveur a refusé'}`); } finally { setBusy(false); } }; // Ouvrir le rendez-vous d'un rappel n'est pas une commande du serveur : c'est // un changement d'écran, que la coque seule sait faire. La page le demande // par le transport déjà publié par le panneau de la cloche. const appointments = global.VascoOpenAppointment || null; const openAppointment = appointments ? (reminder) => { appointments.request(reminder.appointmentId); } : null; const create = async ({ text, dueAtLocal, priority, patientId }) => { const draft = contract.newReminderDraft({ text, dueAtLocal, priority, patientId }); if (!draft) return; setBusy(true); setCreateError(null); try { await runtime.create(draft); setCreating(false); await load(); } catch (error) { setCreateError(error?.message || 'Le serveur a refusé ce rappel'); } finally { setBusy(false); } }; const page = React.useMemo(() => contract.composePage({ reminders: state.kind === 'ready' ? state.items : [], filters, now: reference, timeZone, complete: state.kind === 'ready' ? state.complete : true, }), [state, filters, reference, timeZone]); const groupChoices = contract.GROUPS.map((group) => ({ id: group.id, label: group.label, count: page.groupCounts[group.id], })); const notice = contract.truncationNotice(page); const statusChip = (selected) => ({ flex: 1, minHeight: 34, padding: '7px 10px', borderRadius: tokens.radius.md, border: 'none', background: selected ? tokens.color.surface : 'transparent', color: selected ? tokens.color.ink : tokens.color.secondary, fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600, cursor: 'pointer', }); return
{ setCreateError(null); setCreating(true); }}> } />
{STATUS_FILTERS.map(([id, label]) => ( setStatus(id)} style={statusChip(status === id)}>{label} ))}
setFilters((current) => ({ ...current, group }))} /> {notice && } {failure && } {state.kind === 'loading' &&
Lecture des rappels…
} {state.kind === 'failed' &&
} {state.kind === 'ready' && page.emptiness === 'none' &&
{status === 'open' ? 'Rien à faire pour le moment.' : 'Aucun rappel terminé.'}
} {state.kind === 'ready' && page.emptiness === 'filtered' &&
Aucun rappel ne correspond à ces filtres. {page.loaded} rappel {page.loaded > 1 ? 's sont chargés' : ' est chargé'}.
} {state.kind === 'ready' && page.sections.map((section) => { const appearance = groupAppearance(section.tone, tokens); return
{section.items.map((reminder) => ( void run(runtime.complete, item, 'Rappel non coché')} onReopen={(item) => void run(runtime.reopen, item, 'Rappel non rouvert')} onArchive={(item) => void run(runtime.archive, item, 'Rappel non archivé')} onOpenAppointment={openAppointment} /> ))}
; })}
{/* La feuille reste montée et reçoit son état : la retirer au moment de fermer supprimerait son animation de sortie. */} { setCreating(false); setCreateError(null); }} onCreate={(draft) => void create(draft)} />
; } global.VascoAccountRemindersScreen = VascoAccountRemindersScreen; })(window);