/** * Application des semaines types depuis l'écran de saisie * (`PTA.modules.weekTemplates`). * * À distinguer de `week-template-editor.js`, qui sert à COMPOSER un modèle ; ce * module-ci sert à en ENREGISTRER un depuis la semaine affichée, et surtout à * l'APPLIQUER. Les deux cohabitent sur planning.php et ne sont chargés que si la * permission `can_use_templates` est accordée. * * Trois modales, de complexité croissante : * 1. ENREGISTRER la semaine affichée comme modèle ; * 2. APPLIQUER un modèle à la semaine affichée ; * 3. APPLIQUER un modèle sur une PÉRIODE — la seule qui impose un aperçu. * * Le parcours d'aperçu est le point le plus délicat : une application sur * période touche des dizaines de semaines et peut rouvrir des plannings validés. * Le bouton de confirmation reste donc désactivé tant qu'un aperçu VALIDE ET À * JOUR n'a pas été obtenu. C'est le rôle de `lastPeriodPreviewSignature` : * toute modification d'un paramètre invalide l'aperçu, empêchant d'appliquer un * réglage différent de celui qui a été montré. */ (() => { 'use strict'; const PTA = window.PTA; if (!PTA) throw new Error('PTA core doit être chargé avant week-templates.js'); const { state } = PTA; const { agentSelect, weekInput } = PTA.elements; const { showMessage, readJsonResponse, apiErrorMessage, escapeHtml } = PTA.utils; const saveButton = document.querySelector('#save-week-template'); const applyButton = document.querySelector('#apply-week-template'); const saveModal = document.querySelector('#save-week-template-modal'); const saveForm = document.querySelector('#save-week-template-form'); const saveNameInput = document.querySelector('#week-template-name'); const saveContext = document.querySelector('#save-week-template-context'); const saveMessage = document.querySelector('#save-week-template-message'); const applyModal = document.querySelector('#apply-week-template-modal'); const applyForm = document.querySelector('#apply-week-template-form'); const templateSelect = document.querySelector('#week-template-select'); const templateDetails = document.querySelector('#week-template-details'); const replaceWarning = document.querySelector('#week-template-replace-warning'); const deleteButton = document.querySelector('#delete-week-template'); const applyMessage = document.querySelector('#apply-week-template-message'); const periodButton = document.querySelector('#apply-week-template-period'); const periodModal = document.querySelector('#apply-week-template-period-modal'); const periodForm = document.querySelector('#apply-week-template-period-form'); const periodTemplateSelect = document.querySelector('#week-template-period-select'); const periodStartInput = document.querySelector('#week-template-period-start'); const periodEndInput = document.querySelector('#week-template-period-end'); const periodReplaceWarning = document.querySelector('#week-template-period-replace-warning'); const periodPreviewButton = document.querySelector('#preview-week-template-period'); const periodPreviewPanel = document.querySelector('#week-template-period-preview'); const periodConfirmButton = document.querySelector('#confirm-apply-week-template-period'); const periodMessage = document.querySelector('#apply-week-template-period-message'); let templates = []; let lastPeriodPreview = null; let lastPeriodPreviewSignature = ''; /** Un agent et une semaine suffisent : le modèle porte ses propres lieux. */ function contextReady() { return Boolean(agentSelect?.value && weekInput?.value); } /** Nombre de créneaux de la semaine, tous lieux confondus. */ function globalEntryCount() { return (state.entries?.length || 0) + (state.otherStructureEntries?.length || 0); } /** Réactive les trois boutons de semaine type selon le contexte courant. */ function refreshButtons() { const ready = contextReady(); if (saveButton) saveButton.disabled = !ready; if (applyButton) applyButton.disabled = !ready; if (periodButton) periodButton.disabled = !ready; } /** * Message DANS une modale, distinct du bandeau global de la page : une * modale ouverte masque le bandeau, l'erreur y passerait inaperçue. */ function setInlineMessage(element, text = '', type = 'info') { if (!element) return; element.className = text ? `message message-${type}` : 'message'; element.textContent = text; } /** Bloque le défilement de la page tant qu'une modale est ouverte. */ function updateBodyModalState() { const anyOpen = Array.from(document.querySelectorAll('.modal-backdrop')) .some((modal) => !modal.hidden); document.body.classList.toggle('modal-open', anyOpen); } function closeModal(modal) { if (!modal) return; modal.hidden = true; updateBodyModalState(); } function openModal(modal) { if (!modal) return; modal.hidden = false; document.body.classList.add('modal-open'); } /** Libellé de l'agent sélectionné, pour les intitulés de modale. */ function agentLabel() { return agentSelect?.options[agentSelect.selectedIndex]?.textContent?.trim() || 'Agent sélectionné'; } /** * Enregistre le brouillon en cours avant d'en faire un modèle. * Le modèle est construit côté serveur à partir de ce qui est EN BASE : une * saisie non enregistrée n'y figurerait pas. */ async function persistCurrentEditorIfNeeded() { const planning = PTA.modules.planning; if (!planning || state.planningStatus === 'VALIDE') return true; if (!planning.isPlanningContextReady?.()) return true; const planningId = await planning.saveDraft?.({ silent: true }); return planningId !== null && planningId !== undefined; } /** Ouvre la modale d'enregistrement, après avoir sécurisé le brouillon. */ async function openSaveModal() { if (!contextReady()) { showMessage('Sélectionnez un agent et une semaine avant d’enregistrer une semaine type.', 'warning'); return; } if (state.planningStatus !== 'VALIDE') { const synced = PTA.modules.planning?.syncEntriesFromWeeklyForm?.({ silent: true }); if (synced === false) return; } if (globalEntryCount() === 0) { showMessage('La semaine ne contient aucune affectation à enregistrer comme modèle.', 'warning'); return; } if (saveNameInput) saveNameInput.value = ''; if (saveContext) { saveContext.innerHTML = ` ${escapeHtml(agentLabel())} Semaine ${escapeHtml(weekInput.value)} · ${globalEntryCount()} créneau${globalEntryCount() > 1 ? 'x' : ''} actuellement affiché${globalEntryCount() > 1 ? 's' : ''} `; } setInlineMessage(saveMessage); openModal(saveModal); setTimeout(() => saveNameInput?.focus(), 0); } /** * Enregistre la semaine affichée comme modèle. * * Un 409 signifie qu'un modèle porte déjà ce nom : l'utilisateur se voit * proposer le remplacement, ce qui rappelle cette fonction avec * `replace = true`. Jamais d'écrasement sans confirmation. */ async function saveTemplate(replace = false) { const name = saveNameInput?.value?.trim() || ''; if (!name) { setInlineMessage(saveMessage, 'Saisissez un nom pour la semaine type.', 'warning'); saveNameInput?.focus(); return; } const submitButton = saveForm?.querySelector('button[type="submit"]'); if (submitButton) submitButton.disabled = true; setInlineMessage(saveMessage, 'Enregistrement du modèle...', 'info'); try { const persisted = await persistCurrentEditorIfNeeded(); if (!persisted) { throw new Error('Le planning courant n’a pas pu être enregistré avant la création du modèle.'); } const response = await fetch('api/save_week_template.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, agent_id: Number(agentSelect.value), week: weekInput.value, name, replace, }), }); const data = await readJsonResponse(response); if (response.status === 409 && data.code === 'TEMPLATE_EXISTS' && !replace) { const confirmed = window.confirm(`Une semaine type nommée « ${name} » existe déjà pour cet agent. Voulez-vous la remplacer par la semaine actuelle ?`); if (confirmed) { await saveTemplate(true); } else { setInlineMessage(saveMessage, 'Enregistrement annulé.', 'info'); } return; } if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’enregistrer la semaine type.')); setInlineMessage(saveMessage, data.message, 'success'); showMessage(data.message, 'success'); setTimeout(() => closeModal(saveModal), 450); } catch (error) { setInlineMessage(saveMessage, error.message, 'error'); } finally { if (submitButton) submitButton.disabled = false; } } /** Recharge la liste des modèles de l'agent courant. */ async function loadTemplates() { if (!agentSelect?.value) return []; const response = await fetch(`api/week_templates.php?agent_id=${encodeURIComponent(agentSelect.value)}`); const data = await readJsonResponse(response); if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les semaines types.')); templates = Array.isArray(data.templates) ? data.templates : []; return templates; } /** Remplit le sélecteur de la modale d'application. */ function renderTemplateSelect() { if (!templateSelect) return; if (!templates.length) { templateSelect.innerHTML = ''; templateSelect.disabled = true; if (deleteButton) deleteButton.disabled = true; if (templateDetails) templateDetails.innerHTML = '
Créez d’abord une semaine type depuis une semaine déjà planifiée.
'; return; } templateSelect.disabled = false; templateSelect.innerHTML = '' + templates.map((template) => ( `` )).join(''); renderSelectedTemplateDetails(); } /** Affiche le contenu du modèle choisi, pour vérification avant application. */ function renderSelectedTemplateDetails() { const selectedId = Number(templateSelect?.value || 0); const template = templates.find((item) => Number(item.id) === selectedId); if (deleteButton) deleteButton.disabled = !template; if (!templateDetails) return; if (!template) { templateDetails.innerHTML = 'Sélectionnez un modèle pour l’appliquer à la semaine affichée.
'; return; } templateDetails.innerHTML = `| Date | Contexte | Créés | Ignorés | Conflits |
|---|---|---|---|---|
| Aucune journée du modèle ne correspond à cette période. | ||||
Aperçu limité aux 80 premières journées concernées sur ${relevantDays.length}.
` : ''} `; periodPreviewPanel.hidden = false; const canApply = Number(summary.entry_count || 0) > 0 && (mode !== 'merge' || Number(summary.conflict_count || 0) === 0); if (periodConfirmButton) periodConfirmButton.disabled = !canApply; } /** Demande l'aperçu au serveur et mémorise sa signature. Aucune écriture. */ async function previewPeriodApplication() { const templateId = Number(periodTemplateSelect?.value || 0); const startDate = periodStartInput?.value || ''; const endDate = periodEndInput?.value || ''; if (!templateId || !startDate || !endDate) { setInlineMessage(periodMessage, 'Sélectionnez une semaine type et renseignez les deux dates.', 'warning'); return; } if (endDate < startDate) { setInlineMessage(periodMessage, 'La date de fin doit être postérieure ou égale à la date de début.', 'warning'); return; } if (periodPreviewButton) periodPreviewButton.disabled = true; if (periodConfirmButton) periodConfirmButton.disabled = true; setInlineMessage(periodMessage, 'Analyse de la période et des vacances...', 'info'); try { const response = await fetch('api/preview_week_template_period.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, template_id: templateId, agent_id: Number(agentSelect.value), start_date: startDate, end_date: endDate, mode: periodMode(), period_filter: periodFilter(), }), }); const data = await readJsonResponse(response); if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de prévisualiser l’application sur la période.')); lastPeriodPreview = data.preview; lastPeriodPreviewSignature = currentPeriodSignature(); renderPeriodPreview(lastPeriodPreview); setInlineMessage(periodMessage, 'Aperçu calculé. Vérifiez les dates avant de confirmer.', 'success'); } catch (error) { lastPeriodPreview = null; lastPeriodPreviewSignature = ''; if (periodPreviewPanel) periodPreviewPanel.hidden = true; setInlineMessage(periodMessage, error.message, 'error'); } finally { if (periodPreviewButton) periodPreviewButton.disabled = false; } } /** * Applique le modèle sur la période, une fois l'aperçu confirmé. * Le message de retour du serveur est affiché tel quel : il détaille les * créneaux créés, ignorés, remplacés et les plannings rouverts. */ async function applyTemplatePeriod() { if (!lastPeriodPreview || lastPeriodPreviewSignature !== currentPeriodSignature()) { setInlineMessage(periodMessage, 'La période a changé. Relancez la prévisualisation avant d’appliquer le modèle.', 'warning'); if (periodConfirmButton) periodConfirmButton.disabled = true; return; } const templateId = Number(periodTemplateSelect?.value || 0); const template = templates.find((item) => Number(item.id) === templateId); const mode = periodMode(); const summary = lastPeriodPreview.summary || {}; if (mode === 'replace' && Number(summary.existing_entry_count_on_target_dates || 0) > 0) { const confirmed = window.confirm( `${Number(summary.existing_entry_count_on_target_dates)} créneau(x) existant(s) seront supprimé(s) sur les jours réellement concernés avant d’appliquer « ${template?.name || 'la semaine type'} ».\n\nLes dates exclues par le filtre de calendrier ne seront pas modifiées. Continuer ?` ); if (!confirmed) return; } if (periodConfirmButton) periodConfirmButton.disabled = true; if (periodPreviewButton) periodPreviewButton.disabled = true; setInlineMessage(periodMessage, 'Application de la semaine type sur la période...', 'info'); try { const response = await fetch('api/apply_week_template_period.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, template_id: templateId, agent_id: Number(agentSelect.value), start_date: periodStartInput.value, end_date: periodEndInput.value, mode, period_filter: periodFilter(), }), }); const data = await readJsonResponse(response); if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’appliquer la semaine type sur la période.')); await PTA.modules.planning?.loadPlanning?.(); PTA.modules.pendingValidation?.refresh?.({ silent: true }); PTA.modules.coverage?.refresh?.({ silent: true }); showMessage(data.message, 'success'); setInlineMessage(periodMessage, data.message, 'success'); lastPeriodPreview = null; lastPeriodPreviewSignature = ''; setTimeout(() => closeModal(periodModal), 700); } catch (error) { setInlineMessage(periodMessage, error.message, 'error'); // Le planning peut avoir changé depuis l'aperçu : imposer une nouvelle prévisualisation. lastPeriodPreview = null; lastPeriodPreviewSignature = ''; if (periodConfirmButton) periodConfirmButton.disabled = true; } finally { if (periodPreviewButton) periodPreviewButton.disabled = false; } } /** * Supprime le modèle sélectionné, après confirmation. * Les créneaux déjà appliqués ne sont pas touchés. */ async function deleteSelectedTemplate() { const templateId = Number(templateSelect?.value || 0); const template = templates.find((item) => Number(item.id) === templateId); if (!template) return; const confirmed = window.confirm(`Supprimer définitivement la semaine type « ${template.name} » ?`); if (!confirmed) return; if (deleteButton) deleteButton.disabled = true; try { const response = await fetch('api/delete_week_template.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, template_id: templateId, agent_id: Number(agentSelect.value), }), }); const data = await readJsonResponse(response); if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de supprimer la semaine type.')); setInlineMessage(applyMessage, data.message, 'success'); await loadTemplates(); renderTemplateSelect(); } catch (error) { setInlineMessage(applyMessage, error.message, 'error'); renderSelectedTemplateDetails(); } } /** Affiche l'avertissement du mode « remplacer » de la modale semaine. */ function updateReplaceWarning() { const mode = applyForm?.querySelector('input[name="week-template-mode"]:checked')?.value || 'merge'; if (replaceWarning) replaceWarning.hidden = mode !== 'replace'; } function init() { saveButton?.addEventListener('click', openSaveModal); applyButton?.addEventListener('click', openApplyModal); periodButton?.addEventListener('click', openPeriodModal); saveForm?.addEventListener('submit', (event) => { event.preventDefault(); saveTemplate(false); }); applyForm?.addEventListener('submit', (event) => { event.preventDefault(); applyTemplate(); }); periodForm?.addEventListener('submit', (event) => { event.preventDefault(); applyTemplatePeriod(); }); periodPreviewButton?.addEventListener('click', previewPeriodApplication); document.querySelector('#close-save-week-template-modal')?.addEventListener('click', () => closeModal(saveModal)); document.querySelector('#cancel-save-week-template')?.addEventListener('click', () => closeModal(saveModal)); document.querySelector('#close-apply-week-template-modal')?.addEventListener('click', () => closeModal(applyModal)); document.querySelector('#cancel-apply-week-template')?.addEventListener('click', () => closeModal(applyModal)); document.querySelector('#close-apply-week-template-period-modal')?.addEventListener('click', () => closeModal(periodModal)); document.querySelector('#cancel-apply-week-template-period')?.addEventListener('click', () => closeModal(periodModal)); saveModal?.addEventListener('click', (event) => { if (event.target === saveModal) closeModal(saveModal); }); applyModal?.addEventListener('click', (event) => { if (event.target === applyModal) closeModal(applyModal); }); periodModal?.addEventListener('click', (event) => { if (event.target === periodModal) closeModal(periodModal); }); templateSelect?.addEventListener('change', renderSelectedTemplateDetails); applyForm?.querySelectorAll('input[name="week-template-mode"]').forEach((input) => { input.addEventListener('change', updateReplaceWarning); }); periodForm?.querySelectorAll('input[name="week-template-period-mode"]').forEach((input) => { input.addEventListener('change', invalidatePeriodPreview); }); periodForm?.querySelectorAll('input[name="week-template-period-filter"]').forEach((input) => { input.addEventListener('change', invalidatePeriodPreview); }); [periodTemplateSelect, periodStartInput, periodEndInput].forEach((input) => { input?.addEventListener('change', invalidatePeriodPreview); input?.addEventListener('input', invalidatePeriodPreview); }); deleteButton?.addEventListener('click', deleteSelectedTemplate); agentSelect?.addEventListener('change', refreshButtons); weekInput?.addEventListener('input', refreshButtons); weekInput?.addEventListener('change', refreshButtons); document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; if (saveModal && !saveModal.hidden) closeModal(saveModal); if (applyModal && !applyModal.hidden) closeModal(applyModal); if (periodModal && !periodModal.hidden) closeModal(periodModal); }); updateReplaceWarning(); updatePeriodReplaceWarning(); refreshButtons(); } PTA.modules.weekTemplates = { init, refreshButtons, loadTemplates, // Ouverts aussi depuis l'éditeur de semaine type. openApplyModal, openPeriodModal, }; })();