// 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 (
{/* 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 || '—'}
);
}
// ─────────────────────────────────────────────────────────────
// 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 (
) : (
<>
{/* 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 (
{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) && (
{/* 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
{error && (
{error}
)}
{pinned.length > 0 && (
Épinglées
{pinned.map((n) => (
{n.text}
{n.when || n.d} · {n.author}
{canPin && (
)}
))}
)}
Notes libres
{free.length === 0 ? (
Aucune note libre.
) : free.map((n) => (
{n.text}
{n.when || n.d} · {n.author}
{canPin && (
)}
{canDelete && (
)}
))}
);
}
// ─────────────────────────────────────────────────────────────
// Tab : Stats
// ─────────────────────────────────────────────────────────────
function TabStats({ p, accent, runtime = null }) {
// Les barres étaient écrites en dur ([2,1,3,2,4,5] sur « Déc → Mai ») et ne
// bougeaient pour aucun patient. La série vient maintenant de l'API.
const [stats, setStats] = React.useState(null);
const [error, setError] = React.useState(null);
React.useEffect(() => {
if (!runtime) return undefined;
let cancelled = false;
runtime.statistics(p.id)
.then((view) => { if (!cancelled) { setStats(view); setError(null); } })
.catch((failure) => { if (!cancelled) setError(failure?.message || 'Statistiques indisponibles'); });
return () => { cancelled = true; };
}, [runtime, p.id]);
const monthNames = ['Jan', 'Fév', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sep', 'Oct', 'Nov', 'Déc'];
const series = stats?.series || [];
const months = series.map((point) => monthNames[Number(point.month.split('-')[1]) - 1]);
const data = series.map((point) => point.report_count);
const data2 = series.map((point) => point.appointment_count);
const dateLabel = (value) => (value
? new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: stats?.timezone || 'Europe/Paris' }).format(new Date(value))
: '—');
return (
{error && (
{error}
)}
Activité — 6 mois
{/* Le serveur dit lui-même si la période contient assez de points pour
être lue ; on ne trace pas une courbe sur du vide. */}
{stats && !stats.has_sufficient_data && (
Données insuffisantes sur la période pour une lecture utile.
);
}
// ─────────────────────────────────────────────────────────────
// Header (sticky-ish — non sticky here, scrolls with content)
// ─────────────────────────────────────────────────────────────
// « IPP » ne parle qu'aux gens qui travaillent déjà avec. Sur desktop le
// `title` suffit au survol ; sur mobile il n'y a pas de survol, donc le
// numéro devient tapable et ouvre la même explication.
function IppHint({ ipp }) {
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
if (!open) return;
const t = setTimeout(() => setOpen(false), 4000);
return () => clearTimeout(t);
}, [open]);
return (
{p.age} ans · {p.sex} · né{p.sex === 'F' ? 'e' : ''} le {p.ddn}
{p.summary && (
{p.summary}
)}
{p.flags && p.flags.length > 0 && (
{p.flags.map(f => (
{f.toUpperCase()}
))}
)}
>
);
}
// ─────────────────────────────────────────────────────────────
// Tabs strip (sticky)
// ─────────────────────────────────────────────────────────────
function TabsStrip({ active, onChange }) {
const [more, setMore] = React.useState(false);
const inMore = active === 'stats';
// Le dégradé ne doit apparaître que s'il reste réellement du contenu à droite.
const stripRef = React.useRef(null);
const [canScroll, setCanScroll] = React.useState(false);
const measure = () => {
const el = stripRef.current;
if (!el) return;
setCanScroll(el.scrollWidth - el.clientWidth - el.scrollLeft > 4);
};
React.useLayoutEffect(measure, [active]);
React.useEffect(() => {
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, []);
const onStripScroll = measure;
return (
{/* Les onglets s'étirent quand il y a la place et gardent leur largeur
naturelle sinon : sur écran étroit la barre défile plutôt que de
tronquer les libellés. Le dégradé à droite signale ce défilement. */}
>
);
}
function PatientDetail({ patientId, accent, onBack, onOpenChat, runtime = null,
initialTab = 'resume', onNewAppointment }) {
// Le jeton de fraîcheur force la relecture de PATIENTS_FULL : la correction
// y remplace l'entrée, mais ce remplacement ne re-rend rien tout seul.
const [recordToken, setRecordToken] = React.useState(0);
const p = React.useMemo(
() => PATIENTS_FULL.find(x => x.id === patientId),
[patientId, recordToken]
);
// Ouvrir le dossier sur un onglet précis : « Nouvelle note » depuis la pile
// des dossiers doit tomber sur le champ de saisie, pas sur le résumé.
const [tab, setTab] = React.useState(initialTab);
// Un jeton plutôt qu'un booléen : deux demandes successives de saisie doivent
// toutes les deux ramener le curseur dans le champ.
const [noteFocus, setNoteFocus] = React.useState(initialTab === 'notes' ? 1 : 0);
React.useEffect(() => {
setTab(initialTab);
if (initialTab === 'notes') setNoteFocus((n) => n + 1);
}, [patientId, initialTab]);
const [sheetOpen, setSheetOpen] = React.useState(false);
const [editOpen, setEditOpen] = React.useState(false);
// La feuille du QR est montée au niveau du dossier, comme la feuille
// d'actions : posée dans le contenu qui défile, elle défilerait avec lui.
const [qrOpen, setQrOpen] = React.useState(false);
// Le dossier affiché peut changer sans que l'écran soit démonté : un QR scanné
// pendant qu'on lit un dossier en ouvre un autre à la même place. Une feuille
// restée ouverte se retrouverait alors posée sur le nouveau nom avec les
// commandes de l'ancien — supprimer, modifier, un QR qui n'est plus le sien.
// Elles se referment donc avec le dossier qui les avait ouvertes.
React.useEffect(() => {
setSheetOpen(false);
setEditOpen(false);
setQrOpen(false);
}, [patientId]);
// Le dossier demandé n'est pas chargé : on le dit. Se replier sur le premier
// dossier de la liste affichait le nom, l'IPP et les antécédents de quelqu'un
// d'autre sous l'identité attendue — un écran de ce genre ne peut pas être
// relu comme une erreur, il se lit comme un dossier. Rendu placé après tous
// les hooks, qui doivent s'exécuter à chaque rendu.
if (!p) {
return (
Dossier introuvable
Ce dossier n’est pas chargé dans cette session.
);
}
return (
{/* La réserve du haut est une cale qui défile, pas une marge intérieure : une
marge intérieure ne colle pas la barre d'onglets au bord visible mais 62 px
plus bas, où elle vient se poser au milieu du contenu — et comme elle porte
la couleur du fond, elle y ouvre un trou. */}
{/* Feuille d'actions partagée avec le répertoire. « Voir le dossier » n'y
est pas passé : on y est déjà. Après une suppression, le dossier a
quitté la vue du serveur : rester ici afficherait une fiche qu'il ne
rendrait plus, donc on repart d'où l'on venait. */}
{sheetOpen && (
setSheetOpen(false)}
onDelete={onBack}
onNewNote={() => { setTab('notes'); setNoteFocus((n) => n + 1); }}
onNewAppointment={onNewAppointment ? () => onNewAppointment(p.id) : undefined}
onEdit={runtime ? () => setEditOpen(true) : undefined} />
)}
{editOpen && runtime && (
setEditOpen(false)}
onSaved={() => setRecordToken((n) => n + 1)} />
)}
{qrOpen && (
setQrOpen(false)} />
)}