// Le suivi post-opératoire, composé au lieu d’être saisi.
//
// L’onglet montrait une chronologie que personne ne pouvait remplir : aucune
// route du serveur ne crée d’événement post-opératoire, et il n’y avait donc
// rien à lire hors des lignes semées par la démonstration. Les constantes en
// particulier – « TA 132/78 · FC 74 » – se lisaient comme un relevé alors que
// personne n’était là pour les entrer.
//
// Le fil rassemble maintenant ce qui est DÉJÀ écrit ailleurs sur ce patient :
// ses rendez-vous, ses notes, ses documents, ses séjours, l’échéance de
// contrôle et les rappels posés. Chaque chose à sa date, de part et d’autre du
// jour de l’intervention. Un traitement y pose son début et sa fin, que le
// dossier porte maintenant sur chaque fiche ; celui que la tranche affichée ne
// montre pas se lit juste à côté du fil, avec ce que sa fiche en dit.
//
// Rien ne s’ouvre tant qu’aucune chirurgie n’est programmée : le suivi
// post-opératoire d’un patient qu’on n’opère pas n’existe pas, et l’écran le
// dit au lieu d’afficher un cadre vide.
const PT_VT = window.VT || {};
const PT_INK = PT_VT.ink || '#0a0a0a';
const PT_SUB = PT_VT.sub || '#6b7280';
const PT_SURFACE = PT_VT.surface || '#fff';
const PT_HAIR = PT_VT.hair || 'rgba(10,10,10,0.08)';
const PT_HAIR2 = PT_VT.hair2 || 'rgba(10,10,10,0.14)';
const PT_DANGER = PT_VT.danger || '#b91c1c';
const PT_ACCENT = PT_VT.medBlue || '#3d6b8f';
// Le halo de la journée d’intervention : assez pour qu’elle se détache du fil,
// assez discret pour ne pas peser sur les lignes qu’elle contient.
const PT_PIVOT_TINT = 'rgba(61,107,143,0.07)';
const PT_PIVOT_BORDER = 'rgba(61,107,143,0.20)';
// Les trois états de la chronologie de la V5, repris tels quels : ce qui est
// fait porte le vert, ce qui est prévu reste gris, une alerte est rouge. Le
// gabarit de ligne – pastille, trait continu, carré d’icône teinté – est le
// sien aussi. L’intervention est le repère du fil, pas une alerte : elle porte
// l’accent médical, jamais le rouge, qui reste au danger seul.
const PT_STATUS = {
done: {
bg: PT_VT.successTintSoft || 'rgba(5,150,105,0.06)',
dot: PT_VT.success || '#059669',
line: PT_VT.success || '#059669',
},
alert: { bg: 'rgba(185,28,28,0.08)', dot: PT_DANGER, line: PT_DANGER },
planned: { bg: 'rgba(10,10,10,0.03)', dot: '#9ca3af', line: '#d1d5db' },
};
function postopEntryStyle(entry) {
if (entry.accent === 'danger') return PT_STATUS.alert;
return entry.status === 'planned' ? PT_STATUS.planned : PT_STATUS.done;
}
const PT_KIND_ICONS = {
intervention: ,
appointment: <>>,
event: <>>,
'treatment-start': <>>,
'treatment-end': ,
document: <>>,
note: <>>,
reminder: <>>,
control: <>>,
};
// Les couches de l’application se posent sur la scène, le seul conteneur qui a
// une taille et un ancrage. Une confirmation rendue dans le corps d’une feuille
// resterait sous son panneau.
function postopOverlayRoot() {
return window.document.getElementById('vasco-stage') || window.document.body;
}
function postopTimelineError(failure, fallback) {
if (failure?.status === 401) return 'Votre session a expiré. Reconnectez-vous avant de réessayer.';
if (failure?.status === 403) return 'Vous n’êtes pas autorisé à consulter ce suivi.';
if (failure?.status === 404) return 'Ce dossier n’existe plus.';
if (failure?.status === 0 || failure?.code === 'network_error' || failure?.code === 'request_timeout') {
return 'Connexion au serveur impossible. Vérifiez le réseau puis réessayez.';
}
return failure?.message || fallback;
}
// Chaque source est chargée pour elle-même : le fil d’un dossier sans document
// ne doit pas disparaître parce que la liste des documents a échoué. Ce qui
// manque est nommé sous le fil plutôt que caché.
function usePostopSources(patient, runtime, reloadToken) {
const [state, setState] = React.useState({ status: 'loading', sources: null, missing: [], error: null });
React.useEffect(() => {
if (!runtime) {
setState({ status: 'ready', sources: { patientId: patient.id }, missing: [], error: null });
return undefined;
}
let cancelled = false;
setState({ status: 'loading', sources: null, missing: [], error: null });
const dashboard = window.VascoApi?.dashboard?.reminders;
const wanted = [
['appointments', 'les rendez-vous', () => runtime.appointments(patient.id)],
['events', 'les séjours et consultations', () => runtime.events.list(patient.id, { eventType: 'all', pageSize: 100 })],
['notes', 'les notes', () => runtime.notes(patient.id)],
['documents', 'les documents', () => runtime.documents(patient.id, 'all')],
// Le fil ne garde que les rappels de ce dossier : il ne demande que
// ceux-là, ouverts et terminés en une requête, plutôt que toute la
// liste du praticien deux fois.
['reminders', 'les rappels', () => (dashboard
? dashboard.load({ patientId: patient.id, statuses: ['open', 'completed'] })
: Promise.resolve([]))],
// Les codages CCAM portent l’intitulé d’une intervention et son compte
// rendu : ils restent le repli quand aucun intitulé n’a été écrit.
['codings', 'les codages d’actes', () => runtime.ccamCodings.list(patient.id, { pageSize: 50 })],
];
// Le fil n’a besoin que des rendez-vous pour exister : il se pose dès leur
// réponse, et les cinq autres sources s’y ajoutent à mesure qu’elles
// arrivent. Attendre la plus lente pour tout montrer laissait l’onglet sur
// son texte de chargement bien après que la chronologie était lisible.
// Aucune arrivée ne fait revenir l’écran sur « chargement », et rien
// n’est dit manquant tant qu’une réponse est encore en route : une source
// qui n’a pas fini n’est pas une source tombée.
const loaded = {};
const failed = [];
let settled = 0;
let rejection = null;
let closed = false;
const publish = () => {
if (cancelled || closed) return;
if (failed.includes('appointments')) {
closed = true;
setState({
status: 'error',
sources: null,
missing: [],
error: postopTimelineError(rejection, 'Le suivi n’a pas pu être chargé.'),
});
return;
}
if (!Object.prototype.hasOwnProperty.call(loaded, 'appointments')) return;
const complete = settled === wanted.length;
setState({
status: 'ready',
sources: { patientId: patient.id, patient, ...loaded },
missing: complete
? wanted.filter(([key]) => failed.includes(key)).map(([, label]) => label)
: [],
error: null,
});
};
wanted.forEach(([key, , load]) => {
Promise.resolve().then(load).then(
(value) => {
loaded[key] = key === 'events' || key === 'documents' || key === 'codings'
? (value?.items || [])
: value;
},
(failure) => {
failed.push(key);
if (key === 'appointments') rejection = failure;
},
).then(() => { settled += 1; publish(); });
});
return () => { cancelled = true; };
}, [patient, runtime, reloadToken]);
return state;
}
// L’épisode de l’opération qu’on regarde, et d’elle seule. Le serveur en porte
// un par intervention : demander « celui du dossier » ramènerait l’état de la
// dernière opération au-dessus d’une autre, et ferait lire « À surveiller » sur
// une chirurgie qui n’a pas encore eu lieu.
function usePostopEpisode(patient, runtime, interventionDayKey, reloadToken) {
const [state, setState] = React.useState({ episode: null, loaded: false });
React.useEffect(() => {
if (!runtime || !interventionDayKey) { setState({ episode: null, loaded: true }); return undefined; }
let cancelled = false;
setState({ episode: null, loaded: false });
runtime.postOperative(patient.id, { interventionOn: interventionDayKey })
.then((response) => { if (!cancelled) setState({ episode: response?.episode || null, loaded: true }); })
.catch(() => { if (!cancelled) setState({ episode: null, loaded: true }); });
return () => { cancelled = true; };
}, [patient.id, runtime, interventionDayKey, reloadToken]);
return state;
}
// Le corps d’une ligne, tel que la chronologie de la V5 le pose : le carré
// d’icône teinté, l’intitulé, l’heure en chiffres fixes, puis le détail et la
// source alignés sous l’intitulé.
function PostopEntryBody({ entry, ss, past }) {
return (
<>
{entry.title}
{entry.time && {entry.time}}
{entry.detail &&
{entry.detail}
}
{entry.source &&
{entry.source}
}
>
);
}
function PostopDayHeading({ day, pivot }) {
// Le jour de l’opération est le seul repère du fil : tout le reste se compte
// à partir de lui. Il porte donc une pastille pleine à l’accent médical plutôt
// qu’un simple gras – jamais le rouge, qui reste au danger.
if (pivot) {
return (
);
}
// Un seul fil, du premier jour au dernier. Les en-têtes de journée sont des
// rangées comme les autres : le trait leur passe derrière au lieu d’être coupé
// à chaque date, sans quoi une journée à un seul événement laissait une pastille
// toute seule au milieu de rien.
function PostopTimelineRail({ days, interventionDayKey, onOpen }) {
const { InteractiveRow } = window.VascoUI;
const rows = [];
days.forEach((day) => {
const pivot = day.dayKey === interventionDayKey;
rows.push({ kind: 'day', key: `day:${day.dayKey}`, day, entry: day.entries[0], pivot });
day.entries.forEach((entry, index) => rows.push({
kind: 'entry', key: entry.id, entry, pivot, pivotLast: pivot && index === day.entries.length - 1,
}));
});
return rows.map((row, index) => {
const last = index === rows.length - 1;
const ss = postopEntryStyle(row.entry);
const past = row.entry.status !== 'planned';
// La journée de l’intervention est enveloppée : chaque rangée porte le même
// fond, et seules la première et la dernière arrondissent leur bord.
const halo = !row.pivot ? null : {
background: PT_PIVOT_TINT,
margin: '0 -12px', padding: '0 12px',
borderLeft: `1px solid ${PT_PIVOT_BORDER}`,
borderRight: `1px solid ${PT_PIVOT_BORDER}`,
borderTop: row.kind === 'day' ? `1px solid ${PT_PIVOT_BORDER}` : 'none',
borderBottom: row.pivotLast ? `1px solid ${PT_PIVOT_BORDER}` : 'none',
borderTopLeftRadius: row.kind === 'day' ? 14 : 0,
borderTopRightRadius: row.kind === 'day' ? 14 : 0,
borderBottomLeftRadius: row.pivotLast ? 14 : 0,
borderBottomRightRadius: row.pivotLast ? 14 : 0,
paddingTop: row.kind === 'day' ? 10 : 0,
};
const body = row.kind === 'day'
?
: ;
return (
);
});
}
// Les traitements que le fil ne montre pas : ceux dont aucune borne ne tombe
// dans la tranche affichée, et ceux dont le dossier ne porte aucune date. Ils se
// lisent ici avec la période que leur fiche porte, plutôt que de disparaître de
// l’écran. Quand aucun n’a de date, le dire évite qu’on les cherche à une journée.
function PostopTreatments({ treatments }) {
const contract = window.VascoPostopTimeline;
if (!treatments.length) return null;
const undated = treatments.every((item) => !item.startedOn && !item.endedOn);
return (
Traitements du dossier
{treatments.map((treatment) => {
const range = contract.treatmentRangeLabel(treatment);
return (
{treatment.label}
{range && (
{range}
)}
);
})}
{undated && (
Le dossier ne garde aucune date de début ni de fin pour ces lignes : elles ne se posent
donc pas dans le fil.
)}
);
}
function PostopFact({ label, value }) {
if (!value) return null;
return (
{label}{value}
);
}
function postopAppointmentLabel(type) {
return window.VascoPostopTimeline.APPOINTMENT_LABELS[type] || 'Rendez-vous';
}
// Ce qu’il y a derrière une ligne du fil. Le fil ne montre qu’un intitulé et une
// heure ; le reste – le texte entier d’une note, le contenu d’un compte rendu,
// les actes réellement codés – se lit ici, sans quitter le suivi.
//
// Rien n’y est recopié : le document est relu au serveur au moment où on
// l’ouvre, et les actes viennent du codage lui-même.
function PostopEntryDetail({ open, patient, entry, runtime, onClose, onChanged }) {
const { BottomSheet, Button, InlineAction, StatusNotice, ConfirmDialog } = window.VascoUI;
const contract = window.VascoPostopTimeline;
const ref = entry?.ref || {};
const coding = ref.coding || null;
const [document, setDocument] = React.useState(null);
const [documentError, setDocumentError] = React.useState(null);
const [reminder, setReminder] = React.useState(null);
const [commandBusy, setCommandBusy] = React.useState(false);
const [commandError, setCommandError] = React.useState(null);
const [confirmingDelete, setConfirmingDelete] = React.useState(false);
const can = (action) => reminder?.capabilities?.[action]?.enabled !== false;
const commandReminder = async (action) => {
const commands = window.VascoApi?.dashboard?.reminders;
if (!reminder || !commands?.[action] || commandBusy) return;
setCommandBusy(true); setCommandError(null);
try {
const updated = await commands[action](reminder);
setReminder(updated);
setConfirmingDelete(false);
showToast(action === 'archive' ? 'Rappel supprimé'
: action === 'complete' ? 'Rappel marqué comme fait' : 'Rappel rouvert', 'success');
onChanged();
if (action === 'archive') onClose();
} catch (failure) {
setConfirmingDelete(false);
setCommandError(postopTimelineError(failure, 'Le rappel n’a pas pu être modifié.'));
} finally { setCommandBusy(false); }
};
// La lecture appartient à la ligne qui l’a demandée. Sans ce garde, une
// réponse partie pour un compte rendu revenait après l’ouverture de la ligne
// suivante et affichait son texte sous un tout autre intitulé.
const readFor = React.useRef(null);
const readDocument = React.useCallback(async (documentId, owner) => {
if (!runtime?.document || !documentId) { setDocument(null); return; }
readFor.current = owner;
setDocument('loading'); setDocumentError(null);
try {
const value = await runtime.document(patient.id, documentId);
if (readFor.current !== owner) return;
setDocument(value);
} catch (failure) {
if (readFor.current !== owner) return;
setDocument(null);
setDocumentError(postopTimelineError(failure, 'Ce document n’a pas pu être ouvert.'));
}
}, [patient.id, runtime]);
React.useEffect(() => {
if (!open) return;
setCommandError(null);
setConfirmingDelete(false);
setDocumentError(null);
setReminder(ref.reminder || null);
readFor.current = entry?.id || null;
if (ref.kind === 'document' && ref.document?.id) void readDocument(ref.document.id, entry.id);
else setDocument(null);
// `entry.id` identifie la ligne ouverte : rouvrir la même relit son document.
}, [open, entry?.id]);
const facts = [];
if (entry) {
if (entry.time) facts.push(['Heure', entry.time]);
if (ref.kind === 'appointment') {
facts.push(['Type', postopAppointmentLabel(ref.appointment?.type)]);
facts.push(['Lieu', ref.appointment?.lieu]);
facts.push(['Horaire', `${ref.appointment?.start} – ${ref.appointment?.end}`]);
if (ref.appointment?.urgent) facts.push(['Urgence', 'Rendez-vous marqué urgent']);
}
if (ref.kind === 'event') {
facts.push(['Nature', contract.EVENT_LABELS[ref.event?.eventType] || 'Événement']);
facts.push(['Praticien', ref.event?.practitioner]);
if (ref.event?.actCode) facts.push(['Code d’acte', ref.event.actCode]);
if (ref.parent) {
facts.push(['Rattaché à',
`${contract.EVENT_LABELS[ref.parent.eventType] || 'Séjour'} du ${contract.dayTitle(ref.parent.eventDate)}`]);
}
}
if (ref.kind === 'reminder') {
facts.push(['Échéance', reminder?.date]);
facts.push(['État', reminder?.done ? 'Fait' : 'À faire']);
}
if (ref.kind === 'control') {
facts.push(['Échéance', contract.dayTitle(ref.episode?.nextControlDate)]);
facts.push(['État', window.VascoPostopFollowUp.stateLabel(ref.episode)]);
}
if (ref.kind === 'treatment') {
facts.push(['Période', contract.treatmentRangeLabel(ref.treatment)]);
}
if (ref.kind === 'document') {
facts.push(['Type', ref.document?.type]);
if (document?.version) facts.push(['Version', `version ${document.version}`]);
}
// « Par » ne se répète pas : pour un événement, la source EST le praticien
// déjà nommé plus haut.
if (entry.source && ref.kind !== 'event') facts.push(['Par', entry.source]);
}
const acts = Array.isArray(coding?.acts) ? coding.acts : [];
const croId = coding?.croDocumentId || null;
const readDocumentContent = document && document !== 'loading' ? document : null;
return <>
{facts.map(([label, value]) => (
))}
{/* Un événement du dossier n’a d’intitulé que si quelqu’un l’a écrit :
le dire ici plutôt que de laisser chercher où le saisir. */}
{ref.kind === 'event' && !ref.event?.label && !coding && (
Aucun intitulé n’a été écrit sur cette ligne. Il se saisit dans « Séjours et
gestes » du dossier ; à défaut, il viendrait du codage d’actes, qui n’a pas
encore été fait pour celle-ci.
)}
{ref.kind === 'note' && (
{ref.note?.text}
)}
{/* La décision d’un staff se lit là où on lit le staff, en entier. */}
{ref.appointment?.staffRcpDecision && <>
>}
{croId && !readDocumentContent && document !== 'loading' && (
void readDocument(croId, entry.id)}
style={{ display: 'flex', marginTop: 14, color: PT_ACCENT, fontWeight: 650 }}>
Lire le compte rendu opératoire
)}
{/* Un rappel se commande depuis l’endroit où on le lit : le retrouver
dans la cloche du tableau de bord pour le refermer n’avait aucun
sens. Les trois commandes sont celles du serveur, avec ses propres
refus – un rappel rangé ne se rouvre pas. */}
{ref.kind === 'reminder' &&
{commandError && }
{reminder?.done
?
: }
}
{/* La confirmation se pose SUR la feuille, pas dedans : imbriquée, elle
resterait sous son panneau et ne se verrait pas. */}
{ReactDOM.createPortal(
setConfirmingDelete(false)}
onConfirm={() => void commandReminder('archive')} />,
postopOverlayRoot())}
>;
}
// Poser un rappel, ou demander qu’un courriel parte avant un rendez-vous. Les
// deux se programment ; ce sont les deux seules choses que le serveur sait
// vraiment faire partir, et l’écran n’en promet pas une troisième.
function PostopScheduling({ open, patient, appointments, runtime, onClose, onDone }) {
const { BottomSheet, Button, ChoiceChip, Field, DayPicker, StatusNotice, InteractiveRow, SimpleSelect } = window.VascoUI;
const contract = window.VascoPostopTimeline;
const agenda = window.VascoAgendaGeneral || {};
const leadChoices = agenda.REMINDER_LEAD_CHOICES || [[1440, 'La veille']];
const [mode, setMode] = React.useState('reminder');
const [text, setText] = React.useState('');
const [due, setDue] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
const [fieldErrors, setFieldErrors] = React.useState({});
const [capability, setCapability] = React.useState(undefined);
const [chosen, setChosen] = React.useState(null);
const [lead, setLead] = React.useState(agenda.REMINDER_DEFAULT_LEAD_MINUTES || 1440);
const [channelReady, setChannelReady] = React.useState(null);
const [customLead, setCustomLead] = React.useState(false);
const [leadAmount, setLeadAmount] = React.useState('3');
const [leadUnit, setLeadUnit] = React.useState('days');
const [rdvType, setRdvType] = React.useState('consultation');
const [duration, setDuration] = React.useState(30);
const [customDuration, setCustomDuration] = React.useState(false);
const [durationAmount, setDurationAmount] = React.useState('45');
const [place, setPlace] = React.useState('');
const leadBounds = contract.leadBounds(leadUnit);
const customMinutes = contract.leadMinutes(leadAmount, leadUnit);
const upcoming = contract.upcomingAppointments(appointments);
// Une feuille rouverte repart propre : elle reste montée pour son animation
// de sortie, pas pour garder la saisie précédente.
React.useEffect(() => {
if (!open) return;
setMode('reminder'); setText(''); setDue(null);
setError(null); setFieldErrors({}); setChosen(null);
setCustomLead(false); setLead(agenda.REMINDER_DEFAULT_LEAD_MINUTES || 1440);
setCustomDuration(false); setDuration(30); setPlace(''); setRdvType('consultation');
}, [open]);
React.useEffect(() => {
if (!open || mode !== 'mail' || capability !== undefined) return undefined;
let cancelled = false;
window.VascoApi?.agenda?.reminderCapability(patient.id)
.then((value) => { if (!cancelled) setCapability(value); })
.catch(() => { if (!cancelled) setCapability(null); });
return () => { cancelled = true; };
}, [open, mode, capability, patient.id]);
// Le dossier peut très bien accepter un courriel pendant qu’aucun serveur
// d’envoi n’est branché sur l’environnement : le rappel s’enregistre alors
// sans jamais partir. Le rendez-vous choisi porte cette réponse ; l’écran la
// lit et la dit, plutôt que d’annoncer un envoi qui n’aura pas lieu.
React.useEffect(() => {
if (!chosen?.id) { setChannelReady(null); return undefined; }
let cancelled = false;
window.VascoApi?.agenda?.reminder(chosen.id)
.then((value) => { if (!cancelled) setChannelReady(value?.channelConfigured !== false); })
.catch(() => { if (!cancelled) setChannelReady(null); });
return () => { cancelled = true; };
}, [chosen?.id]);
const saveReminder = async () => {
const analysis = contract.reminderDraft(patient, due, text);
if (Object.keys(analysis.errors).length) { setFieldErrors(analysis.errors); return; }
setBusy(true); setError(null); setFieldErrors({});
try {
await window.VascoApi.dashboard.reminders.create(analysis.body);
showToast('Rappel posé', 'success');
onDone();
} catch (failure) {
setError(postopTimelineError(failure, 'Le rappel n’a pas pu être posé.'));
} finally { setBusy(false); }
};
// « Autre… » : du texte libre, rangé là où le dossier range son texte libre.
const saveNote = async () => {
const analysis = contract.noteDraft(text);
if (Object.keys(analysis.errors).length) { setFieldErrors(analysis.errors); return; }
setBusy(true); setError(null); setFieldErrors({});
try {
await runtime.createNote(patient.id, analysis.body.text);
showToast('Noté au dossier', 'success');
onDone();
} catch (failure) {
setError(postopTimelineError(failure, 'La note n’a pas pu être enregistrée.'));
} finally { setBusy(false); }
};
const saveAppointment = async () => {
const analysis = contract.appointmentDraft({
reason: text, type: rdvType, stamp: due, location: place,
durationMinutes: customDuration ? contract.durationMinutes(durationAmount) : duration,
});
if (Object.keys(analysis.errors).length) { setFieldErrors(analysis.errors); return; }
setBusy(true); setError(null); setFieldErrors({});
try {
// Le serveur SIGNALE un chevauchement, il ne le refuse pas : la décision
// reste au praticien, et l’avertissement se lit au lieu de disparaître.
const result = await runtime.createAppointment(patient.id, analysis.draft);
const warnings = (result?.warnings || []).map((warning) => warning?.message || warning).filter(Boolean);
showToast(warnings.length ? `Rendez-vous créé · ${warnings[0]}` : 'Rendez-vous créé', 'success');
onDone();
} catch (failure) {
setError(postopTimelineError(failure, 'Le rendez-vous n’a pas pu être créé.'));
} finally { setBusy(false); }
};
const effectiveLead = customLead ? customMinutes : lead;
const saveMail = async () => {
if (!chosen) { setError('Choisissez le rendez-vous que le courriel doit annoncer.'); return; }
if (effectiveLead === null) { setError('Corrigez le délai avant d’enregistrer.'); return; }
setBusy(true); setError(null);
try {
await window.VascoApi.agenda.setReminder(chosen.id, {
enabled: true, channel: 'email', leadTimeMinutes: effectiveLead,
});
showToast('Courriel de rappel programmé', 'success');
onDone();
} catch (failure) {
setError(postopTimelineError(failure, 'Le courriel de rappel n’a pas pu être programmé.'));
} finally { setBusy(false); }
};
const mailBlocked = capability === null
|| (capability && !capability.availableChannels.includes('email'));
const mailReason = capability?.reasonCode === 'patient_opted_out'
? 'Ce patient a demandé à ne pas recevoir de rappel.'
: 'Aucune adresse de courriel n’est renseignée dans ce dossier.';
const textAreaStyle = (invalid) => ({
width: '100%', padding: '10px 12px', borderRadius: 12, resize: 'vertical',
border: `1px solid ${invalid ? PT_DANGER : PT_HAIR2}`,
background: PT_SURFACE, color: PT_INK, fontFamily: 'inherit',
fontSize: 13.5, fontWeight: 500, lineHeight: 1.45,
});
const inputStyle = (invalid) => ({
width: '100%', padding: '10px 12px', borderRadius: 12,
border: `1px solid ${invalid ? PT_DANGER : PT_HAIR2}`,
background: PT_SURFACE, color: PT_INK, fontFamily: 'inherit',
fontSize: 13.5, fontWeight: 500,
});
const captionStyle = {
marginBottom: 6, color: PT_SUB, fontSize: 9.5,
fontWeight: 600, letterSpacing: '0.4px', textTransform: 'uppercase',
};
const errorStyle = { marginTop: -8, marginBottom: 12, color: PT_DANGER, fontSize: 11.5, fontWeight: 600 };
return (
{/* Hauteur tenue : les modes ne remplissent pas la feuille de la même
façon. Sans plancher, passer de l’un à l’autre ferait sauter le panneau
sous le doigt, juste sous les pastilles qu’on vient de toucher. */}
{/* Quatre choses, et seulement celles que le serveur sait enregistrer.
« Autre… » n’est pas une cinquième catégorie : c’est du texte libre,
et le texte libre du dossier s’appelle une note. */}
{ setMode('reminder'); setError(null); setFieldErrors({}); }}>
Un rappel pour moi
{ setMode('appointment'); setError(null); setFieldErrors({}); }}>
Un rendez-vous
{ setMode('mail'); setError(null); setFieldErrors({}); }}>
Un courriel au patient
{ setMode('note'); setError(null); setFieldErrors({}); }}>
Autre…
{/* Trois jours, dix heures : le serveur accepte tout entre cinq
minutes et vingt-huit jours. Les bornes affichées sont les
siennes, l’écran n’en invente pas de plus étroites. */}
{customLead &&
Entre {leadBounds.min} et {leadBounds.max} {leadUnit === 'hours' ? 'heures' : 'jours'}.
)}
{channelReady === false && }
Le courriel annonce le rendez-vous choisi{capability?.destinationHint
? `, à ${capability.destinationHint}` : ''} : la date, l’heure, le lieu et le nom du
cabinet, rien du dossier. Son texte est écrit par le serveur et ne se modifie pas
depuis cet écran.
>}
>}
{error &&
{error}
}
);
}
// Le staff : la réunion où l’équipe discute le dossier et arrête une conduite.
// Deux gestes, et pas un de plus, parce que le serveur n’en porte que deux :
// inscrire le patient à un staff, et y consigner la décision prise, que le
// serveur horodate lui-même.
function PostopStaffSection({ title, children }) {
if (!children) return null;
return (
{title}
{children}
);
}
function PostopStaffLine({ children, tone = null }) {
return (
;
}
// Pas de chirurgie programmée, pas de suivi. L’écran ne fabrique pas un cadre
// vide à remplir : il dit ce qui l’ouvrira.
if (!timeline?.intervention) {
return
Aucune chirurgie programmée
Le suivi s’ouvre tout seul dès qu’une intervention est posée dans l’agenda de ce
dossier. Il n’y a rien à remplir ici en attendant.
;
}
const shownIntervention = timeline.intervention;
const offset = contract.daysBetween(timeline.interventionDayKey, timeline.todayKey);
const situation = offset === null ? ''
: offset === 0 ? 'Intervention aujourd’hui'
: offset > 0 ? `J+${offset} après l’intervention`
: `Intervention dans ${Math.abs(offset)} jour${Math.abs(offset) > 1 ? 's' : ''}`;
// Le bandeau d’état de la V5, à sa place : l’état vient du serveur, qui le
// calcule selon une règle versionnée. Le serveur rattache chaque épisode à
// une opération ; sans épisode pour celle qu’on regarde, il n’y a pas d’état
// à rapporter, et le bandeau reste neutre plutôt que d’annoncer une évolution
// favorable que personne n’a constatée.
const shownEpisode = timeline.episode;
const alert = shownEpisode?.status === 'alert';
const tone = !shownEpisode ? PT_STATUS.planned : (alert ? PT_STATUS.alert : PT_STATUS.done);
const shortDate = (instant) => new Intl.DateTimeFormat('fr-FR', {
day: '2-digit', month: '2-digit', timeZone,
}).format(new Date(instant));
return (
{/* Un patient opéré plusieurs fois a plusieurs suites, et elles ne se
mélangent pas. Le choix ne s’affiche que s’il y a vraiment à choisir. */}
{timeline.interventions.length > 1 && (
{/* L’échéance de contrôle appartient à l’épisode de cette opération : elle
ne s’affiche donc qu’avec elle, et se modifie sur le même épisode. */}
{/* L’épisode est déjà lu plus haut pour le bandeau : le bloc le reçoit
au lieu de redemander la même route au serveur, ce qui ajoutait un
aller-retour en série après celui du fil. */}
{shownEpisode && window.PostopNextControlBlock && (
)}
Chronologie post-opératoire
{/* Le fil est une lecture, pas un registre : le dire ici évite qu’on
cherche plus tard où « ajouter un événement ». */}
Ce fil rassemble ce qui est déjà écrit dans le dossier : rendez-vous, notes,
documents, séjours, rappels. « Ajouter » n’en propose que quatre parce que ce sont
les quatre que le serveur sait enregistrer ; un séjour ou un geste s’écrit encore
ailleurs, et un relevé de constantes nulle part.
{missing.length > 0 && ` Non chargé pour l’instant : ${missing.join(', ')}.`}
{shown && setOpened(null)}
onChanged={() => setReloadToken((token) => token + 1)} />}
setScheduling(false)}
onDone={() => { setScheduling(false); setReloadToken((token) => token + 1); }} />
setStaffing(false)}
onOpenDocument={(documentId) => {
// Le compte rendu s’ouvre par la feuille de détail du fil, celle qui
// sait déjà relire un document au serveur.
const line = timeline.days.flatMap((day) => day.entries)
.find((item) => item.id === `document:${documentId}`);
setStaffing(false);
setOpened(line || {
id: `document:${documentId}`, kind: 'document', dayKey: timeline.interventionDayKey,
title: 'Compte rendu opératoire', detail: '', source: '', status: 'done',
ref: { kind: 'document', document: { id: documentId } },
});
}}
onDone={() => { setStaffing(false); setReloadToken((token) => token + 1); }} />
);
}
// Le fil remplace l’onglet figé de la V5 : il ne s’y ajoute pas. L’ancienne
// chronologie, ses constantes, son bouton SMS jamais raccordé et son bouton
// « Appeler » disparaissent avec lui.
TabPostOp = PostopTimelineTab;
window.TabPostOp = PostopTimelineTab;