// 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 (
{contract.WEEKDAY_INITIALS.map((initial, index) => (
{initial}
))} {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 (
{contract.longDayLabel(detail.dayKey)}
{detail.reports > 0 &&
{detail.reports === 1 ? '1 compte rendu écrit' : `${detail.reports} comptes rendus écrits`}
}
{loading &&
Lecture de l'agenda…
} {!loading && detail.entries.length === 0 && offAgenda === 0 && (
Rien ce jour-là.
)} {/* 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}
{entry.typeLabel}{entry.reason ? ` · ${entry.reason}` : ''}
{entry.place &&
{entry.place}
}
))}
); } // 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 (
{calendar.bars.map((bar) => (
0 ? SA_INK : 'transparent', }}>{bar.value}
0 ? SA_ACCENT : SA_HAIR, opacity: bar.future ? 0.35 : 1, }} />
{bar.label}
))}
); } // 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 (
{workload.rows.map((row) => (
{row.label} {contract.hoursLabel(row.minutes)} {row.count === 1 ? '1 rendez-vous' : `${row.count} rendez-vous`}
{/* 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. */}
{SA_PERIODS.map(([value, label]) => ( { setPeriod(value); setAnchor(''); }}>{label} ))}
Mon activité
{/* 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.
)}
{view.workload && view.workload.total > 0 && ( {contract.hoursLabel(view.workload.total)}
}> )} {view.tally.length > 0 ? :
{view.covered ? 'Aucune intervention sur cette période.' : 'Le relevé des actes se lit sur une semaine ou un mois.'}
}
}
); } // Remplace l'écran figé de la V5 : il ne s'y ajoute pas. StatsScreen = StatsActiviteScreen; window.StatsScreen = StatsActiviteScreen;