// Dashboard — écran d'accueil / lancement de dictée // Brutalist editorial — strict B&W + un accent. const VT = window.VT || {}; const INK = VT.ink || '#0a0a0a'; const PAPER = VT.paper || '#f6f5f1'; const SURF = VT.surface || '#ffffff'; const SUB = VT.sub || '#6b7280'; const HAIR = VT.hair || 'rgba(10,10,10,0.06)'; const HAIR_2 = VT.hair2 || 'rgba(10,10,10,0.10)'; const SHADOW_SM = VT.shadowSm || '0 1px 2px rgba(10,10,10,0.04), 0 0 0 1px rgba(10,10,10,0.04)'; const SHADOW_MD = VT.shadowMd || '0 1px 3px rgba(10,10,10,0.06), 0 4px 12px rgba(10,10,10,0.04)'; const OK = VT.success || '#059669'; const MED = VT.medBlue || '#3d6b8f'; const MED_TINT = VT.medBlueTint || 'rgba(61,107,143,0.10)'; // Rouge réservé à l'alerte clinique critique, cf. §1 de l'audit. const DANGER = VT.danger || '#b91c1c'; // ───────────────────────────────────────────────────────────── // Atoms // ───────────────────────────────────────────────────────────── function Caption({ children, color, style = {} }) { return ( {children}); } function HRule({ heavy = false, style = {} }) { return
; } // Tabs CRO / CRC / CRH — underline only function UnderlineTabs({ tabs, active, onChange }) { return (
{tabs.map((t) => { const on = t.id === active; return ( ); })}
); } // ───────────────────────────────────────────────────────────── // 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. */}
setNewDate(e.target.value)} aria-label="Échéance du rappel" style={{ flex: 1, minWidth: 0, border: `1px solid ${HAIR_2}`, borderRadius: 12, padding: '8px 10px', fontFamily: "'JetBrains Mono', monospace", fontSize: 11, fontWeight: 500, color: INK, outline: 'none', background: PAPER }} />
{/* Priority */}
{[ { id: 'high', label: 'Haute' }, { id: 'medium', label: 'Moyenne' }, { id: 'low', label: 'Basse' }, ].map(p => ( ))}
)} {/* List */}
{rappels.length === 0 ? (
{busy ? 'Chargement des rappels…' : 'Aucun rappel'}
) : ( {active.map(r => { const ps = PRIO_STYLE[r.priority] || PRIO_STYLE.low; return (
); })} {done.length > 0 && (
Terminés
)} {done.map(r => (
{r.text}
))}
)}
)}
); } // ───────────────────────────────────────────────────────────── // Notification Bell — popover dropdown // ───────────────────────────────────────────────────────────── const NOTIF_DATA = [ { id: 1, type: 'alert', title: 'INR critique', text: 'M. Martin Jean — INR à 3.8, contrôle urgent', time: 'Il y a 12 min', priority: 'high', patientId: 'p-2210' }, { id: 2, type: 'rdv', title: 'RDV dans 30 min', text: 'Mme Petit Aline — Suivi FAV, Salle 2', time: 'Il y a 18 min', priority: 'medium', patientId: 'p-5023' }, { id: 3, type: 'doc', title: 'CR à finaliser', text: 'CRO Pontage fémoro-poplité — M. Martin', time: 'Il y a 1h', priority: 'medium', patientId: 'p-2210' }, { id: 4, type: 'sign', title: 'Signature requise', text: 'Ordonnance anticoagulants Mme Durand', time: 'Il y a 2h', priority: 'low', patientId: 'p-1987' }, { id: 5, type: 'patient', title: 'Nouveau patient', text: 'M. Garnier Robert enregistré par le secrétariat', time: 'Il y a 3h', priority: 'low', patientId: 'p-5511' }, { id: 6, type: 'postop', title: 'Alerte post-op', text: 'Mme Lefèvre J+5 — température 38.2°C', time: 'Il y a 4h', priority: 'high', patientId: 'p-4128' }, ]; const NOTIF_ICONS = { alert: <>, rdv: <>, doc: <>, sign: <>, patient: <>, postop: <>, }; const NOTIF_COLORS = { high: { bg: 'rgba(185,28,28,0.06)', dot: '#b91c1c' }, medium: { bg: 'rgba(217,119,6,0.06)', dot: '#d97706' }, low: { bg: 'rgba(10,10,10,0.03)', dot: '#9ca3af' }, }; function NotifBell({ accent, onNavigate, onOpenPatient, onOpenReport, runtime = null, capability = null, dismissCapability = null, activeCount = null }) { const [open, setOpen] = React.useState(false); const [notifs, setNotifs] = React.useState(NOTIF_DATA); const [loaded, setLoaded] = React.useState(false); const [snapshot, setSnapshot] = React.useState(null); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const ref = React.useRef(null); // Même règle que la pastille des rappels : l'aperçu du tableau de bord est // tronqué à cinq notifications, son compte n'est pas celui du serveur. La // pastille porte le décompte servi par `notifications_preview.active_count` // jusqu'à ce que la liste complète soit chargée, elle prend le relais ensuite. const count = loaded || activeCount === null ? notifs.length : activeCount; const allowed = !capability || capability.enabled; const canDismiss = !dismissCapability || dismissCapability.enabled; // Close on outside click React.useEffect(() => { if (!open) return; const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); const reload = React.useCallback(async () => { if (!runtime) return; setBusy(true); try { const page = await runtime.notifications.load(); setNotifs(page.items); setLoaded(true); setSnapshot(page); setError(null); } catch (failure) { setError(failure?.message || 'Chargement des notifications impossible'); } finally { setBusy(false); } }, [runtime]); React.useEffect(() => { if (open) void reload(); }, [open, reload]); const dismiss = async (notification) => { if (!runtime) return; if (!canDismiss) { setError(dbReason(dismissCapability)); return; } setBusy(true); setError(null); try { await runtime.notifications.dismiss(notification); setNotifs((list) => list.filter((item) => item.id !== notification.id)); await runtime.refresh().catch(() => {}); } catch (failure) { setError(failure?.message || 'Retrait impossible'); if (failure?.status === 409) await reload(); } finally { setBusy(false); } }; // « Tout effacer » est scellé par le jeton d'instantané : une notification // arrivée après l'affichage n'est pas emportée par le clic. const dismissAll = async () => { if (!runtime || !snapshot) return; if (!canDismiss) { setError(dbReason(dismissCapability)); return; } setBusy(true); setError(null); try { await runtime.notifications.dismissAll(snapshot); // La liste se vide sous les yeux : le compte des lignes retirées n'apprend // rien de plus que la liste elle-même. await reload(); await runtime.refresh().catch(() => {}); } catch (failure) { setError(failure?.message || 'Retrait impossible'); await reload(); } finally { setBusy(false); } }; // La navigation d'une notification est autorisée par le serveur, cible par // cible : un dossier archivé ou un document repris ne s'ouvre pas. const openTarget = async (notification) => { const target = notification.target; if (!target || target.kind === 'none') { setError('Cette notification n’a pas de cible'); return; } if (notification.targetCapability && !notification.targetCapability.enabled) { setError(dbReason(notification.targetCapability)); return; } if (runtime && !notification.read) { await runtime.notifications.read(notification).catch(() => {}); setNotifs((list) => list.map((item) => (item.id === notification.id ? { ...item, read: true } : item))); } setOpen(false); if (target.kind === 'patient' && onOpenPatient) onOpenPatient(target.patient_id); else if (target.kind === 'appointment' && onNavigate) onNavigate('AGE'); else if (target.kind === 'document_review' && onOpenReport) onOpenReport(target.document_id); }; return (
{/* Dropdown */} {open && (
{/* Header */}
Notifications {count > 0 && ( {count} )}
{count > 0 && ( )}
{/* List */}
{notifs.length === 0 ? (
Aucune notification
) : notifs.map((n) => { const nc = NOTIF_COLORS[n.priority] || NOTIF_COLORS.low; return (
void openTarget(n)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); void openTarget(n); } }} title={n.targetCapability && !n.targetCapability.enabled ? dbReason(n.targetCapability) : undefined} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '12px 16px', borderTop: `1px solid ${HAIR}`, cursor: 'pointer', background: n.read ? 'transparent' : 'rgba(61,107,143,0.04)', opacity: n.targetCapability && !n.targetCapability.enabled ? 0.6 : 1, }} className="press">
{NOTIF_ICONS[n.type] || NOTIF_ICONS.alert}
{n.title} {n.priority === 'high' &&
}
{n.text}
{n.time}
{/* 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 && (
{dbReason(n.targetCapability)}
)}
); })}
)}
); } // ───────────────────────────────────────────────────────────── // 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 (
{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. */} }
); } // ───────────────────────────────────────────────────────────── // Resume banner — top-border 1px ink, pause label in accent // ───────────────────────────────────────────────────────────── function ResumeBanner({ accent, item, onResume }) { return (
); } // ───────────────────────────────────────────────────────────── // Smart Stack — horizontal swipable widget carousel // ───────────────────────────────────────────────────────────── // ── Feuille de validation des comptes rendus ── // Les chips CRO/CRC/CRH du dashboard changeaient d'état sans rien filtrer. // Elles ouvrent maintenant cette feuille sur le type choisi ; la même rangée // est reprise ici pour changer de type sans ressortir. // `filter` vaut 'all' (ouverture depuis la carte) ou un type de CR. function CRValidationSheet({ pending, filter, onChangeFilter, onValidate, onClose, runtime = null, capability = null, focusReportId = null }) { const [serverList, setServerList] = React.useState(null); const [serverCounts, setServerCounts] = React.useState(null); const [loading, setLoading] = React.useState(Boolean(runtime)); const [detail, setDetail] = React.useState(null); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const rows = serverList || pending; const counts = serverCounts || { all: pending.length, CRO: pending.filter((r) => r.type === 'CRO').length, CRC: pending.filter((r) => r.type === 'CRC').length, CRH: pending.filter((r) => r.type === 'CRH').length, }; const shown = serverList ? rows : (filter === 'all' ? rows : rows.filter((r) => r.type === filter)); // La file est refiltrée par le serveur, pas dans le navigateur : le compteur // affiché doit être celui de l'API, sinon un CR repris ailleurs resterait // compté ici. const reload = React.useCallback(async () => { if (!runtime) return; setLoading(true); try { const page = await runtime.reports.load(filter); setServerList(page.items); setServerCounts({ all: page.counts.all, CRO: page.counts.cro, CRC: page.counts.crc, CRH: page.counts.crh }); setError(null); } catch (failure) { setError(failure?.message || 'Chargement des comptes rendus impossible'); } finally { setLoading(false); } }, [runtime, filter]); React.useEffect(() => { void reload(); }, [reload]); // Ouvrir un compte rendu, c'est le lire : le serveur exige l'empreinte du // contenu réellement affiché avant d'accepter la validation. const openDetail = React.useCallback(async (reportId) => { if (!runtime) return; setBusy(true); setError(null); try { setDetail(await runtime.reports.get(reportId)); } catch (failure) { setError(failure?.message || 'Compte rendu indisponible'); } finally { setBusy(false); } }, [runtime]); React.useEffect(() => { if (focusReportId) void openDetail(focusReportId); }, [focusReportId, openDetail]); const validateDetail = async () => { if (!runtime || !detail) return; if (!detail.content_sha256) { setError('Le contenu du compte rendu est indisponible'); return; } setBusy(true); setError(null); try { await runtime.reports.validate(detail, detail.content_sha256); // Le compte rendu validé quitte la file d'attente au rechargement. setDetail(null); await reload(); await runtime.refresh().catch(() => {}); if (onValidate) onValidate(detail); } catch (failure) { setError(failure?.message || 'Validation refusée'); if (failure?.status === 409) await openDetail(detail.id); } finally { setBusy(false); } }; const chips = [ { id: 'all', label: 'Tous' }, { id: 'CRO', label: 'CRO' }, { id: 'CRC', label: 'CRC' }, { id: 'CRH', label: 'CRH' }, ]; return (
Comptes rendus à valider
{loading ? 'Chargement…' : `${shown.length} en attente${filter === 'all' ? '' : ` · ${filter}`}`}
{chips.map((c) => { const on = c.id === filter; return ( ); })}
{/* Relecture : le compte rendu est affiché en entier avant que le bouton de validation n'apparaisse. */} {detail ? (
{detail.document_type} {detail.patient.display_name}
{detail.title}
{detail.author.display_name} · version {detail.version}
{detail.content || 'Contenu indisponible : la validation est impossible.'}
{detail.capabilities?.validate?.enabled && (!capability || capability.enabled) ? ( ) : (
{dbReason(capability && !capability.enabled ? capability : detail.capabilities?.validate)}
)}
) : ( {loading && } {!loading && shown.length === 0 && (
{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. */}
))}
)}
); } // 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 */}
{currentCard === 'cr' && (
onOpenCR && onOpenCR('all')} style={{ cursor: 'pointer' }}> COMPTES RENDUS À VALIDER
{pending.length} en attente
Valider maintenant
)} {currentCard === 'pause' && paused && (
EN PAUSE · REPRENDRE {paused.duration}
{paused.procedure}
{paused.patient} · {paused.started}
)} {currentCard === 'rdv' && nextRdv && (
PROCHAIN RDV {countdown}
{nextRdv.time}
{nextRdv.patient} · {nextRdv.lieu}
{nextRdv.archived && (
Patient archivé
)}
)} {currentCard === 'stats' && (
MON ACTIVITÉ {ACTIVITE.periode}
{['crs', 'rdvs'].map((k) => { const kpi = ACTIVITE.kpis.find((x) => x.key === k); return (
{kpi.value} {kpi.delta}
{kpi.label.toLowerCase()}
); })}
)} {currentCard === 'charge' && (
CHARGE DU JOUR
{[ // 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 ? (
{dbReason(ccamCapability)}
) : (
setShowCCAMSheet(true)} style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 10, background: PAPER, borderRadius: 12, padding: '10px 12px', cursor: 'pointer', }}> Code, acte, catégorie…
)}
)}
{/* Dots */}
{cards.map((_, i) => (
))}
{showCCAMSheet && setShowCCAMSheet(false)} />}
); } // ───────────────────────────────────────────────────────────── // Tab bar — 64px, 18×2 accent indicator under active icon, // labels uppercase 9px tracking 0.6 // ───────────────────────────────────────────────────────────── const TAB_ICONS = { DICT: (s) => , PAT: (s) => , AGE: (s) => , IA: (s) => , MOI: (s) => }; function TabBar({ active, onChange, accent }) { const items = [ { id: 'DICT', label: 'Dictée' }, { id: 'PAT', label: 'Patients' }, { id: 'AGE', label: 'Agenda' }, { id: 'IA', label: 'Assistant' }, { id: 'MOI', label: 'Compte' }]; return (
{items.map((it) => { const on = it.id === active; const stroke = on ? INK : SUB; return ( ); })}
); } // ───────────────────────────────────────────────────────────── // 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 (
{/* Drag handle */}
{/* Header */}
Codes CCAM
{/* Search */}
setQuery(e.target.value)} placeholder="Acte, code, catégorie…" style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontFamily: 'inherit', fontSize: 14, fontWeight: 500, color: INK }} /> {query && ( )}
{/* Quick suggestions */} {!query.trim() && (
{['Pontage', 'Carotide', 'Fistule artérioveineuse', 'Endoprothèse', 'Saphène'].map(s => ( ))}
)}
{/* Results */}
{results.length === 0 ? (
{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.'}
) : results.map((c, i, arr) => (
void handleCopy(c.code)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 0', cursor: 'pointer', borderBottom: i < arr.length - 1 ? `1px solid ${HAIR}` : 'none', }}>
{c.code} {c.family}
{c.label}
{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 */}
{DATE_TODAY.num} · {DATE_TODAY.dow}
onChangeTab && onChangeTab(route)} onOpenPatient={(id) => onOpenPatient && onOpenPatient(id)} onOpenReport={(documentId) => { setFocusReportId(documentId); setCrSheet('all'); }} />

Bonjour,

{practitionerName}

{/* 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 */}
{ const capability = capabilities?.start_dictation; if (capability && !capability.enabled) { showToast(dbReason(capability), 'error'); return; } setDictating('full'); }} onQuick={() => { const capability = capabilities?.start_quick_dictation; if (capability && !capability.enabled) { showToast(dbReason(capability), 'error'); return; } setDictating('quick'); }} />
{/* Smart Stack — horizontal swipable widgets */} onSlideToChat ? onSlideToChat() : (onChangeTab && onChangeTab('IA'))} onOpenAgenda={() => onChangeTab && onChangeTab('AGE')} pending={reports} onOpenCR={(f) => setCrSheet(f)} onOpenStats={onOpenStats} /> {crSheet && ( { setFocusReportId(null); setCrSheet(f); if (f !== 'all') setTab(f); }} onValidate={validate} onClose={() => { setFocusReportId(null); setCrSheet(null); }} /> )} {/* Tab bar */} {/* Flux de dictée — le CR part dans le chat, on bascule sur l'assistant */} {dictating && ( setDictating(false)} onSent={(conversation) => { void runtime?.refresh().catch(() => {}); if (onChangeTab) onChangeTab('IA', conversation); }} /> )}
); } window.Dashboard = Dashboard; window.VascoDashboardFixtures = { RAPPEL_INIT, NOTIF_DATA };