// 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 ( <>
{PT_KIND_ICONS[entry.kind] || PT_KIND_ICONS.note}
{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 (
{day.label} {day.title} {day.today && AUJOURD’HUI}
); } return (
{day.label} {day.title} {day.today && AUJOURD’HUI}
); } // 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 (
{row.kind === 'entry' &&
} {!last &&
}
{row.kind === 'day' ? body : ( onOpen(row.entry)} style={{ width: '100%', minHeight: 0, padding: 0, border: 'none', background: 'none', textAlign: 'left', fontFamily: 'inherit', cursor: 'pointer', display: 'block', }}>{body} )}
); }); } // 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 (
); })}
{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 && <>
Décision du staff
{ref.appointment.staffRcpDecision}
} {acts.length > 0 && <>
Actes codés
{acts.map((act) => (
{act.code} {act.label}
))} } {documentError && } {document === 'loading' && (
Ouverture du document…
)} {readDocumentContent && <>
{readDocumentContent.title}
{readDocumentContent.content}
} {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…
{mode === 'reminder' && <>