Files
pta/assets/js/week-templates.js
2026-08-20 13:22:49 +02:00

813 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `
<strong>${escapeHtml(agentLabel())}</strong>
<span>Semaine ${escapeHtml(weekInput.value)} · ${globalEntryCount()} créneau${globalEntryCount() > 1 ? 'x' : ''} actuellement affiché${globalEntryCount() > 1 ? 's' : ''}</span>
`;
}
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 = '<option value="">Aucune semaine type enregistrée</option>';
templateSelect.disabled = true;
if (deleteButton) deleteButton.disabled = true;
if (templateDetails) templateDetails.innerHTML = '<p class="hint">Créez d’abord une semaine type depuis une semaine déjà planifiée.</p>';
return;
}
templateSelect.disabled = false;
templateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
`<option value="${Number(template.id)}">${escapeHtml(template.name)}</option>`
)).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 = '<p class="hint">Sélectionnez un modèle pour l’appliquer à la semaine affichée.</p>';
return;
}
templateDetails.innerHTML = `
<div class="week-template-detail-card">
<strong>${escapeHtml(template.name)}</strong>
<span>${Number(template.entry_count)} créneau${Number(template.entry_count) > 1 ? 'x' : ''} · ${Number(template.location_count)} lieu${Number(template.location_count) > 1 ? 'x' : ''}</span>
</div>
`;
}
/**
* `preselectedId` permet à l'éditeur de semaine type d'ouvrir la modale
* directement sur le modèle en cours d'édition, sans le rechercher.
*/
/** Ouvre la modale d'application à la semaine affichée. */
async function openApplyModal(preselectedId = null) {
if (!contextReady()) {
showMessage('Sélectionnez un agent et la semaine cible avant d’importer une semaine type.', 'warning');
return;
}
setInlineMessage(applyMessage, 'Chargement des modèles...', 'info');
if (templateSelect) {
templateSelect.disabled = true;
templateSelect.innerHTML = '<option value="">Chargement...</option>';
}
openModal(applyModal);
try {
await loadTemplates();
renderTemplateSelect();
if (preselectedId && templateSelect) {
templateSelect.value = String(Number(preselectedId));
renderSelectedTemplateDetails();
}
setInlineMessage(applyMessage);
} catch (error) {
setInlineMessage(applyMessage, error.message, 'error');
}
}
/**
* Applique un modèle à la semaine affichée, puis recharge le planning : les
* créneaux créés doivent apparaître immédiatement.
*/
async function applyTemplate() {
const templateId = Number(templateSelect?.value || 0);
const mode = applyForm?.querySelector('input[name="week-template-mode"]:checked')?.value || 'merge';
const template = templates.find((item) => Number(item.id) === templateId);
if (!templateId || !template) {
setInlineMessage(applyMessage, 'Sélectionnez une semaine type.', 'warning');
return;
}
if (mode === 'replace') {
const confirmed = window.confirm(
`Le mode « Remplacer » va supprimer toutes les affectations actuelles de ${agentLabel()} pour ${weekInput.value}, tous lieux confondus, puis appliquer « ${template.name} ».\n\nContinuer ?`
);
if (!confirmed) return;
}
const submitButton = applyForm?.querySelector('button[type="submit"]');
if (submitButton) submitButton.disabled = true;
if (deleteButton) deleteButton.disabled = true;
setInlineMessage(applyMessage, 'Import de la semaine type...', 'info');
try {
// En mode compléter, persister d'abord les éventuelles modifications locales non encore enregistrées.
if (mode === 'merge') {
const persisted = await persistCurrentEditorIfNeeded();
if (!persisted) {
throw new Error('Le planning courant n’a pas pu être enregistré avant l’import.');
}
}
const response = await fetch('api/apply_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),
week: weekInput.value,
mode,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’importer la semaine type.'));
if (data.quota) state.quota = data.quota;
await PTA.modules.planning?.loadPlanning?.();
setInlineMessage(applyMessage, data.message, 'success');
showMessage(data.message, 'success');
setTimeout(() => closeModal(applyModal), 550);
} catch (error) {
setInlineMessage(applyMessage, error.message, 'error');
} finally {
if (submitButton) submitButton.disabled = false;
renderSelectedTemplateDetails();
}
}
/** Mode d'application sur période : « merge » (défaut) ou « replace ». */
function periodMode() {
return periodForm?.querySelector('input[name="week-template-period-mode"]:checked')?.value || 'merge';
}
/** Filtre de calendrier : « all », « school » ou « extra ». */
function periodFilter() {
return periodForm?.querySelector('input[name="week-template-period-filter"]:checked')?.value || 'all';
}
/**
* « 2026-W08 » → l'objet Date du lundi de cette semaine ISO.
*
* Calcul entièrement en UTC (Date.UTC, getUTC*) : ces dates ne servent qu'à
* préremplir des champs, jamais à afficher une heure, et l'UTC évite tout
* décalage de fuseau ou d'heure d'été.
*/
function weekMonday(weekValue) {
const match = /^(\d{4})-W(\d{2})$/.exec(weekValue || '');
if (!match) return null;
const year = Number(match[1]);
const week = Number(match[2]);
const jan4 = new Date(Date.UTC(year, 0, 4));
const jan4Day = jan4.getUTCDay() || 7;
const monday = new Date(jan4);
monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + (week - 1) * 7);
return monday;
}
/** Date → « AAAA-MM-JJ » en UTC, pour un champ `input type="date"`. */
function dateInputValue(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '';
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* « 2026-02-17 » → « 17/02/2026 ».
* Le « T12:00:00 » ancre la date à midi : à minuit, un décalage de fuseau
* ferait basculer l'affichage sur la veille.
*/
function formatDateFr(value) {
if (!value) return '';
const date = new Date(`${value}T12:00:00`);
return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
}
/**
* Invalide l'aperçu et REDÉSACTIVE le bouton d'application.
*
* Appelée dès qu'un paramètre change. C'est la garantie qu'on n'applique
* jamais un réglage différent de celui qui a été montré à l'utilisateur.
*/
function invalidatePeriodPreview() {
lastPeriodPreview = null;
lastPeriodPreviewSignature = '';
if (periodConfirmButton) periodConfirmButton.disabled = true;
if (periodPreviewPanel) {
periodPreviewPanel.hidden = true;
periodPreviewPanel.innerHTML = '';
}
setInlineMessage(periodMessage);
updatePeriodReplaceWarning();
}
/**
* Empreinte des paramètres courants, comparée à celle de l'aperçu obtenu
* pour détecter qu'il est devenu obsolète.
*/
function currentPeriodSignature() {
return JSON.stringify({
template_id: Number(periodTemplateSelect?.value || 0),
start_date: periodStartInput?.value || '',
end_date: periodEndInput?.value || '',
mode: periodMode(),
period_filter: periodFilter(),
});
}
/** Remplit le sélecteur de la modale « période ». */
function renderPeriodTemplateSelect() {
if (!periodTemplateSelect) return;
if (!templates.length) {
periodTemplateSelect.innerHTML = '<option value="">Aucune semaine type enregistrée</option>';
periodTemplateSelect.disabled = true;
return;
}
periodTemplateSelect.disabled = false;
periodTemplateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
`<option value="${Number(template.id)}">${escapeHtml(template.name)}</option>`
)).join('');
}
/**
* Préremplit la période avec la semaine affichée (lundi → vendredi), sans
* jamais écraser une saisie de l'utilisateur.
*/
function setDefaultPeriodDates() {
const monday = weekMonday(weekInput?.value);
if (!monday) return;
if (periodStartInput && !periodStartInput.value) periodStartInput.value = dateInputValue(monday);
if (periodEndInput && !periodEndInput.value) {
const friday = new Date(monday);
friday.setUTCDate(monday.getUTCDate() + 4);
periodEndInput.value = dateInputValue(friday);
}
}
/** Ouvre la modale « période », aperçu remis à zéro. */
async function openPeriodModal(preselectedId = null) {
if (!contextReady()) {
showMessage('Sélectionnez un agent et une semaine avant d’appliquer une semaine type sur une période.', 'warning');
return;
}
invalidatePeriodPreview();
setDefaultPeriodDates();
if (periodTemplateSelect) {
periodTemplateSelect.disabled = true;
periodTemplateSelect.innerHTML = '<option value="">Chargement...</option>';
}
openModal(periodModal);
setInlineMessage(periodMessage, 'Chargement des modèles...', 'info');
try {
await loadTemplates();
renderPeriodTemplateSelect();
if (preselectedId && periodTemplateSelect) {
periodTemplateSelect.value = String(Number(preselectedId));
invalidatePeriodPreview();
}
setInlineMessage(periodMessage);
} catch (error) {
setInlineMessage(periodMessage, error.message, 'error');
}
}
/** Affiche l'avertissement propre au mode « remplacer ». */
function updatePeriodReplaceWarning() {
if (periodReplaceWarning) periodReplaceWarning.hidden = periodMode() !== 'replace';
}
/**
* Rend l'aperçu d'application sur période : compteurs de synthèse, vacances
* prises en compte, conflits, puis le détail jour par jour.
*
* Le tableau est PLAFONNÉ à 80 journées, avec la mention du total réel :
* deux ans de période produiraient plusieurs centaines de lignes, illisibles
* et coûteuses à rendre.
*
* L'application n'est autorisée que si le plan crée au moins un créneau ET,
* en mode « compléter », qu'aucun conflit ne subsiste — le serveur refuserait
* de toute façon.
*/
function renderPeriodPreview(preview) {
if (!periodPreviewPanel) return;
const summary = preview.summary || {};
const conflicts = Array.isArray(preview.conflicts) ? preview.conflicts : [];
const days = Array.isArray(preview.days) ? preview.days : [];
const vacations = Array.isArray(preview.vacation_periods) ? preview.vacation_periods : [];
const mode = preview.mode || periodMode();
const filter = preview.period_filter || periodFilter();
const filterLabel = filter === 'school'
? 'Périodes scolaires uniquement'
: filter === 'extra'
? 'Périodes extrascolaires uniquement'
: 'Toutes les semaines';
const vacationHtml = vacations.length
? `<div class="week-template-period-vacations"><strong>Vacances prises en compte</strong><ul>${vacations.map((item) => `<li>${escapeHtml(item.label)} : ${formatDateFr(item.start_date)} → ${formatDateFr(item.end_date)}</li>`).join('')}</ul></div>`
: filter === 'extra'
? '<div class="week-template-period-alert is-warning"><strong>Aucune période de vacances enregistrée sur cette plage.</strong> Aucun jour ne pourra être considéré comme extrascolaire tant que le calendrier n’est pas renseigné.</div>'
: '<div class="week-template-period-alert is-warning"><strong>Aucune période de vacances enregistrée sur cette plage.</strong> Les dates seront considérées comme scolaires.</div>';
const relevantDays = days.filter((day) => Number(day.eligible_count) > 0 || Number(day.skipped_count) > 0 || Number(day.conflict_count) > 0);
const dayRows = relevantDays.slice(0, 80).map((day) => {
const periodLabel = day.period_type === 'EXTRASCOLAIRE'
? `Extrascolaire${day.vacation_label ? ` · ${escapeHtml(day.vacation_label)}` : ''}`
: 'Périscolaire';
return `<tr>
<td>${formatDateFr(day.date)}</td>
<td><span class="weekly-period-badge ${day.period_type === 'EXTRASCOLAIRE' ? 'is-extra' : 'is-peri'}">${periodLabel}</span></td>
<td>${Number(day.eligible_count)}</td>
<td>${Number(day.skipped_count)}</td>
<td>${Number(day.conflict_count) || '—'}</td>
</tr>`;
}).join('');
const conflictHtml = conflicts.length
? `<div class="week-template-period-alert is-error"><strong>${conflicts.length} conflit${conflicts.length > 1 ? 's' : ''} détecté${conflicts.length > 1 ? 's' : ''}.</strong> ${mode === 'merge' ? 'Le mode « Compléter » ne peut pas être appliqué tant que ces conflits existent. Vous pouvez choisir « Remplacer les jours concernés » après vérification.' : 'Ces créneaux existants seront remplacés sur les jours concernés.'}</div>`
: '<div class="week-template-period-alert is-success"><strong>Aucun chevauchement détecté.</strong></div>';
periodPreviewPanel.innerHTML = `
<div class="week-template-period-summary">
<article><span>Périmètre</span><strong>${escapeHtml(filterLabel)}</strong></article>
<article><span>Créneaux à créer</span><strong>${Number(summary.entry_count || 0)}</strong></article>
<article><span>Jours concernés</span><strong>${Number(summary.target_day_count || 0)}</strong></article>
<article><span>Semaines touchées</span><strong>${Number(summary.week_count || 0)}</strong></article>
<article><span>Ignorés (vacances)</span><strong>${Number(summary.skipped_vacation_count || 0)}</strong></article>
<article><span>Ignorés (hors vacances)</span><strong>${Number(summary.skipped_school_count || 0)}</strong></article>
<article><span>${mode === 'replace' ? 'Créneaux existants remplacés' : 'Conflits'}</span><strong>${mode === 'replace' ? Number(summary.existing_entry_count_on_target_dates || 0) : Number(summary.conflict_count || 0)}</strong></article>
</div>
${vacationHtml}
${conflictHtml}
<div class="table-scroll week-template-period-table-wrap">
<table class="data-table week-template-period-table">
<thead><tr><th>Date</th><th>Contexte</th><th>Créés</th><th>Ignorés</th><th>Conflits</th></tr></thead>
<tbody>${dayRows || '<tr><td colspan="5" class="empty-cell">Aucune journée du modèle ne correspond à cette période.</td></tr>'}</tbody>
</table>
</div>
${relevantDays.length > 80 ? `<p class="hint">Aperçu limité aux 80 premières journées concernées sur ${relevantDays.length}.</p>` : ''}
`;
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,
};
})();