// 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
;
}
// 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.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é. */}
{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);