);
}
// ─────────────────────────────────────────────────────────────
// Rappel Bell — popover with reminders + add form
// ─────────────────────────────────────────────────────────────
const RAPPEL_INIT = [
{ id: 1, text: 'Appeler M. Martin — résultat INR', date: '30/04 · 16:00', patient: 'M. Martin', priority: 'high', done: false },
{ id: 2, text: 'Commander prothèse PTFE 6mm Bloc A', date: '01/05 · 09:00', patient: null, priority: 'medium', done: false },
{ id: 3, text: 'Vérifier doppler Mme Durand post-op', date: '01/05 · 14:00', patient: 'Mme Durand', priority: 'high', done: false },
{ id: 4, text: 'Envoyer courrier Dr. Legrand', date: '02/05 · 10:00', patient: null, priority: 'low', done: true },
{ id: 5, text: 'RDV staff vasculaire M. Bernard', date: '03/05 · 11:00', patient: 'M. Bernard', priority: 'medium', done: false },
];
const PRIO_STYLE = {
high: { dot: '#b91c1c', bg: 'rgba(185,28,28,0.06)' },
medium: { dot: '#d97706', bg: 'rgba(217,119,6,0.06)' },
low: { dot: '#9ca3af', bg: 'rgba(10,10,10,0.03)' },
};
// Libellés des `reason_code` du tableau de bord, repris mot pour mot de
// l'application native afin qu'un refus s'énonce de la même façon partout.
const DB_REASONS = {
role_not_supported: 'Rôle non pris en charge',
feature_unavailable: 'Fonction indisponible',
source_unavailable: 'Source indisponible',
provider_unavailable: 'Fournisseur indisponible',
reference_unavailable: 'Référentiel indisponible',
target_unavailable: 'Cible indisponible',
patient_archived: 'Patient archivé',
patient_deceased: 'Patient décédé',
document_unavailable: 'Document indisponible',
document_state_changed: 'État du document modifié',
document_not_owned: 'Document non attribué',
feature_not_in_j1: 'Fonction hors périmètre',
reminder_open: 'Rappel déjà ouvert',
reminder_completed: 'Rappel terminé',
reminder_archived: 'Rappel archivé',
};
const dbReason = (capability) => DB_REASONS[capability?.reason_code] || 'Action indisponible';
// Un message d'erreur inline vaut mieux qu'un toast qui disparaît : le
// praticien doit pouvoir relire pourquoi sa commande a été refusée.
function DbBanner({ text, tone = 'error' }) {
if (!text) return null;
const color = tone === 'error' ? DANGER : SUB;
return (
{text}
);
}
// `datetime-local` rend une heure locale sans fuseau ; l'API attend un instant.
function localInputToIso(value) {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
function RappelBell({ accent, runtime = null, capability = null, activeCount = null }) {
const [open, setOpen] = React.useState(false);
const [rappels, setRappels] = React.useState(RAPPEL_INIT);
const [loaded, setLoaded] = React.useState(false);
const [showAdd, setShowAdd] = React.useState(false);
const [newText, setNewText] = React.useState('');
const [newDate, setNewDate] = React.useState('');
const [newPatient, setNewPatient] = React.useState('');
const [newPrio, setNewPrio] = React.useState('medium');
const [patients, setPatients] = React.useState([]);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
const ref = React.useRef(null);
const allowed = !capability || capability.enabled;
const active = rappels.filter(r => !r.done);
const done = rappels.filter(r => r.done);
// La pastille annonce ce que le panneau va montrer. L'aperçu du tableau de
// bord s'arrête à cinq rappels : en compter les lignes faisait dire « 5 » à
// la pastille pour six rappels ouverts, et le chiffre ne se corrigeait qu'à
// l'ouverture. 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 passées depuis le panneau.
const badge = loaded || activeCount === null ? active.length : activeCount;
React.useEffect(() => {
if (!open) return;
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) { setOpen(false); setShowAdd(false); } };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// L'aperçu du tableau de bord est tronqué : à l'ouverture on recharge la
// liste complète, ouverts et terminés, avec les versions à jour.
const reload = React.useCallback(async () => {
if (!runtime) return;
setBusy(true);
try {
setRappels(await runtime.reminders.load());
setLoaded(true);
setError(null);
} catch (failure) {
setError(failure?.message || 'Chargement des rappels impossible');
} finally {
setBusy(false);
}
}, [runtime]);
React.useEffect(() => { if (open) void reload(); }, [open, reload]);
React.useEffect(() => {
if (!open || !showAdd || !runtime?.patients) return;
let cancelled = false;
runtime.patients()
.then((list) => { if (!cancelled) setPatients(list); })
.catch(() => { if (!cancelled) setPatients([]); });
return () => { cancelled = true; };
}, [open, showAdd, runtime]);
// Une commande refusée ne doit rien changer à 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);
setRappels((list) => (action === 'archive'
? list.filter((item) => item.id !== reminder.id)
: list.map((item) => (item.id === updated.id ? updated : item))));
// La ligne du rappel change à l'écran — reportée, archivée, terminée.
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(dbReason(capabilities)); return; }
void runCommand(reminder, target);
};
const remove = (reminder) => {
const capabilities = reminder.capabilities?.archive;
if (capabilities && !capabilities.enabled) { setError(dbReason(capabilities)); return; }
void runCommand(reminder, 'archive');
};
const addRappel = async () => {
if (!newText.trim() || !runtime) return;
setBusy(true);
setError(null);
try {
const created = await runtime.reminders.create({
text: newText.trim(),
dueAt: localInputToIso(newDate),
patientId: newPatient || null,
priority: newPrio,
});
setRappels((list) => [created, ...list]);
setNewText(''); setNewDate(''); setNewPatient(''); setNewPrio('medium');
// Le rappel créé apparaît en tête de liste, formulaire refermé.
setShowAdd(false);
await runtime.refresh().catch(() => {});
} catch (failure) {
setError(failure?.message || 'Création du rappel impossible');
} finally {
setBusy(false);
}
};
return (
{open && (
{/* Header */}
Rappels
{badge > 0 && (
{badge}
)}
{/* Add form */}
{showAdd && (
setNewText(e.target.value)}
placeholder="Ex : appeler M. Martin pour son INR"
style={{ width: '100%', border: `1px solid ${HAIR_2}`, borderRadius: 12, padding: '8px 12px', fontFamily: 'inherit', fontSize: 13, fontWeight: 500, color: INK, outline: 'none', marginBottom: 8, background: PAPER }} />
{/* L'échéance est un instant côté serveur et le patient une clé :
les champs libres de la maquette ne pouvaient pas être
transmis, ils sont remplacés par une date et une liste. */}
{/* Le corps d'une notification nomme un patient en clair.
Quand le serveur refuse d'ouvrir la cible, la raison ne
vivait que dans le `title` — invisible au doigt : une
alerte au nom d'un dossier archivé se lisait comme un
dossier ouvert. Elle se lit maintenant sous la ligne,
comme sous les commandes de l'agenda. */}
{n.targetCapability && !n.targetCapability.enabled && (
);
}
// ─────────────────────────────────────────────────────────────
// Hero — outline mic 180px
// ─────────────────────────────────────────────────────────────
function MicHero({ accent, recording, onTap, onQuick }) {
const [pressed, setPressed] = React.useState(false);
const [elapsed, setElapsed] = React.useState(0);
// Maintien = raccourci vers la dictée rapide. Ce n'est qu'un raccourci : le
// bouton explicite sous le micro reste le chemin principal, un geste invisible
// ne doit pas être la seule porte d'entrée.
const holdTimer = React.useRef(null);
const didHold = React.useRef(false);
const startHold = () => {
setPressed(true);
didHold.current = false;
holdTimer.current = setTimeout(() => {
didHold.current = true;
onQuick && onQuick();
}, 600);
};
const endHold = () => {
setPressed(false);
if (holdTimer.current) clearTimeout(holdTimer.current);
};
React.useEffect(() => () => { if (holdTimer.current) clearTimeout(holdTimer.current); }, []);
// Run a 1Hz timer while recording — reset to 0 when recording stops.
React.useEffect(() => {
if (!recording) {setElapsed(0);return;}
const start = Date.now();
setElapsed(0);
const id = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 250);
return () => clearInterval(id);
}, [recording]);
const mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
const ss = String(elapsed % 60).padStart(2, '0');
const ringScale = pressed || recording ? 1.04 : 1;
return (
{
// Le maintien a déjà lancé la dictée rapide : on n'enchaîne pas.
if (didHold.current) { didHold.current = false; return; }
onTap && onTap();
}}
onMouseDown={startHold}
onMouseUp={endHold}
onMouseLeave={endHold}
onTouchStart={startHold}
onTouchEnd={endHold}
style={{
position: 'relative',
width: 180, height: 180, borderRadius: '50%',
border: `2px solid ${recording ? accent : INK}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
transform: `scale(${ringScale})`,
transition: 'transform .18s cubic-bezier(.2,.7,.3,1), border-color .2s ease',
cursor: 'pointer',
background: recording ? `${accent}10` : 'transparent',
padding: 0,
fontFamily: 'inherit'
}}>
{/* Inner ring — accent at 50% opacity, full when recording */}
{recording && }
{/* Glyph: mic when idle, stop square when recording */}
{recording ?
{/* solid square = stop, no inner glyph needed */}
:
}
{recording ?
<>
{mm}:{ss}TAP POUR ARRÊTER & OUVRIR LE CHAT
> :
<>
Dicter →
Tapez pour choisir un patient
{/* Chemin explicite vers la dictée sans patient — le maintien du
micro fait la même chose, mais ne peut pas être le seul accès. */}
onQuick && onQuick()} className="press" style={{
marginTop: 10, height: 34, padding: '0 14px', borderRadius: 999,
background: MED_TINT, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600, color: MED,
display: 'flex', alignItems: 'center', gap: 6,
}}>
Dictée rapide sans patient
>
}
{counts.all === 0
? 'Tout est validé.'
: `Aucun ${filter} en attente.`}
)}
{!loading && shown.map((r) => (
{r.type}
{r.procedure}
{r.patient} · {r.started}
{/* La maquette validait en un clic, sans que le contenu ait été
affiché. L'API l'interdit : elle réclame l'empreinte du texte
relu. Le bouton ouvre donc la relecture. */}
void openDetail(r.id)}
disabled={busy || !runtime || r.capabilities?.read?.enabled === false}
title={r.capabilities?.read?.enabled === false ? dbReason(r.capabilities.read) : 'Relire avant validation'}
className="press" style={{
flexShrink: 0, height: VT.hSm, padding: '0 12px', borderRadius: 999,
background: INK, border: 'none', color: '#fff',
fontFamily: 'inherit', fontSize: 12, fontWeight: 700,
cursor: busy || !runtime ? 'default' : 'pointer',
opacity: busy || !runtime ? 0.5 : 1,
}}>Relire
))}
)}
);
}
// La feuille de validation est remontée au Dashboard : les chips l'ouvrent
// aussi, on ne peut donc plus la garder dans l'état du carrousel.
// Sparkline des CR dictés — barres, pas de courbe : sur 40px de haut une
// courbe à 6 points est illisible, des barres se lisent d'un coup d'œil.
function StatSpark({ data, accent }) {
const max = Math.max(...data, 1);
return (
{data.map((v, i) => (
))}
);
}
function SmartStack({ accent, paused, onResume, onOpenAgenda, pending = [], onOpenCR, onOpenStats }) {
const [activeIdx, setActiveIdx] = React.useState(0);
const [showCCAMSheet, setShowCCAMSheet] = React.useState(false);
const startX = React.useRef(null);
const [sliding, setSliding] = React.useState(null); // 'left' | 'right' | null
const dashboardPayload = window.VASCO_RUNTIME && window.VASCO_RUNTIME.dashboard;
const timeZone = dashboardPayload?.time_zone || 'Europe/Paris';
const ccamCapability = dashboardPayload?.capabilities?.search_ccam || null;
// Le prochain rendez-vous était écrit en dur dans la maquette (« 14:30,
// M. Martin, Salle 2 ») : la carte annonçait donc un rendez-vous qui
// n'existait pas. Elle lit maintenant `next_appointment`, et disparaît quand
// le serveur n'en renvoie aucun.
const nextAppointment = dashboardPayload?.next_appointment || null;
const nextRdv = nextAppointment ? {
time: new Intl.DateTimeFormat('fr-FR', {
hour: '2-digit', minute: '2-digit', hour12: false, timeZone,
}).format(new Date(nextAppointment.starts_at)),
patient: nextAppointment.patient.display_name,
// Archiver n'annule pas le rendez-vous : il reste le prochain, et la carte
// le dit plutôt que d'annoncer un patient dont le dossier est rangé.
archived: nextAppointment.patient.archived === true,
lieu: nextAppointment.location || nextAppointment.reason,
startsAt: new Date(nextAppointment.starts_at),
} : null;
// La charge vient du serveur quand il la calcule ; sinon elle reste dérivée
// de l'agenda affiché, jamais d'un second jeu de chiffres inventé.
const dayLoad = dashboardPayload?.day_load || null;
const charge = dayLoad ? {
consult: dayLoad.consultations, interv: dayLoad.procedures, urgence: dayLoad.emergencies,
} : AGENDA_TODAY.reduce((acc, e) => {
if (e.kind === 'CONSULTATION') acc.consult++;
else if (e.kind === 'BLOC') acc.interv++;
else if (e.kind === 'URGENCE') acc.urgence++;
return acc;
}, { consult: 0, interv: 0, urgence: 0 });
const diffMin = nextRdv ? Math.round((nextRdv.startsAt.getTime() - Date.now()) / 60000) : 0;
const countdown = !nextRdv ? '—'
: diffMin > 0 ? `dans ${Math.floor(diffMin / 60)}h${String(diffMin % 60).padStart(2, '0')}`
: 'maintenant';
// Build cards array
const cards = [];
// « À valider » en tête : c'est l'action la plus attendue en ouvrant l'app.
if (pending.length > 0) cards.push('cr');
if (paused) cards.push('pause');
if (nextRdv) cards.push('rdv');
cards.push('charge');
cards.push('stats');
cards.push('ccam');
const total = cards.length;
// Les bornes sont appliquées au moment où l'index change, pas au moment du geste :
// la garde lisait `activeIdx` figé dans la fermeture du gestionnaire, si bien qu'un
// geste de trop sortait de la pile et affichait une carte vide.
const goNext = () => {
setSliding('left');
setTimeout(() => {
setActiveIdx((i) => Math.min(i + 1, total - 1));
setSliding(null);
}, 250);
};
const goPrev = () => {
setSliding('right');
setTimeout(() => {
setActiveIdx((i) => Math.max(i - 1, 0));
setSliding(null);
}, 250);
};
const onTouchStart = (e) => { startX.current = e.touches[0].clientX; };
const onTouchEnd = (e) => {
if (startX.current == null) return;
const dx = e.changedTouches[0].clientX - startX.current;
startX.current = null;
if (dx < -50) goNext();
else if (dx > 50) goPrev();
};
// Mouse drag
const onMouseDown = (e) => { startX.current = e.clientX; };
const onMouseUp = (e) => {
if (startX.current == null) return;
const dx = e.clientX - startX.current;
startX.current = null;
if (dx < -50) goNext();
else if (dx > 50) goPrev();
};
// Mouse wheel — scroll horizontally through cards
const wheelTimer = React.useRef(null);
const stackRef = React.useRef(null);
React.useEffect(() => {
const el = stackRef.current;
if (!el) return;
const handler = (e) => {
e.preventDefault();
if (wheelTimer.current) return;
if (Math.abs(e.deltaY) > 10 || Math.abs(e.deltaX) > 10) {
const dir = (e.deltaY > 0 || e.deltaX > 0) ? 1 : -1;
if (dir > 0) goNext(); else goPrev();
wheelTimer.current = setTimeout(() => { wheelTimer.current = null; }, 400);
}
};
el.addEventListener('wheel', handler, { passive: false });
return () => el.removeEventListener('wheel', handler);
}, [goNext, goPrev]);
// Animation style
let contentStyle = { transition: 'all .25s cubic-bezier(.25,.1,.25,1)' };
if (sliding === 'left') contentStyle = { ...contentStyle, transform: 'translateX(-30px)', opacity: 0 };
else if (sliding === 'right') contentStyle = { ...contentStyle, transform: 'translateX(30px)', opacity: 0 };
else contentStyle = { ...contentStyle, transform: 'translateX(0)', opacity: 1 };
// La pile se raccourcit toute seule : la carte « à valider » disparaît quand la file
// se vide, celle du prochain rendez-vous quand l'heure est passée. Sans ce recadrage,
// l'index resté au-delà de la dernière carte affichait une carte vide — un rectangle
// blanc sans un mot, à la place du tableau de bord.
React.useEffect(() => {
setActiveIdx((index) => (index > total - 1 ? Math.max(0, total - 1) : index));
}, [total]);
const currentCard = cards[Math.max(0, Math.min(activeIdx, total - 1))];
return (
{/* Colored left bar */}
{/* Content — animates on swipe */}
{[
// Le serveur compte ensemble les consultations et les suivis : dire
// « consultations » seul annonçait six consultations là où l'agenda
// du même jour en montre trois, plus trois suivis.
{ n: charge.consult, label: 'consult. et suivis' },
{ n: charge.interv, label: charge.interv > 1 ? 'interventions' : 'intervention' },
{ n: charge.urgence, label: charge.urgence > 1 ? 'urgences' : 'urgence', danger: true },
].map((s) => (
0 ? DANGER : INK,
}}>{s.n}
{s.label}
))}
)}
{currentCard === 'ccam' && (
RECHERCHE CCAM
{/* Le référentiel CCAM n'est pas raccordé : la maquette cherchait
dans une table de six actes écrite dans le fichier, ce qui
ressemblait à une vraie recherche. La carte affiche désormais
le motif renvoyé par le serveur au lieu de faux résultats. */}
{ccamCapability && !ccamCapability.enabled ? (
);
}
// ─────────────────────────────────────────────────────────────
// CCAM Bottom Sheet
// ─────────────────────────────────────────────────────────────
// L'API date en ISO. On reformate sans `new Date()` : une date nue interprétée comme un
// instant UTC recule d'un jour à l'affichage pour un fuseau à l'est de Greenwich.
function frenchDate(iso) {
const parts = String(iso || '').split('-');
return parts.length === 3 ? `${parts[2]}/${parts[1]}/${parts[0]}` : String(iso || '');
}
function CCAMBottomSheet({ onClose }) {
const [query, setQuery] = React.useState('');
const [copied, setCopied] = React.useState(null);
// La maquette filtrait quinze actes écrits dans ce fichier, dont les libellés et les
// tarifs étaient faux sous des codes réels. La recherche interroge maintenant le
// référentiel officiel importé en base, et il n'y a pas de repli local : tant qu'aucune
// requête n'a abouti, la feuille n'affiche rien plutôt qu'un extrait inventé.
const [results, setResults] = React.useState([]);
const [reference, setReference] = React.useState(null);
const [state, setState] = React.useState('idle');
React.useEffect(() => {
const term = query.trim();
if (!term) { setResults([]); setState('idle'); return undefined; }
let cancelled = false;
setState('loading');
// Le référentiel compte 8292 actes : on attend que la frappe se pose avant de
// demander, sinon chaque lettre déclenche une recherche qui sera jetée.
const handle = setTimeout(() => {
window.VascoApi.ccam.search(term).then((payload) => {
if (cancelled) return;
setResults(payload.items);
setReference(payload.referenceVersion ? payload : null);
setState(payload.items.length ? 'ready' : 'empty');
}).catch((error) => {
if (cancelled) return;
setResults([]);
// L'API rend `detail` en chaîne brute pour ce refus, donc `code` est nul :
// c'est le statut qui porte l'information, pas le corps.
setState(error?.status === 503 ? 'unavailable' : 'error');
});
}, 250);
return () => { cancelled = true; clearTimeout(handle); };
}, [query]);
// « Copié ✓ » ne s'affichait qu'à la faveur d'un `setTimeout` : rien n'était
// jamais écrit au presse-papiers. La confirmation attend maintenant que
// l'écriture ait abouti, comme la copie d'IPP du répertoire patients, et un
// refus du navigateur se dit au lieu de se déguiser en réussite.
const handleCopy = async (code) => {
try {
await copyToClipboard(code);
setCopied(code);
setTimeout(() => setCopied(null), 1500);
} catch {
showToast('Copie impossible — presse-papier refusé', 'error');
}
};
return (
{state === 'idle' && 'Décrivez l’acte ou saisissez son code.'}
{state === 'loading' && 'Recherche…'}
{state === 'empty' && 'Aucun acte du référentiel ne correspond.'}
{/* Ne jamais dire « aucun code trouvé » quand il n'y a pas de base :
ce serait affirmer que l'acte n'existe pas. */}
{state === 'unavailable' && 'Référentiel CCAM non chargé sur ce serveur.'}
{state === 'error' && 'La recherche a échoué. Réessayez.'}
{copied === c.code ? (
Copié ✓
) : (
{/* Pas de tarif opposable dans l'extrait : on l'écrit. Un 0 € en
ferait un acte gratuit, un tiret muet laisserait croire à un oubli. */}
{c.tariff || 'tarif non publié'}
)}
))}
{/* Un code recopié sans la version sous laquelle il a été lu n'est plus
vérifiable : la provenance reste affichée sous les résultats. */}
{reference && (
Référentiel {reference.referenceVersion} — {reference.effectiveFrom
? `en vigueur depuis le ${frenchDate(reference.effectiveFrom)}`
: `extrait du ${frenchDate(reference.referenceDate)}`}.
Tarifs à vérifier avant facturation.
)}
);
}
// ─────────────────────────────────────────────────────────────
// Dashboard composition
// ─────────────────────────────────────────────────────────────
function Dashboard({ accent, accentName, onOpenChat, onChangeTab, onOpenPatient, onSlideToChat, onOpenStats, runtime = null, dictationRuntime = null }) {
const runtimeDashboard = window.VASCO_RUNTIME && window.VASCO_RUNTIME.dashboard;
const practitionerName = runtimeDashboard?.practitioner?.display_name || 'Dr.X';
const patientsRemaining = runtimeDashboard?.summary?.patients_remaining ?? 12;
const capabilities = runtimeDashboard?.capabilities || null;
// Les aperçus du tableau de bord sont tronqués à cinq lignes, mais ils
// portent le décompte complet : c'est lui que les pastilles affichent.
const reminderCount = runtimeDashboard?.reminders_preview?.active_count ?? null;
const notificationCount = runtimeDashboard?.notifications_preview?.active_count ?? null;
const [tab, setTab] = React.useState('CRO');
const [recording, setRecording] = React.useState(false);
const [dictating, setDictating] = React.useState(false);
const [reports, setReports] = React.useState(TODO_REPORTS);
const [showCCAM, setShowCCAM] = React.useState(false);
// null = feuille fermée ; sinon 'all' ou le type de CR affiché.
const [crSheet, setCrSheet] = React.useState(null);
// Une notification « compte rendu à valider » ouvre directement la relecture
// du document visé, sans faire chercher la ligne dans la file.
const [focusReportId, setFocusReportId] = React.useState(null);
const bottomTab = 'DICT';
const setBottomTab = (id) => {onChangeTab && onChangeTab(id);};
const counts = {
CRO: reports.filter(r => r.type === 'CRO').length,
CRC: reports.filter(r => r.type === 'CRC').length,
CRH: reports.filter(r => r.type === 'CRH').length,
};
const totalPending = counts.CRO + counts.CRC + counts.CRH;
const tabs = [
{ id: 'CRO', label: 'CRO', count: counts.CRO },
{ id: 'CRC', label: 'CRC', count: counts.CRC },
{ id: 'CRH', label: 'CRH', count: counts.CRH }];
const paused = reports.find((r) => r.state === 'paused');
// Un tap sur une pastille ouvre la file de validation sur ce type.
const openType = (type) => { setTab(type); setCrSheet(type); };
// La feuille valide contre le serveur et se recharge elle-même ; il ne reste
// ici qu'à retirer la ligne des compteurs de l'accueil.
const validate = (document) => {
setReports((prev) => prev.filter((x) => x.id !== document.id));
};
return (
{/* Scrollable content above the tab bar */}
{/* Top section */}
{/* Le compteur ne porte que la journée en cours : il se vide le soir,
et sans « aujourd'hui » un 0 se lit comme « plus aucun patient ». */}
{patientsRemaining} patient{patientsRemaining > 1 ? 's' : ''} restant{patientsRemaining > 1 ? 's' : ''} aujourd’hui
·{totalPending} CR à finaliser
{/* Mic hero — centered between tabs and resume banner */}