// Extensions connectées du suivi post-opératoire et du tableau de bord. // Les composants figés de la V5 restent intacts : leurs points d’extension // React sont enveloppés après leur chargement. const PF_ORIGINAL_SMART_STACK = SmartStack; const PF_ORIGINAL_DASHBOARD = Dashboard; // Palette locale prefixee POSTOP_ : 'patients-folders.jsx' de la reference // figee possede deja PF_VT, PF_INK, PF_SUB, PF_HAIR et PF_HAIR2 dans la meme // portee globale. Seuls les points d extension partages gardent le prefixe PF_. const POSTOP_VT = window.VT || {}; const POSTOP_INK = POSTOP_VT.ink || '#0a0a0a'; const POSTOP_SUB = POSTOP_VT.sub || '#6b7280'; const POSTOP_SURFACE = POSTOP_VT.surface || '#fff'; const POSTOP_PAPER = POSTOP_VT.paper || '#f6f5f1'; const POSTOP_HAIR = POSTOP_VT.hair || 'rgba(10,10,10,0.08)'; const POSTOP_HAIR2 = POSTOP_VT.hair2 || 'rgba(10,10,10,0.14)'; const POSTOP_DANGER = POSTOP_VT.danger || '#b91c1c'; const PF_NAVIGATION = React.createContext(null); const PF_STATE_LABELS = { unscheduled: 'Non planifié', upcoming: 'À venir', due_today: 'Aujourd’hui', overdue: 'En retard de', }; function postopFollowUpError(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é à modifier cette surveillance.'; if (failure?.status === 404) return 'Cet épisode post-opératoire ou 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; } function postopFollowUpConflict(failure) { return failure?.status === 409 || failure?.code === 'version_conflict' || failure?.code === 'idempotency_conflict'; } function postopFollowUpDate(value) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || ''); if (!match) return value || '–'; return new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC', }).format(new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])))); } function NextControlEditor({ editor, accent, busy, error, fieldError, conflict, onChange, onClose, onSave, onReload }) { const { BottomSheet, Button, InlineAction, DayPicker } = window.VascoUI; return
{/* Le calendrier de l'application, jamais celui du navigateur : il vient par-dessus l'écran, dans sa langue et son style. */} {fieldError && } {error &&
{error} {conflict && Recharger l’épisode }
}
; } function NextControlBlock({ p, accent, runtime, interventionOn = null, loadedEpisode = undefined }) { const { InlineAction } = window.VascoUI; const contract = window.VascoPostopFollowUp; // L’écran qui monte ce bloc a parfois déjà l’épisode de cette opération : // il le passe alors ici. Le redemander au serveur ajoutait un aller-retour // en série derrière celui du fil, pour la même réponse. Sans cette // présentation, le bloc le lit lui-même comme avant. const preloaded = loadedEpisode !== undefined; const [episode, setEpisode] = React.useState(preloaded ? loadedEpisode : undefined); const [editor, setEditor] = React.useState(null); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const [fieldError, setFieldError] = React.useState(null); const [conflict, setConflict] = React.useState(false); const [clearError, setClearError] = React.useState(null); const clearAttempt = React.useRef(null); const patientActive = contract.patientActive(p); // L'épisode suivi est celui de l'opération qu'on regarde : un dossier peut // en porter plusieurs, et modifier l'échéance de l'un sous le titre d'un // autre écrirait dans le mauvais épisode. const loadEpisode = React.useCallback(async () => { if (!runtime?.postOperative) { setEpisode(null); return null; } const response = await runtime.postOperative(p.id, { interventionOn }); setEpisode(response.episode || null); return response.episode || null; }, [p.id, runtime, interventionOn]); React.useEffect(() => { if (preloaded) { setEpisode(loadedEpisode); setClearError(null); return undefined; } let cancelled = false; setEpisode(undefined); runtime?.postOperative(p.id, { interventionOn }) .then((response) => { if (!cancelled) { setEpisode(response.episode || null); setClearError(null); } }) .catch((failure) => { if (!cancelled) { setEpisode(null); setClearError(postopFollowUpError(failure, 'Le prochain contrôle n’a pas pu être chargé.')); } }); return () => { cancelled = true; }; }, [p.id, runtime, interventionOn, preloaded, loadedEpisode]); const openEditor = () => { if (!episode || !patientActive || !runtime?.followUp) return; setEditor(contract.openEditor(episode, () => runtime.followUp.newAttempt())); setError(null); setFieldError(null); setConflict(false); }; const changeDate = (date) => { setEditor((current) => contract.changeEditor(current, date, () => runtime.followUp.newAttempt())); setFieldError(null); setError(null); setConflict(false); }; const save = async (event) => { event.preventDefault(); if (!editor || busy) return; const analysis = contract.buildMutation(editor.baseline, editor.date); if (analysis.errors.next_control_date) { setFieldError(analysis.errors.next_control_date); setError('Corrigez la date avant d’enregistrer.'); return; } if (!analysis.dirty) { setError('Aucune modification à enregistrer.'); return; } setBusy(true); setError(null); setFieldError(null); try { const updated = await contract.performMutation( analysis, (body) => runtime.followUp.setNextControl( p.id, editor.baseline, body.next_control_date, editor.attempt, ), ); setEpisode((current) => ({ ...current, version: updated.version, nextControlDate: updated.nextControlDate, next_control_date: updated.nextControlDate, followUpState: updated.followUpState, follow_up_state: updated.followUpState, daysOverdue: updated.daysOverdue, days_overdue: updated.daysOverdue, })); setEditor(null); showToast('Prochain contrôle enregistré', 'success'); } catch (failure) { if (postopFollowUpConflict(failure)) { setConflict(true); setError('L’épisode a changé entre-temps. Votre date est conservée. Rechargez avant de réessayer.'); } else { setError(postopFollowUpError(failure, 'Le prochain contrôle n’a pas pu être enregistré.')); } } finally { setBusy(false); } }; const reloadConflict = async () => { if (!editor || busy) return; setBusy(true); try { const fresh = await loadEpisode(); if (!fresh || fresh.id !== editor.baseline.id) { setError('Cet épisode n’existe plus dans le dossier rechargé.'); setConflict(false); return; } setEditor(contract.rebaseEditor(editor, fresh, () => runtime.followUp.newAttempt())); setConflict(false); setError('Épisode rechargé. Vérifiez la date puis réessayez.'); } catch (failure) { setError(postopFollowUpError(failure, 'L’épisode n’a pas pu être rechargé. Votre date est conservée.')); } finally { setBusy(false); } }; const clear = async () => { if (!episode?.nextControlDate || busy || !runtime?.followUp) return; if (!window.confirm('Voulez-vous effacer cette date de surveillance ? Aucun rendez-vous ne sera modifié.')) return; if (!clearAttempt.current) clearAttempt.current = runtime.followUp.newAttempt(); setBusy(true); setClearError(null); try { const updated = await runtime.followUp.setNextControl(p.id, episode, null, clearAttempt.current); clearAttempt.current = null; setEpisode((current) => ({ ...current, version: updated.version, nextControlDate: null, next_control_date: null, followUpState: updated.followUpState, follow_up_state: updated.followUpState, daysOverdue: null, days_overdue: null, })); showToast('Date de surveillance effacée', 'success'); } catch (failure) { setClearError(postopFollowUpConflict(failure) ? 'L’épisode a changé entre-temps. Rechargez-le avant de confirmer l’effacement.' : postopFollowUpError(failure, 'La date n’a pas pu être effacée.')); } finally { setBusy(false); } }; const reloadAfterClearConflict = async () => { setBusy(true); try { await loadEpisode(); clearAttempt.current = runtime.followUp.newAttempt(); setClearError('Épisode rechargé. Vérifiez la date avant de recommencer.'); } catch (failure) { setClearError(postopFollowUpError(failure, 'L’épisode n’a pas pu être rechargé.')); } finally { setBusy(false); } }; const label = episode ? contract.stateLabel(episode) : PF_STATE_LABELS.unscheduled; return (
Prochain contrôle {episode === undefined ? (
Chargement de l’échéance…
) : ( <>
{label}
{episode?.nextControlDate &&
{postopFollowUpDate(episode.nextControlDate)}
} )}
{episode && patientActive && {episode.nextControlDate ? 'Modifier' : 'Définir'}}
Cette échéance ne crée pas de rendez-vous. Planifiez-le séparément dans l’Agenda.
{!patientActive &&
Définir ou modifier une échéance est indisponible pour ce dossier.
} {!patientActive && episode?.nextControlDate && Effacer} {patientActive && episode?.nextControlDate && Effacer la date} {clearError &&
{clearError} {clearError.includes('Recharg') && Recharger l’épisode }
} {editor && ReactDOM.createPortal( setEditor(null)} onSave={save} onReload={reloadConflict} />, document.getElementById('root'), )}
); } // L'échéance de contrôle est posée dans le fil du suivi (`postop-timeline.jsx`), // qui remplace l'onglet figé de la V5. Ce bloc y est monté tel quel plutôt que // réécrit : c'est la même commande, avec le même conflit de version et le même // effacement. window.PostopNextControlBlock = NextControlBlock; function overdueFollowUpError(failure) { if (failure?.status === 401) return 'Votre session a expiré. Reconnectez-vous pour charger les surveillances.'; if (failure?.status === 403) return 'Vous n’êtes pas autorisé à consulter les surveillances en retard.'; if (failure?.status === 404) return 'La liste des surveillances n’est pas disponible sur ce serveur.'; 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 || 'Les surveillances en retard n’ont pas pu être chargées.'; } function OverdueFollowUpsSheet({ payload, state, error, busy, accent, onClose, onReload, onOpenPatient }) { const { BottomSheet, InlineAction, InteractiveRow } = window.VascoUI; return ReactDOM.createPortal(
{state === 'loading' &&
Chargement des surveillances…
} {state === 'error' &&
{error} Réessayer
} {state === 'ready' && payload?.items.length === 0 &&
Aucune surveillance en retard.
} {state === 'ready' && payload?.items.map((item) => { const content = <>
{item.patient.displayName}
{item.interventionLabel}
{item.daysOverdue} J
Contrôle prévu le {postopFollowUpDate(item.nextControlDate)} · En retard de {item.daysOverdue} jours
{item.patient.archived &&
Patient archivé
} ; const style = { width: '100%', padding: '13px 0', border: 'none', borderTop: `1px solid ${POSTOP_HAIR}`, background: 'none', textAlign: 'left', fontFamily: 'inherit' }; const target = window.VascoPostopFollowUp.dashboardTarget(item); return !target ?
{content}
: { onClose(); onOpenPatient(target.patientId, target.tab); }} style={{ ...style, cursor: 'pointer', borderRadius: 0 }}>{content}; })} {state === 'ready' && payload?.hasMore &&
Les 50 échéances les plus anciennes sont affichées.
}
, document.getElementById('root'), ); } function OverdueFollowUpsSurface({ accent }) { const { InteractiveRow } = window.VascoUI; const onOpenPatient = React.useContext(PF_NAVIGATION); const runtime = window.VascoApi?.dashboard?.overdueFollowUps; const [state, setState] = React.useState('loading'); const [payload, setPayload] = React.useState(null); const [error, setError] = React.useState(null); const [open, setOpen] = React.useState(false); const [busy, setBusy] = React.useState(false); const load = React.useCallback(async () => { if (!runtime) { setState('error'); setError('Les surveillances en retard ne sont pas raccordées.'); return; } setBusy(true); setState('loading'); setError(null); try { setPayload(await runtime.load()); setState('ready'); } catch (failure) { setPayload(null); setState('error'); setError(overdueFollowUpError(failure)); } finally { setBusy(false); } }, [runtime]); React.useEffect(() => { void load(); }, [load]); const status = state === 'loading' ? 'Chargement des surveillances…' : state === 'error' ? 'Chargement impossible' : payload.count === 0 ? 'Aucune surveillance en retard' : `${payload.count} en retard`; return (
setOpen(true)} ariaLabel={`Surveillances en retard. ${status}`} style={{ width: '100%', minHeight: 42, padding: '8px 14px', borderRadius: 14, border: `1px solid ${state === 'ready' && payload?.count ? 'rgba(185,28,28,.18)' : POSTOP_HAIR}`, background: POSTOP_SURFACE, cursor: 'pointer', fontFamily: 'inherit', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, }}> Surveillances en retard {status} {open && setOpen(false)} onReload={load} onOpenPatient={onOpenPatient || (() => {})} />}
); } function ConnectedSmartStack(props) { return ( <> ); } function ConnectedDashboard(props) { return ( ); } SmartStack = ConnectedSmartStack; window.SmartStack = ConnectedSmartStack; Dashboard = ConnectedDashboard; window.Dashboard = ConnectedDashboard;