813 lines
38 KiB
JavaScript
813 lines
38 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|
||
})();
|