// Suivi actionnable du dossier patient, connecté uniquement à des faits stockés. // Cette couche produit remplace l'écran hérité sans modifier la référence figée. const PATIENT_FOLLOW_UP_TAB_EVENT = 'vasco:patient-follow-up-tab'; function requestPatientTab(tab) { window.dispatchEvent(new CustomEvent(PATIENT_FOLLOW_UP_TAB_EVENT, { detail: { tab } })); } // Le composant hérité garde son onglet en état local. Cette enveloppe produit lui // transmet les raccourcis du Suivi comme un changement d'onglet normal. const PatientFollowUpDetailBase = PatientDetail; PatientDetail = function PatientFollowUpDetail(props) { const [requestedTab, setRequestedTab] = React.useState(props.initialTab || 'resume'); React.useEffect(() => { setRequestedTab(props.initialTab || 'resume'); }, [props.initialTab, props.patientId]); React.useEffect(() => { const onTabRequest = (event) => { if (['docs', 'agenda'].includes(event.detail?.tab)) setRequestedTab(event.detail.tab); }; window.addEventListener(PATIENT_FOLLOW_UP_TAB_EVENT, onTabRequest); return () => window.removeEventListener(PATIENT_FOLLOW_UP_TAB_EVENT, onTabRequest); }, []); return ; }; function PatientFollowUpTabsStrip({ active, onChange }) { const { ChoiceChip, IconButton, InteractiveRow } = window.VascoUI; const [more, setMore] = React.useState(false); const inMore = active === 'stats'; const stripRef = React.useRef(null); const [canScroll, setCanScroll] = React.useState(false); const measure = () => { const element = stripRef.current; if (element) setCanScroll(element.scrollWidth - element.clientWidth - element.scrollLeft > 4); }; React.useLayoutEffect(measure, [active]); React.useEffect(() => { window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); }, []); return (
{TABS.map((item) => { const selected = item.id === active; return ( onChange(item.id)} selected={selected} style={{ background: selected ? '#fff' : 'transparent', border: 'none', cursor: 'pointer', padding: '8px 6px', borderRadius: 12, fontFamily: 'inherit', fontSize: 12, fontWeight: selected ? 700 : 500, color: selected ? P_INK : P_SUB, letterSpacing: '0.1px', whiteSpace: 'nowrap', flex: '1 1 auto', minWidth: 'max-content', boxShadow: selected ? '0 1px 3px rgba(10,10,10,0.08)' : 'none', }}> {item.label} ); })}
{canScroll &&
setMore((open) => !open)} label="Plus" expanded={more} size={34} style={{ borderRadius: 12, background: inMore ? '#fff' : 'rgba(10,10,10,0.04)', border: 'none', cursor: 'pointer', boxShadow: inMore ? '0 1px 3px rgba(10,10,10,0.08)' : 'none', }}> {more && <>
setMore(false)} style={{ position: 'fixed', inset: 0, zIndex: 20 }} />
{ onChange('stats'); setMore(false); }} selected={inMore} style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 9, background: inMore ? PD_MED_TINT : 'none', border: 'none', borderRadius: 8, padding: '9px 10px', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', fontSize: 13, fontWeight: 600, color: inMore ? PD_MED : P_INK, }}> Suivi {/* Entrées ajoutées par les extensions connectées. Elles ouvrent une feuille et ne deviennent jamais l'onglet actif : le menu « Plus » reste une liste d'accès, pas une barre d'onglets bis. */} {(window.VASCO_PATIENT_MORE || []).map((entry) => ( { setMore(false); entry.onSelect(); }} style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 9, background: 'none', border: 'none', borderRadius: 8, padding: '9px 10px', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', fontSize: 13, fontWeight: 600, color: P_INK, }}> {entry.icon ? entry.icon(P_SUB) : null} {entry.label} ))}
}
); } TabsStrip = PatientFollowUpTabsStrip; function FollowUpArrow() { return ( ); } function FollowUpActionCard({ eyebrow, title, detail, tone = 'neutral', onClick, children }) { const palette = tone === 'attention' ? { background: '#fff9ea', border: '#ead8a6', color: '#70541c' } : { background: '#fff', border: P_HAIR2, color: P_SUB }; const Tag = onClick ? 'button' : 'div'; return (
{eyebrow}
{title}
{detail &&
{detail}
} {children}
{onClick && }
); } function FollowUpMetric({ label, value, detail, featured = false }) { return (
{value}
{label}
{detail}
); } function PatientFollowUpLoading() { return (
Chargement du suivi…
Rendez-vous et documents du dossier
); } function PatientStatisticsTab({ p, accent = PD_MED, runtime = null }) { const { Button, InteractiveRow } = window.VascoUI; const [stats, setStats] = React.useState(null); const [error, setError] = React.useState(null); const [requestVersion, setRequestVersion] = React.useState(0); React.useEffect(() => { if (!runtime?.statistics) { setError('Suivi indisponible'); return undefined; } let cancelled = false; setStats(null); setError(null); runtime.statistics(p.id) .then((view) => { if (!cancelled) setStats(view); }) .catch((failure) => { if (!cancelled) setError(failure?.message || 'Suivi indisponible'); }); return () => { cancelled = true; }; }, [runtime, p.id, requestVersion]); const zone = stats?.timezone || 'Europe/Paris'; const formatDate = (value, withTime = false) => { if (!value) return 'Aucune'; return new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short', year: 'numeric', ...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}), timeZone: zone, }).format(new Date(value)); }; const next = stats?.next_appointment; const pending = Number(stats?.awaiting_validation_document_count || 0); const pendingLabel = pending === 1 ? '1 document à valider' : `${pending} documents à valider`; const nextDetail = next ? [formatDate(next.starts_at, true), next.location].filter(Boolean).join(' · ') : null; const latest = stats?.latest_document; const documentStatusLabels = { awaiting_validation: 'À valider', validated: 'Validé', draft: 'Brouillon', in_progress: 'En cours', archived: 'Archivé', }; if (!stats && !error) return ; if (error) { return (
Impossible de charger le suivi
{error}
); } return (
À traiter
{pending > 0 ? ( requestPatientTab('docs')} /> ) : ( )} {next ? ( requestPatientTab('agenda')}> {stats.upcoming_appointment_count > 1 &&
+ {stats.upcoming_appointment_count - 1} autre{stats.upcoming_appointment_count > 2 ? 's' : ''} à venir
}
) : ( )}
Rendez-vous 12 derniers mois
Continuité du suivi
Dernière visite
{formatDate(stats.last_visit_at)}
Prochain suivi
{next ? formatDate(next.starts_at) : 'Non programmé'}
Documents {stats.document_count} au total
{latest ? ( requestPatientTab('docs')} style={{ width: '100%', minWidth: 0, marginTop: 10, padding: '13px 14px', borderRadius: 17, border: `1px solid ${P_HAIR2}`, background: '#fff', boxShadow: VT.shadowMd, display: 'flex', alignItems: 'center', gap: 10, fontFamily: 'inherit', textAlign: 'left', cursor: 'pointer', }}>
Dernier document
{latest.document_type || 'DOCUMENT'} {documentStatusLabels[latest.status] || latest.status}
{latest.title}
Ajouté le {formatDate(latest.created_at)}
) : (
Aucun document dans ce dossier
)}
); } TabStats = PatientStatisticsTab;