// Statistiques : l'écran personnel du praticien.
//
// Il servait six compteurs, deux graphiques de quatre-vingts pixels et deux
// promesses. Trois de ses chiffres étaient faux (des périodes de durées
// inégales comparées, un « +600 % » sur un passage de 1 à 7, un total de courbe
// qui contredisait le chiffre du haut).
//
// Il porte maintenant deux chiffres justes, un calendrier où chaque journée
// s'ouvre, le temps passé au bloc et en consultation comparé à la période
// précédente, et le relevé des actes opérés.
//
// Une règle tient tout l'écran : ce qu'une case affiche, le clic doit le
// montrer. Le calendrier compte donc les rendez-vous de l'agenda, jamais un
// total venu d'ailleurs.
const SA_VT = window.VT || {};
const SA_INK = SA_VT.ink || '#0a0a0a';
const SA_SUB = SA_VT.sub || '#6b7280';
const SA_SURFACE = SA_VT.surface || '#fff';
const SA_BG = SA_VT.paper || '#f6f5f1';
const SA_HAIR = SA_VT.hair || 'rgba(10,10,10,0.08)';
const SA_ACCENT = SA_VT.medBlue || '#3d6b8f';
const SA_SUCCESS = SA_VT.success || '#059669';
const SA_DANGER = SA_VT.danger || '#b91c1c';
// Deux poids d'un seul bleu plutôt que deux couleurs : le bloc est plein, la
// consultation est en voile. Le rouge reste réservé au danger, et une palette
// de plus n'aurait rien appris à l'œil.
const SA_BLOC = SA_ACCENT;
const SA_CONSULT = 'rgba(61,107,143,0.24)';
const SA_OTHER = 'rgba(10,10,10,0.13)';
const SA_EMPTY = 'rgba(10,10,10,0.05)';
const SA_DASH = 'rgba(10,10,10,0.16)';
const SA_KIND_FILL = { bloc: SA_BLOC, consultation: SA_CONSULT, autre: SA_OTHER };
const SA_KIND_INK = { bloc: '#fff', consultation: SA_INK, autre: SA_INK };
const SA_PERIODS = [['week', 'Semaine'], ['month', 'Mois'], ['year', 'Année']];
function SaCard({ card, previousLabel }) {
return (
{card.value}
{card.label}
{card.rhythm &&
{card.rhythm}
}
{/* Une baisse d'activité n'est pas un danger : elle ne porte donc pas le
rouge, réservé à ce qui menace le patient. */}
{card.trend &&
vs {previousLabel}
}
);
}
function SaSection({ title, note, children, extra }) {
return (
{title}
{extra}
{note &&
{note}
}
{!note && }
{children}
);
}
// Une flèche de défilement. La période entière recule ou avance : les chiffres,
// le calendrier, le temps passé et les actes suivent ensemble.
function SaStep({ label, onClick, back = false, disabled = false }) {
const { IconButton } = window.VascoUI;
return (
);
}
// Le pourcentage était écrit en dix pixels de gris clair, tout au bout de la
// ligne : personne ne le voyait. Il porte maintenant une pastille, comme les
// états ailleurs dans l'application.
function SaDelta({ trend, size = 'normal' }) {
if (!trend) return null;
const up = trend.direction === 'up';
const flat = trend.direction === 'flat';
return (
{flat ? 'stable' : `${trend.percent > 0 ? '+' : ''}${trend.percent} %`}
);
}
// Un carré par jour. Sa COULEUR dit ce qui s'y est passé, son nombre dit
// combien de rendez-vous. Le nombre seul ne distinguait pas une journée de
// consultation d'une journée de bloc, et l'échelle d'intensité qui le colorait
// se lisait comme un maximum sans qu'on sache maximum de quoi.
function SaCalendar({ calendar, selected, onPick, typed }) {
const { IconButton } = window.VascoUI;
const contract = window.VascoStatsActivite;
return (
))}
{calendar.weeks.map((week, weekIndex) => week.map((day, dayIndex) => {
if (!day) return ;
const picked = selected === day.key;
const fill = day.value === 0 ? SA_EMPTY
: day.kind ? SA_KIND_FILL[day.kind] : SA_CONSULT;
const ink = day.value === 0 ? 'transparent'
: day.kind ? SA_KIND_INK[day.kind] : SA_INK;
const what = day.value === 0 ? 'rien'
: day.kind === 'bloc' ? `${day.value} au bloc ou en consultation`
: `${day.value} rendez-vous`;
// Ce que l'agenda ne peut pas rendre se dit sur la case, pas seulement
// au bas de la section : c'est cette journée-là qui est incomplète, et
// son détail le répète en toutes lettres.
const hidden = day.offAgenda > 0
? day.offAgenda === 1 ? ', et 1 hors agenda' : `, et ${day.offAgenda} hors agenda`
: '';
return (
onPick(picked ? '' : day.key)}
style={{
position: 'relative',
width: '100%', height: 'auto', aspectRatio: '1', borderRadius: 7,
background: day.future && day.value === 0 ? 'transparent' : fill,
// Le jour à venir garde sa place et son trait pointillé, même
// quand il porte déjà un rendez-vous : ce qui est prévu ne doit
// pas se lire comme ce qui est fait.
border: day.future ? `1px dashed ${SA_DASH}` : 'none',
opacity: day.future && day.value > 0 ? 0.5 : 1,
color: ink,
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: 11.5, fontWeight: 700, fontVariantNumeric: 'tabular-nums',
boxShadow: picked ? `0 0 0 2px ${SA_SURFACE}, 0 0 0 3.5px ${SA_INK}`
: day.today ? `0 0 0 2px ${SA_SURFACE}, 0 0 0 3.5px ${SA_ACCENT}` : 'none',
}}>
{day.value > 0 ? day.value : day.day}
{/* Le coin marqué dit « il manque quelque chose ici » sans
prétendre le compter à la place du nombre, qui reste celui de
l'agenda. Le libellé accessible porte le combien. */}
{day.offAgenda > 0 && }
);
}))}
{/* La légende nomme les deux couleurs. L'ancienne graduait une intensité
sans jamais dire de quoi, et « 3 » se lisait comme un plafond. */}
{typed && (
{contract.DAY_KINDS.map(([key, label]) => (
{label}
))}
)}
);
}
// Ce que la journée a contenu, en entier. C'est la réponse à « le 15, j'ai fait
// quoi ? », que ni un chiffre ni une couleur ne donnent.
function SaDayDetail({ detail, loading, offAgenda }) {
const contract = window.VascoStatsActivite;
return (
)}
{/* Ce qui manque est nommé avec sa cause, là où le praticien le cherche :
sur la journée qu'il vient d'ouvrir. */}
{!loading && offAgenda > 0 && (
{offAgenda === 1
? 'Un rendez-vous de cette journée n’est pas dans votre agenda'
: `${offAgenda} rendez-vous de cette journée ne sont pas dans votre agenda`}
{' : sans service, ou dans un service où votre session n’est pas ouverte.'}
)}
{!loading && detail.entries.map((entry) => (
{entry.start} à {entry.end}
{entry.patient}
{/* Le rouge dit l'urgence, comme dans l'agenda. Rien d'autre ici
ne le porte. */}
{entry.urgent && URGENT}
);
}
// Douze mois ne tiennent pas dans une grille de semaines : l'année garde des
// barres, une par mois.
function SaYearBars({ calendar }) {
const max = Math.max(calendar.max, 1);
return (
);
}
// Le temps passé, pas le nombre de rendez-vous : une consultation de vingt
// minutes et un pontage de trois heures ne pèsent pas pareil dans une semaine.
function SaWorkload({ workload, periodLabel, previousLabel }) {
const contract = window.VascoStatsActivite;
const max = Math.max(workload.max, 1);
return (
{/* Le chiffre d'avant est écrit sous la barre : un pourcentage tout
seul oblige à deviner ce qu'il compare. */}
{row.previous > 0
? `${contract.hoursLabel(row.previous)} ${previousLabel}, sur la même durée`
: `rien ${previousLabel} sur la même durée`}
))}
{/* « La même durée » n'est pas un détail : le 17 du mois, on compare
dix-sept jours à dix-sept jours, pas à un mois entier. Chaque ligne
porte déjà son chiffre d'avant ; il ne reste ici qu'à expliquer un
pourcentage absent. */}
{workload.trend
? `Chaque comparaison porte sur la même durée : ${periodLabel} face au même nombre de jours ${previousLabel}.`
: workload.previousTotal > 0
? `Sur la même durée, ${previousLabel} ne comptait que ${contract.hoursLabel(workload.previousTotal)} : trop peu pour comparer.`
: `Rien ${previousLabel} sur la même durée : pas de comparaison possible.`}
);
}
// Le relevé des actes : « quel type d'intervention, et combien de fois ».
function SaProcedures({ tally }) {
return (
{tally.map((row) => (
{row.label}
× {row.value}
))}
);
}
function StatsActiviteScreen({ accent, runtime, onBack }) {
const { IconButton, ChoiceChip, InlineAction } = window.VascoUI;
const contract = window.VascoStatsActivite;
const [period, setPeriod] = React.useState('month');
const [anchor, setAnchor] = React.useState('');
const [response, setResponse] = React.useState(null);
const [error, setError] = React.useState(null);
const [agenda, setAgenda] = React.useState(null);
const [selected, setSelected] = React.useState('');
React.useEffect(() => {
let cancelled = false;
setResponse(null); setError(null); setAgenda(null); setSelected('');
runtime.activity(period, anchor)
.then((value) => {
if (cancelled) return undefined;
setResponse(value);
// Le détail et le temps passé viennent de l'Agenda, qui ne se lit que
// sur une plage courte. Un refus est une réponse : l'écran garde ses
// totaux et se prive du détail, il n'invente rien.
const current = value?.current_range;
const previous = value?.previous_range;
const read = runtime.activityAppointments;
if (typeof read !== 'function' || !current?.from || !current?.to) {
setAgenda({ items: [], previous: [], covered: false });
return undefined;
}
return Promise.all([
read(current.from, current.to),
previous?.from && previous?.to
? read(previous.from, previous.to).catch(() => ({ items: [] }))
: Promise.resolve({ items: [] }),
]).then(([now, before]) => {
if (!cancelled) setAgenda({ items: now.items, previous: before.items, covered: now.covered });
}).catch(() => {
if (!cancelled) setAgenda({ items: [], previous: [], covered: false });
});
})
.catch((failure) => {
if (!cancelled) setError(failure?.message || 'Activité indisponible');
});
return () => { cancelled = true; };
}, [runtime, period, anchor]);
const view = response ? contract.activityView(response, {
appointments: agenda?.items,
previousAppointments: agenda?.previous,
covered: agenda?.covered === true,
}) : null;
const detail = view && selected
? contract.dayDetail(selected, agenda?.items, response?.series?.clinical_reports,
response?.time_zone)
: null;
// Le compte du serveur pour la journée ouverte : ce que l'agenda n'a pas pu
// rendre. Il vient de la même case que le praticien a touchée.
const selectedOffAgenda = view && selected
? (view.calendar.days || []).find((day) => day.key === selected)?.offAgenda || 0
: 0;
return (
{/* Les mêmes pastilles que partout ailleurs dans l'application, plutôt
qu'un troisième sélecteur de période dessiné à la main. */}
{/* Dire où l'on en est dans la période était le seul moyen honnête de
garder une comparaison : sans ça, onze jours se lisaient comme un
mois. Les deux flèches déplacent TOUT l'écran, pas seulement le
calendrier : c'est pour ça qu'elles sont ici et pas dans une carte. */}
{view &&
setAnchor(view.steps.previous)} />
{view.elapsed.complete
? `${view.periodLabel} · ${view.activeDays} jours travaillés`
: `${view.periodLabel} · ${view.elapsed.done} jours sur ${view.elapsed.total}, dont ${view.activeDays} travaillés`}
{/* On ne va pas voir l'activité d'un mois qui n'a pas eu lieu. */}
setAnchor(view.steps.next)}
disabled={view.current} />
{!view.current && setAnchor('')}>
Aujourd'hui
}
}
{error && (
{error}
)}
{!view && !error && (
)}
{view && view.empty && (
{/* « Rien à compter 2025 » se lisait de travers : une période
passée porte un nom, pas un complément de temps. */}
{view.current ? `Rien à compter ${view.periodLabel}` : 'Rien à compter sur cette période'}
Les chiffres apparaissent au fur et à mesure que vous consultez et que vous opérez.
)}
{view && !view.empty && <>
{view.cards.map((card) => )}
{view.calendar.mode === 'days'
?
: }
{detail && }
{/* Ce qui manque se dit une fois, en nommant la cause. Un rendez-vous
sans service n'entre pas dans l'agenda : ce n'est ni une erreur
de saisie, ni un choix de cet écran. */}
{view.calendar.mode === 'days' && !view.missing.complete
&& view.missing.offAgenda > 0 && (
{view.missing.offAgenda} rendez-vous de la période ne sont pas dans votre
agenda : ils ne portent aucun service, ou un service où votre session n'est
pas ouverte. Les journées concernées sont marquées.