// Patient detail — 5 onglets + menu « Plus ». const PD_VT = window.VT || {}; const PD_MED = PD_VT.medBlue || '#3d6b8f'; const PD_MED_TINT = PD_VT.medBlueTint || 'rgba(61,107,143,0.10)'; const PD_WARN = PD_VT.warning || '#d97706'; // 5 onglets max, ordonnés par fréquence d'usage. Les antécédents ont rejoint // le Résumé, les statistiques sont passées dans le menu « Plus » : à 7 onglets // la barre débordait et il fallait la faire défiler pour voir les derniers. const TABS = [ { id: 'resume', label: 'Résumé' }, { id: 'docs', label: 'Documents' }, { id: 'notes', label: 'Notes' }, { id: 'agenda', label: 'Agenda' }, { id: 'postop', label: 'Post-op' }, ]; // ───────────────────────────────────────────────────────────── // Mini sparkline (CRs/month) for the stats card // ───────────────────────────────────────────────────────────── function MiniBars({ data, accent }) { const max = Math.max(...data, 1); return (
{data.map((v, i) => (
))}
); } // ───────────────────────────────────────────────────────────── // Tab : Résumé // ───────────────────────────────────────────────────────────── function TabResume({ p, accent, onOpenChat, onTab, runtime = null, onQrCode }) { const capabilities = p.capabilities || {}; // Les capacités d'un dossier sont de simples booléens : le serveur ne joint // pas de code de motif comme le fait l'agenda. On ne nomme donc la cause que // là où on la connaît de source sûre — un patient décédé, un contact absent // sont les causes que le serveur retient — et l'on retombe sur une formule // générale ailleurs, plutôt que d'inventer un motif. const deceased = p.status === 'decede'; const GENERIC_REFUSAL = 'Indisponible pour ce dossier'; const callRefusal = deceased ? 'Patient décédé' : !p.phone ? 'Aucun numéro renseigné' : GENERIC_REFUSAL; const emailRefusal = deceased ? 'Patient décédé' : !p.email ? 'Aucune adresse renseignée' : GENERIC_REFUSAL; // Ouvrir une discussion crée une vraie conversation rattachée au dossier ; // la maquette basculait simplement d'écran. const openConversation = () => { if (capabilities.open_conversation === false) { showToast('Discussion indisponible pour ce dossier', 'error'); return; } if (!runtime) { if (onOpenChat) onOpenChat(); return; } runtime.openConversation(p.id) .then((conversation) => { if (onOpenChat) onOpenChat(conversation); }) .catch((failure) => showToast(failure?.message || 'Discussion impossible', 'error')); }; return (
{/* Pinned alerts */} {p.pinned && p.pinned.length > 0 && (
Alertes épinglées · · ·
{p.pinned.map((t, i) => (
{t}
))}
)} {/* Next RDV */} {p.nextRdv && (
Prochain RDV
{p.nextRdv.when}
{p.nextRdv.what}
)} {/* Latest documents */}
Derniers comptes rendus
{(p.documents || []).slice(0, 3).map((d, i, arr) => (
{d.type}
{d.label}
{d.d}{d.tag === 'live' && <> · EN COURS}
))}
{/* Quick clinical glance */}
Clinique
{[ ['Groupe', p.blood || '—'], ['Allergies', (p.allergies && p.allergies.length) ? p.allergies.join(', ') : 'Aucune connue'], ['Traitements', (p.treatments && p.treatments.length) ? `${p.treatments.length} en cours` : '—'], ['Médecin tt.', p.mt || '—'], ].map(([k, v], i, arr) => (
{k}
{v}
))}
{/* Antécédents — rapatriés depuis leur ancien onglet. Le bloc « Clinique » ci-dessus résume déjà groupe / allergies / nombre de traitements ; on ne remet donc ici que le détail qui n'y figure pas. */} {/* Quick actions */}
Actions {/* Appeler et écrire sont tracés côté serveur avant que le lien ne s'ouvre. Le QR code est dessiné par le serveur, à partir du seul IPP. Imprimer compose la feuille depuis ce dossier et la sort sur l'imprimante du poste. */}
{ if (capabilities.call === false) { showToast('Appel indisponible pour ce dossier', 'error'); return; } if (!runtime) { requestCall(`${p.last} ${p.first}`, p.phone); return; } runtime.authorizeAction(p.id, 'call') .then(() => requestCall(`${p.last} ${p.first}`, p.phone)) .catch((failure) => showToast(failure?.message || 'Appel impossible', 'error')); }} icon={ } /> { if (capabilities.email === false) { showToast('Courriel indisponible pour ce dossier', 'error'); return; } if (!runtime) { showToast(p.email ? `Courriel à ${p.email}` : 'Aucune adresse renseignée', p.email ? 'info' : 'error'); return; } runtime.authorizeAction(p.id, 'email') .then(() => { window.location.href = `mailto:${p.email}`; }) .catch((failure) => showToast(failure?.message || 'Courriel impossible', 'error')); }} icon={ <> } /> { if (capabilities.qr !== true) { showToast('QR code indisponible pour ce dossier', 'error'); return; } if (onQrCode) onQrCode(); }} icon={ <> } /> { if (capabilities.print === false) { showToast('Impression indisponible pour ce dossier', 'error'); return; } printPatientRecord(p, runtime) .catch((failure) => showToast(failure?.message || 'Impression impossible', 'error')); }} icon={ <> } />
{/* Open chat with this patient */} {/* Le bouton est un aplat sombre : y écrire la raison la rendrait illisible. Elle se pose donc juste dessous, comme sous les commandes de l'agenda. */} {capabilities.open_conversation === false && (
Ouvrir une discussion VASCO — {GENERIC_REFUSAL}
)}
); } // Une commande éteinte dit pourquoi sur la carte elle-même : la raison ne // vivait que dans le toast déclenché par l'appui, donc jamais pour qui regarde // la grille sans y toucher. Le libellé et l'icône s'éteignent, la raison reste // à pleine opacité — c'est la seule chose qui reste à lire. function QAction({ label, icon, onClick, disabled = false, reason = null }) { const refused = disabled && reason; return ( ); } // ───────────────────────────────────────────────────────────── // Tab : Documents // ───────────────────────────────────────────────────────────── // Horodatage des documents — mocké, mais dérivé du libellé plutôt que tiré au // sort : la ligne doit rester identique d'un rendu à l'autre, sinon l'auteur // change à chaque changement de filtre. // L'auteur d'un compte rendu était fabriqué en hachant son titre : la ligne // annonçait « Signé par Dr. Legrand » pour un document que personne n'avait // signé. Le serveur renvoie l'auteur et l'état réels. const PD_DOC_STATUS = { draft: 'Brouillon', in_progress: 'En cours', awaiting_validation: 'En attente de validation', validated: 'Validé', archived: 'Archivé', }; function docSignature(d) { const state = PD_DOC_STATUS[d.status] || d.status || ''; if (!d.author || d.author === '—') return state; return d.status === 'validated' ? `Validé par ${d.author}` : `${state} · ${d.author}`; } function TabDocuments({ p, accent, runtime = null }) { const [filter, setFilter] = React.useState('all'); const [docs, setDocs] = React.useState(p.documents || []); const [counts, setCounts] = React.useState(null); const [opened, setOpened] = React.useState(null); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const types = ['all', 'CRO', 'CRC', 'CRH', 'IMG', 'EXAM']; const labels = { all: 'Tous', CRO: 'Op.', CRC: 'Cons.', CRH: 'Hospi.', IMG: 'Imagerie', EXAM: 'Examens' }; // Le filtre est appliqué par le serveur : le résumé du patient ne porte que // les documents récents, pas l'ensemble du dossier. React.useEffect(() => { if (!runtime) return; let cancelled = false; setBusy(true); runtime.documents(p.id, filter) .then((page) => { if (cancelled) return; setDocs(page.items); setCounts(page.counts); setError(null); }) .catch((failure) => { if (!cancelled) setError(failure?.message || 'Documents indisponibles'); }) .finally(() => { if (!cancelled) setBusy(false); }); return () => { cancelled = true; }; }, [runtime, p.id, filter]); const openDocument = async (document) => { if (!runtime) return; setBusy(true); setError(null); try { setOpened(await runtime.document(p.id, document.id)); } catch (failure) { setError(failure?.message || 'Document indisponible'); } finally { setBusy(false); } }; if (opened) { return (
{opened.title}
{opened.document_type} · version {opened.version} · {opened.author?.display_name || '—'}
{opened.content}
); } return (
{types.map(t => { const on = filter === t; return ( ); })}
{error && (
{error}
)} {docs.length === 0 ? (
{busy ? 'Chargement…' : 'Aucun document de ce type.'}
) : (
{docs.map((d) => (
void openDocument(d)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void openDocument(d); } }} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 0', borderBottom: `1px solid ${P_HAIR}`, cursor: 'pointer', opacity: d.available === false ? 0.5 : 1, }}> {d.type}
{d.label}
{d.d}{d.status === 'awaiting_validation' && <> · À VALIDER}
{docSignature(d)}
))}
)}
); } // ───────────────────────────────────────────────────────────── // Tab : Antécédents // ───────────────────────────────────────────────────────────── function AtcdSections({ p, accent }) { const sections = [ { title: 'Antécédents personnels', items: p.atcdPerso || [], empty: 'Aucun antécédent personnel renseigné.' }, { title: 'Antécédents familiaux', items: p.atcdFam || [], empty: 'Aucun antécédent familial renseigné.' }, { title: 'Traitements en cours', items: p.treatments|| [], empty: 'Aucun traitement en cours.' }, ]; return (
{sections.map((s, idx) => (
{s.title} {s.items.length === 0 ? (
{s.empty}
) : (
{s.items.map((it, i, arr) => (
{String(i + 1).padStart(2, '0')} {it}
))}
)}
))}
); } // ───────────────────────────────────────────────────────────── // Tab : Agenda // ───────────────────────────────────────────────────────────── function TabAgenda({ p, accent, runtime = null }) { const [rdvs, setRdvs] = React.useState(p.rdvs || []); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const [form, setForm] = React.useState(null); const canCreate = (p.capabilities || {}).create_appointment !== false; // Le serveur refuse le rendez-vous d'un patient décédé ; hors de ce cas, ses // capacités de dossier sont des booléens sans code de motif, et l'on s'en // tient à une formule générale plutôt que d'inventer une cause. const createRefusal = p.status === 'decede' ? 'Patient décédé' : 'Indisponible pour ce dossier'; const reload = React.useCallback(async () => { if (!runtime) return; setBusy(true); try { setRdvs(await runtime.appointments(p.id)); setError(null); } catch (failure) { setError(failure?.message || 'Rendez-vous indisponibles'); } finally { setBusy(false); } }, [runtime, p.id]); React.useEffect(() => { void reload(); }, [reload]); // Depuis le dossier, un chevauchement est un avertissement et non un refus : // le rendez-vous est créé, et l'écran dit avec quoi il se chevauche. const submit = async () => { if (!runtime || !form?.reason.trim() || !form.start || !form.end) return; setBusy(true); setError(null); try { const result = await runtime.createAppointment(p.id, { startsAtLocal: form.start, endsAtLocal: form.end, reason: form.reason.trim(), type: form.type, location: form.location.trim() || null, }); // La création n'est pas annoncée : le rendez-vous apparaît dans l'agenda // du dossier au rechargement. Le chevauchement, lui, n'est pas une // validation mais un avertissement — il reste dit. const overlaps = result.warnings.find((warning) => warning.code === 'appointment_overlap'); if (overlaps) { showToast( `Rendez-vous créé en parallèle de ${overlaps.appointment_ids.length} autre(s)`, 'info', ); } setForm(null); await reload(); } catch (failure) { setError(failure?.message || 'Création impossible'); } finally { setBusy(false); } }; return (
Rendez-vous {error && (
{error}
)} {rdvs.length === 0 ? (
{busy ? 'Chargement…' : 'Aucun RDV planifié.'}
) : (
{rdvs.map((r) => (
{(r.when || '').split(' ·')[0]}
{r.start}
{r.what}
{r.status === 'cancelled' ? 'Annulé' : r.status === 'completed' ? 'Terminé' : 'Programmé'} · {r.lieu}
))}
)} {form ? (
Nouveau rendez-vous
setForm({ ...form, start: e.target.value })} style={{ flex: 1, minWidth: 0, height: 38, borderRadius: 12, border: `1px solid ${P_HAIR2}`, padding: '0 10px', fontFamily: "'JetBrains Mono', monospace", fontSize: 11, color: P_INK, outline: 'none' }} /> setForm({ ...form, end: e.target.value })} style={{ flex: 1, minWidth: 0, height: 38, borderRadius: 12, border: `1px solid ${P_HAIR2}`, padding: '0 10px', fontFamily: "'JetBrains Mono', monospace", fontSize: 11, color: P_INK, outline: 'none' }} />
setForm({ ...form, reason: e.target.value })} style={{ width: '100%', height: 38, borderRadius: 12, border: `1px solid ${P_HAIR2}`, padding: '0 12px', fontFamily: 'inherit', fontSize: 13, color: P_INK, outline: 'none', marginTop: 8 }} /> setForm({ ...form, location: e.target.value })} style={{ width: '100%', height: 38, borderRadius: 12, border: `1px solid ${P_HAIR2}`, padding: '0 12px', fontFamily: 'inherit', fontSize: 13, color: P_INK, outline: 'none', marginTop: 8 }} />
) : ( <> {/* La raison ne vivait que dans le `title` : une infobulle ne se survole pas au doigt, et le bouton est désactivé, donc son `onClick` ne peut pas la dire non plus. Elle se lit maintenant sous lui. */} {!canCreate && (
Planifier un RDV — {createRefusal}
)} )}
); } // ───────────────────────────────────────────────────────────── // Tab : Suivi Post-opératoire // ───────────────────────────────────────────────────────────── function TabPostOp({ p, accent, runtime = null }) { // La chronologie était entièrement inventée : « Température 38.1°C », // « hémocultures prélevées », « TA 13/8, FC 72 ». Ces lignes se lisaient // comme des observations cliniques alors qu'aucune n'avait été relevée. // L'épisode vient maintenant du serveur, et il n'y en a pas toujours un. const [episode, setEpisode] = React.useState(undefined); const [error, setError] = React.useState(null); const canSms = (p.capabilities || {}).send_postop_sms !== false; const canCall = (p.capabilities || {}).call !== false; // Le SMS de suivi n'est raccordé à aucun canal ; l'appel se refuse pour le // dossier d'un patient décédé ou sans numéro, les deux causes que le serveur // retient. Les deux boutons éteints le disent sous eux. const smsRefusal = 'Aucun canal de messagerie raccordé'; const postOpCallRefusal = p.status === 'decede' ? 'Patient décédé' : !p.phone ? 'Aucun numéro renseigné' : 'Indisponible pour ce dossier'; React.useEffect(() => { if (!runtime) { setEpisode(null); return undefined; } let cancelled = false; runtime.postOperative(p.id) .then((response) => { if (!cancelled) { setEpisode(response.episode || null); setError(null); } }) .catch((failure) => { if (!cancelled) { setEpisode(null); setError(failure?.message || 'Suivi indisponible'); } }); return () => { cancelled = true; }; }, [runtime, p.id]); const timeZone = window.VASCO_RUNTIME?.dashboard?.time_zone || 'Europe/Paris'; const stamp = (value) => new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone, }).format(new Date(value)).replace(',', ' ·'); const typeIcons = { intervention: <>, action: <>, exam: <>, vitals: <>, alert: <>, appointment: <>, }; const statusStyles = { done: { bg: VT.successTintSoft, dot: VT.success, line: VT.success }, alert: { bg: 'rgba(185,28,28,0.08)', dot: '#b91c1c', line: '#b91c1c' }, planned: { bg: 'rgba(10,10,10,0.03)', dot: '#9ca3af', line: '#d1d5db' }, }; if (episode === undefined) { return (
Chargement du suivi post-opératoire…
); } if (!episode) { return (
{error && (
{error}
)}
Aucun épisode post-opératoire ouvert pour ce dossier.
); } // L'état global est calculé par le serveur, selon une règle versionnée : // l'écran le rapporte, il ne le recalcule pas. const hasAlert = episode.status === 'alert'; const events = episode.events || []; return (
{hasAlert ? <> : <>}
{hasAlert ? 'À surveiller' : 'Évolution favorable'}
J+{episode.day_number} · {episode.intervention_label}
{episode.status_rule_version} · {episode.status_reason === 'unresolved_alert' ? 'alerte non levée' : 'aucune alerte non levée'}
{/* Le SMS de suivi n'est pas raccordé : le bouton dit pourquoi au lieu de promettre un envoi. */}
{/* « SMS indisponible » disait l'état, pas la cause ; « Appeler » éteint ne disait rien du tout. Les deux raisons se lisent sous les boutons, chacune la sienne, comme sous les commandes de l'agenda. */} {(!canSms || !canCall) && (
{!canSms && (
SMS indisponible — {smsRefusal}
)} {!canCall && (
Appeler — {postOpCallRefusal}
)}
)} {canSms && canCall &&
} Chronologie post-opératoire
{events.length === 0 && (
Aucun événement enregistré.
)} {events.map((ev, i) => { const ss = statusStyles[ev.status] || statusStyles.planned; const isLast = i === events.length - 1; const isPast = ev.status !== 'planned'; return (
{!isLast && (
)}
{typeIcons[ev.event_type] || typeIcons.action}
{ev.label}
{stamp(ev.event_at)}
{ev.detail}
{/* Une mesure venue d'un appareil n'a pas la même valeur qu'une observation signée : la source est nommée. */}
{ev.author?.display_name || ev.source_label || 'Source non renseignée'}
); })}
); } // ───────────────────────────────────────────────────────────── // Tab : Notes // ───────────────────────────────────────────────────────────── function TabNotes({ p, accent, runtime = null, focusToken = 0 }) { const [draft, setDraft] = React.useState(''); const draftRef = React.useRef(null); const [notes, setNotes] = React.useState(p.notes || []); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const pinned = notes.filter(n => n.pinned); const free = notes.filter(n => !n.pinned); const capabilities = p.capabilities || {}; const canCreate = capabilities.create_note !== false; const canPin = capabilities.pin_note !== false; const canDelete = capabilities.delete_note !== false; // La capacité `create_note` est un booléen sans code de motif : on dit le // refus tel qu'on le connaît, sans lui prêter une cause qu'on ignore. const noteRefusal = 'Indisponible pour ce dossier'; const reload = React.useCallback(async () => { if (!runtime) return; setBusy(true); try { setNotes(await runtime.notes(p.id)); setError(null); } catch (failure) { setError(failure?.message || 'Chargement des notes impossible'); } finally { setBusy(false); } }, [runtime, p.id]); React.useEffect(() => { void reload(); }, [reload]); // Arrivée par « Nouvelle note » : le champ prend le curseur et se met sous les // yeux. Sans cela l'onglet s'ouvre sur sa liste et le geste reste à finir. React.useEffect(() => { if (!focusToken || !draftRef.current) return; draftRef.current.focus({ preventScroll: true }); draftRef.current.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, [focusToken]); const add = async () => { if (!draft.trim() || !runtime) return; setBusy(true); setError(null); try { const created = await runtime.createNote(p.id, draft.trim()); setNotes((list) => [created, ...list]); setDraft(''); } catch (failure) { setError(failure?.message || 'Création de la note impossible'); } finally { setBusy(false); } }; const togglePin = async (note) => { if (!runtime) return; setBusy(true); setError(null); try { const updated = await runtime.pinNote(p.id, note, !note.pinned); setNotes((list) => list.map((item) => (item.id === updated.id ? updated : item))); } catch (failure) { setError(failure?.message || 'Épinglage impossible'); if (failure?.status === 409) await reload(); } finally { setBusy(false); } }; // La suppression est réversible côté serveur pendant un délai que l'API // annonce. La maquette réinsérait la note en mémoire, donc « Annuler » // faisait réapparaître une note que le serveur n'avait jamais supprimée. const del = async (note) => { if (!runtime) return; setBusy(true); setError(null); try { const deleted = await runtime.deleteNote(p.id, note); setNotes((list) => list.filter((item) => item.id !== note.id)); const undoable = Boolean(deleted.undoExpiresAt) && new Date(deleted.undoExpiresAt).getTime() > Date.now(); showToast('Note supprimée', 'info', undoable ? { duration: 5000, action: { label: 'Annuler', onClick: () => { runtime.restoreNote(p.id, deleted) .then((restored) => setNotes((list) => [restored, ...list])) .catch((failure) => setError(failure?.message || 'Restauration impossible')); }, }, } : { duration: 5000 }); } catch (failure) { setError(failure?.message || 'Suppression impossible'); if (failure?.status === 409) await reload(); } finally { setBusy(false); } }; return (
{/* Add note */}
Nouvelle note