// 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
;
}
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;