// Bloc connecté des traitements du résumé patient. Il réunit le traitement et
// son éventuel arrêt dans la même ligne ; la décision reste saisie par le
// praticien et le serveur ne fait que mémoriser la consigne et son rappel.
// Prefixe TSTOP_ et non TS_ : 'toast.jsx' de la reference figee declare deja
// TS_VT, TS_INK et TS_HAIR2 au sommet de cette meme portee globale.
const TSTOP_VT = window.VT || {};
const TSTOP_INK = TSTOP_VT.ink || '#0a0a0a';
const TSTOP_SUB = TSTOP_VT.sub || '#6b7280';
const TSTOP_HAIR = TSTOP_VT.hair || 'rgba(10,10,10,0.08)';
const TSTOP_HAIR2 = TSTOP_VT.hair2 || 'rgba(10,10,10,0.14)';
const TSTOP_SURFACE = TSTOP_VT.surface || '#f6f5f1';
const TSTOP_DANGER = TSTOP_VT.danger || '#b91c1c';
const TreatmentStopChoice = window.VascoUI?.SimpleSelect;
function treatmentStopControlStyle(multiline = false) {
return {
width: '100%', minHeight: multiline ? 76 : 44,
resize: multiline ? 'vertical' : undefined,
padding: multiline ? '10px 12px' : '0 12px',
background: '#fff', border: `1px solid ${TSTOP_HAIR2}`, borderRadius: 16,
color: TSTOP_INK, outline: 'none', fontFamily: 'inherit', fontSize: 14,
lineHeight: multiline ? 1.45 : undefined,
};
}
function TreatmentStopField({ id, label, required = false, error, children }) {
return (
{label}{required && * }
{React.cloneElement(children, {
id,
'aria-required': required || undefined,
'aria-invalid': error ? 'true' : undefined,
'aria-describedby': error ? `${id}-error` : undefined,
})}
{error &&
{error}
}
);
}
function treatmentStopError(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 ces consignes.';
if (failure?.status === 404) return 'Cette programmation 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 treatmentStopConflict(failure) {
return failure?.status === 409
|| failure?.code === 'version_conflict'
|| failure?.code === 'idempotency_conflict';
}
function treatmentStopDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || '');
if (!match) return value || '–';
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
return new Intl.DateTimeFormat('fr-FR', {
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC',
}).format(date);
}
function treatmentStopReminder(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 'date indisponible';
return new Intl.DateTimeFormat('fr-FR', {
day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit',
}).format(date).replace(' à ', ' · ');
}
function TreatmentStopEditor({ patient, editor, accent, busy, fieldErrors, error, conflict, onChange, onClose, onSubmit, onReload }) {
const isEdit = editor.mode === 'edit';
const values = editor.values;
const { BottomSheet, Button, InlineAction, DayPicker } = window.VascoUI;
return
;
}
// Le début et la fin d'un traitement, tels que le dossier les dit. La fiche est
// lue par son rang – c'est le même ordre que les libellés – et seulement quand
// elle porte bien ce libellé : un dossier à moitié servi n'invente pas de date.
function treatmentRange(entry, label) {
if (!entry || entry.label !== label) return '';
return window.VascoPatientEdit?.treatmentRangeLabel?.(entry) || '';
}
function TreatmentStopBlock({ p, accent, onEditTreatments }) {
const { IconButton, InlineAction } = window.VascoUI;
const runtime = window.VascoApi?.patient;
const contract = window.VascoTreatmentStops;
const [patient, setPatient] = React.useState(p);
const [plans, setPlans] = React.useState(
() => (p.treatmentStopPlans || []).filter((plan) => plan.status === 'scheduled'),
);
const [editor, setEditor] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [busyCancelId, setBusyCancelId] = React.useState(null);
const [fieldErrors, setFieldErrors] = React.useState({});
const [error, setError] = React.useState(null);
const [conflict, setConflict] = React.useState(false);
const [listError, setListError] = React.useState(null);
const [cancelConflict, setCancelConflict] = React.useState(null);
const [expanded, setExpanded] = React.useState(false);
const cancelAttempts = React.useRef(null);
if (!cancelAttempts.current) {
cancelAttempts.current = contract.cancelAttempts(() => runtime.treatmentStops.newAttempt());
}
const canPlan = Boolean(runtime?.treatmentStops) && contract.canPlan(patient);
React.useEffect(() => {
setPatient(p);
setPlans((p.treatmentStopPlans || []).filter((plan) => plan.status === 'scheduled'));
setEditor(null);
setListError(null);
setCancelConflict(null);
setExpanded(false);
cancelAttempts.current.clear();
}, [p]);
const openCreate = (treatment = null) => {
const values = contract.initialCreate(patient);
if (treatment) values.treatment = treatment;
setEditor({
mode: 'create', values, baseline: null, planId: null,
attempt: runtime.treatmentStops.newAttempt(),
});
setFieldErrors({});
setError(null);
setConflict(false);
};
const openEdit = (plan) => {
const baseline = contract.initialEdit(plan);
setEditor({
mode: 'edit', values: { ...baseline }, baseline, planId: plan.id,
attempt: runtime.treatmentStops.newAttempt(),
});
setFieldErrors({});
setError(null);
setConflict(false);
};
const change = (key, value) => {
setEditor((current) => ({
...current,
values: { ...current.values, [key]: value },
attempt: runtime.treatmentStops.newAttempt(),
}));
setFieldErrors((current) => {
if (!current[key]) return current;
const next = { ...current };
delete next[key];
return next;
});
setError(null);
};
const save = async (event) => {
event.preventDefault();
if (!editor || busy) return;
const analysis = editor.mode === 'create'
? contract.buildCreate(patient, editor.values)
: contract.buildUpdate(editor.baseline, editor.values);
if (Object.keys(analysis.errors).length) {
setFieldErrors(analysis.errors);
setError('Corrigez les champs indiqués avant d’enregistrer.');
return;
}
if (editor.mode === 'edit' && !analysis.dirty) {
setError('Aucune modification à enregistrer.');
return;
}
setBusy(true);
setFieldErrors({});
setError(null);
try {
if (editor.mode === 'create') {
const created = await runtime.treatmentStops.create(patient.id, analysis.payload, editor.attempt);
setPlans((current) => [...current, created]);
showToast('Arrêt programmé et rappel créé', 'success');
} else {
const versionedPlan = {
id: editor.planId, patientId: patient.id, version: analysis.expectedVersion,
};
const updated = await runtime.treatmentStops.update(versionedPlan, analysis.payload, editor.attempt);
setPlans((current) => current.map((plan) => plan.id === updated.id ? updated : plan));
showToast('Programmation mise à jour', 'success');
}
setEditor(null);
} catch (failure) {
if (treatmentStopConflict(failure)) {
setConflict(true);
setError('Le dossier a été modifié entre-temps. Votre saisie est conservée. Rechargez le dossier avant de réessayer.');
} else {
setError(treatmentStopError(failure, 'La programmation n’a pas pu être enregistrée.'));
}
} finally {
setBusy(false);
}
};
const reloadConflict = async () => {
if (!editor || busy) return;
setBusy(true);
try {
const freshPatient = await runtime.load(patient.id);
const freshPlans = (freshPatient.treatmentStopPlans || []).filter((plan) => plan.status === 'scheduled');
setPatient(freshPatient);
setPlans(freshPlans);
if (editor.mode === 'edit') {
const freshPlan = freshPlans.find((plan) => plan.id === editor.planId);
if (!freshPlan) {
setError('Cette programmation n’existe plus dans le dossier rechargé.');
setConflict(false);
return;
}
const rebased = contract.rebaseEdit(editor.baseline, editor.values, freshPlan);
setEditor((current) => ({
...current, baseline: rebased.baseline, values: rebased.form,
attempt: runtime.treatmentStops.newAttempt(),
}));
} else {
setEditor((current) => ({
...current, values: contract.rebaseCreate(current.values),
attempt: runtime.treatmentStops.newAttempt(),
}));
}
setConflict(false);
setError('Dossier rechargé. Vérifiez votre saisie puis réessayez.');
} catch (failure) {
setError(treatmentStopError(failure, 'Le dossier n’a pas pu être rechargé. Votre saisie est conservée.'));
} finally {
setBusy(false);
}
};
const cancel = async (plan) => {
const confirmed = window.confirm(
`Annuler la programmation d’arrêt de « ${plan.treatment} » ? Le rappel associé sera annulé.`,
);
if (!confirmed) return;
setBusyCancelId(plan.id);
setListError(null);
setCancelConflict(null);
try {
const cancelled = await runtime.treatmentStops.cancel(plan, cancelAttempts.current.forPlan(plan.id));
if (cancelled.status !== 'cancelled') {
throw new Error('Le serveur n’a pas confirmé l’annulation de la programmation.');
}
cancelAttempts.current.forget(plan.id);
setPlans((current) => current.filter((item) => item.id !== plan.id));
showToast('Programmation et rappel annulés', 'success');
} catch (failure) {
if (treatmentStopConflict(failure)) {
setCancelConflict(plan.id);
setListError('La programmation a changé entre-temps. Rechargez le dossier avant toute nouvelle annulation.');
} else {
setListError(treatmentStopError(failure, 'L’annulation n’a pas pu être confirmée.'));
}
} finally {
setBusyCancelId(null);
}
};
const reloadList = async () => {
setBusyCancelId(cancelConflict || 'reload');
try {
const freshPatient = await runtime.load(patient.id);
setPatient(freshPatient);
setPlans((freshPatient.treatmentStopPlans || []).filter((plan) => plan.status === 'scheduled'));
if (cancelConflict) cancelAttempts.current.renew(cancelConflict);
setCancelConflict(null);
setListError('Dossier rechargé. Vérifiez la programmation avant de recommencer.');
} catch (failure) {
setListError(treatmentStopError(failure, 'Le dossier n’a pas pu être rechargé.'));
} finally {
setBusyCancelId(null);
}
};
const treatments = patient.treatments || [];
// Les fiches portent les dates ; les libellés seuls restent la clé d'un plan
// d'arrêt. Un dossier servi sans fiche affiche exactement ce qu'il affichait.
const treatmentEntries = patient.treatmentEntries || [];
const treatmentPreviewLimit = 3;
const visibleTreatments = expanded ? treatments : treatments.slice(0, treatmentPreviewLimit);
const hiddenTreatmentCount = Math.max(0, treatments.length - treatmentPreviewLimit);
return (
Traitements en cours
{onEditTreatments && (
)}
{treatments.length === 0 ? (
Aucun traitement en cours.
) : (
{visibleTreatments.map((treatment, treatmentIndex) => {
const treatmentPlans = plans.filter((plan) => plan.treatment === treatment);
return (
{String(treatmentIndex + 1).padStart(2, '0')}
{treatment}
{treatmentRange(treatmentEntries[treatmentIndex], treatment) && (
{treatmentRange(treatmentEntries[treatmentIndex], treatment)}
)}
{treatmentPlans.map((plan) => (
Arrêt prévu le {treatmentStopDate(plan.stopDate)}
{plan.reason &&
{plan.reason}
}
{plan.note &&
{plan.note}
}
Rappel le {treatmentStopReminder(plan.reminderDueAt)}
openEdit(plan)} disabled={Boolean(busyCancelId)}
style={{ color: TSTOP_INK }}>Modifier l’arrêt
cancel(plan)} busy={busyCancelId === plan.id}
disabled={Boolean(busyCancelId)} danger>Annuler l’arrêt
))}
{canPlan && treatmentPlans.length === 0 && (
openCreate(treatment)}
style={{ marginTop: 7, color: accent }}>Planifier l’arrêt
)}
);
})}
)}
{hiddenTreatmentCount > 0 && (
setExpanded((value) => !value)}
style={{ marginTop: 9, color: accent, fontSize: 11.5 }}>
{expanded ? 'Réduire' : `Voir les ${hiddenTreatmentCount} autres`}
)}
{!canPlan && treatments.length > 0 && (
La programmation d’arrêt est indisponible pour ce dossier.
)}
{listError &&
{listError}
{cancelConflict &&
Recharger le dossier
}
}
{editor && ReactDOM.createPortal(
setEditor(null)} onSubmit={save} onReload={reloadConflict} />,
document.getElementById('root'),
)}
);
}
// Les extensions du dossier sont chargées par Babel standalone. Publier le
// composant rend leur dépendance explicite même lorsque les sources externes
// finissent leur chargement dans un ordre différent.
window.TreatmentStopBlock = TreatmentStopBlock;