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

321 lines
15 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.
/**
* Périodes de vacances scolaires (`PTA.modules.vacationCalendar`), écran
* vacations.php.
*
* Ces périodes déterminent le contexte périscolaire ou extrascolaire de chaque
* journée : c'est un référentiel structurant, pas un simple calendrier
* d'affichage.
*
* L'IMPORT SE FAIT EN DEUX TEMPS, volontairement séparés :
* 1. APERÇU — `preview()` interroge l'API publique data.education.gouv.fr via
* le serveur, SANS RIEN ÉCRIRE. Les périodes proposées sont affichées dans
* un tableau où chaque ligne est éditable et cochable ;
* 2. ENREGISTREMENT — seules les lignes cochées sont envoyées, avec les
* libellés et dates éventuellement corrigés à l'écran.
* Aucune donnée externe n'entre en base sans relecture humaine : les libellés et
* découpages publiés varient d'une académie et d'une année à l'autre.
*
* Une saisie entièrement manuelle reste possible, pour les cas que l'API ne
* couvre pas.
*/
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant vacation-calendar.js');
const { escapeHtml, readJsonResponse, apiErrorMessage, formatDate } = PTA.utils;
let previewPeriods = [];
/**
* Sélecteurs résolus À CHAQUE APPEL, et non mis en cache : les tableaux de
* cette page sont reconstruits, et des références figées pointeraient vers
* des nœuds détachés du document.
*/
const elements = () => ({
academy: document.querySelector('#vacation-academy'),
calendarYear: document.querySelector('#vacation-calendar-year'),
previewButton: document.querySelector('#preview-vacations'),
previewMessage: document.querySelector('#vacation-preview-message'),
previewPanel: document.querySelector('#vacation-preview-panel'),
previewBody: document.querySelector('#vacation-preview-body'),
selectAll: document.querySelector('#vacation-select-all'),
saveSelected: document.querySelector('#save-selected-vacations'),
savedBody: document.querySelector('#vacation-saved-body'),
savedMessage: document.querySelector('#vacation-saved-message'),
refresh: document.querySelector('#refresh-vacations'),
manualForm: document.querySelector('#manual-vacation-form'),
manualMessage: document.querySelector('#manual-vacation-message'),
});
function setMessage(element, text = '', type = 'info') {
if (!element) return;
element.className = text ? `message message-${type}` : 'message';
element.textContent = text;
}
/** Origine d'une période : import officiel ou saisie manuelle. */
function sourceLabel(source) {
return source === 'DATA_GOUV' ? 'API officielle' : 'Saisie manuelle';
}
/**
* Rend le tableau d'aperçu : une ligne par période proposée, avec une case à
* cocher et des champs éditables. L'utilisateur peut corriger un libellé ou
* une date avant d'enregistrer.
*/
function renderPreview() {
const el = elements();
if (!el.previewBody || !el.previewPanel) return;
if (!previewPeriods.length) {
el.previewBody.innerHTML = '<tr><td colspan="5"><div class="empty-state">Aucune période proposée.</div></td></tr>';
el.previewPanel.hidden = false;
return;
}
el.previewBody.innerHTML = previewPeriods.map((period, index) => `
<tr class="vacation-preview-row" data-index="${index}">
<td><input class="vacation-import-check" type="checkbox" aria-label="Importer ${escapeHtml(period.libelle)}"></td>
<td>
<input class="vacation-preview-label" type="text" maxlength="150" value="${escapeHtml(period.libelle)}">
<small>${escapeHtml(period.annee_scolaire || '')}</small>
</td>
<td><input class="vacation-preview-start" type="date" value="${escapeHtml(period.date_debut)}"></td>
<td>
<input class="vacation-preview-end" type="date" value="${escapeHtml(period.date_fin)}">
${period.date_reprise_api ? `<small>Reprise API : ${escapeHtml(formatDate(period.date_reprise_api))}</small>` : ''}
</td>
<td>${escapeHtml([period.zone, period.academie].filter(Boolean).join(' · ') || 'Non précisé')}</td>
</tr>
`).join('');
el.previewPanel.hidden = false;
if (el.selectAll) el.selectAll.checked = false;
}
/**
* Interroge l'API officielle par l'intermédiaire du serveur. AUCUNE
* écriture. Un échec ressort en 502 : c'est le service distant qui n'a pas
* répondu, pas PTA qui a échoué. Les `warnings` signalent les périodes que
* le serveur n'a pas su interpréter.
*/
async function preview() {
const el = elements();
const academy = el.academy?.value.trim() || '';
const calendarYear = String(el.calendarYear?.value || '').trim();
if (!academy || !/^\d{4}$/.test(calendarYear)) {
setMessage(el.previewMessage, 'Renseignez une académie et une année civile au format 2026.', 'warning');
return;
}
el.previewButton.disabled = true;
setMessage(el.previewMessage, 'Consultation de l’API officielle en cours…', 'info');
if (el.previewPanel) el.previewPanel.hidden = true;
try {
const params = new URLSearchParams({ academy, year: calendarYear });
const response = await fetch(`api/preview_school_holidays.php?${params.toString()}`);
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de consulter le calendrier scolaire.'));
previewPeriods = Array.isArray(data.periods) ? data.periods : [];
renderPreview();
const warnings = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : [];
const baseMessage = data.message || `${previewPeriods.length} période(s) proposée(s). Aucune donnée n’a encore été enregistrée.`;
const fullMessage = warnings.length ? `${baseMessage} Attention : ${warnings.join(' ')}` : baseMessage;
setMessage(el.previewMessage, fullMessage, warnings.length ? 'warning' : (previewPeriods.length ? 'success' : 'warning'));
} catch (error) {
previewPeriods = [];
setMessage(el.previewMessage, error.message, 'error');
} finally {
el.previewButton.disabled = false;
}
}
/**
* Construit la charge utile à partir des seules lignes cochées.
*
* Les valeurs affichées (libellé, dates) priment, puisqu'elles ont pu être
* corrigées ; les métadonnées non éditables (année scolaire, académie, zone)
* sont reprises de la proposition d'origine, retrouvée par l'index de ligne.
*/
function selectedPreviewPeriods() {
const el = elements();
return Array.from(el.previewBody?.querySelectorAll('.vacation-preview-row') || [])
.filter((row) => row.querySelector('.vacation-import-check')?.checked)
.map((row) => {
const source = previewPeriods[Number(row.dataset.index)] || {};
return {
libelle: row.querySelector('.vacation-preview-label')?.value.trim() || '',
date_debut: row.querySelector('.vacation-preview-start')?.value || '',
date_fin: row.querySelector('.vacation-preview-end')?.value || '',
annee_scolaire: source.annee_scolaire || '',
academie: source.academie || elements().academy?.value.trim() || '',
zone: source.zone || '',
source: 'DATA_GOUV',
};
});
}
/**
* Enregistre un lot de périodes puis recharge la liste enregistrée.
* Mutualisé entre l'import et la saisie manuelle.
*/
async function savePeriods(periods, messageElement) {
const response = await fetch('api/save_vacation_periods.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
periods,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’enregistrer les périodes.'));
setMessage(messageElement, data.message, 'success');
await loadSaved();
return data;
}
/** Enregistre les périodes cochées dans l'aperçu. */
async function saveSelected() {
const el = elements();
const periods = selectedPreviewPeriods();
if (!periods.length) {
setMessage(el.previewMessage, 'Cochez au moins une période avant de l’enregistrer.', 'warning');
return;
}
el.saveSelected.disabled = true;
try {
await savePeriods(periods, el.previewMessage);
el.previewBody?.querySelectorAll('.vacation-import-check').forEach((checkbox) => { checkbox.checked = false; });
if (el.selectAll) el.selectAll.checked = false;
} catch (error) {
setMessage(el.previewMessage, error.message, 'error');
} finally {
el.saveSelected.disabled = false;
}
}
/** Enregistre une période saisie à la main, pour les cas hors API. */
async function saveManual(event) {
event.preventDefault();
const el = elements();
const submit = el.manualForm?.querySelector('button[type="submit"]');
const period = {
libelle: document.querySelector('#manual-vacation-label')?.value.trim() || '',
date_debut: document.querySelector('#manual-vacation-start')?.value || '',
date_fin: document.querySelector('#manual-vacation-end')?.value || '',
annee_scolaire: document.querySelector('#manual-vacation-school-year')?.value.trim() || '',
academie: document.querySelector('#manual-vacation-academy')?.value.trim() || '',
zone: document.querySelector('#manual-vacation-zone')?.value.trim() || '',
source: 'MANUEL',
};
if (period.date_debut && period.date_fin && period.date_fin < period.date_debut) {
setMessage(el.manualMessage, 'La date de fin doit être postérieure ou égale à la date de début.', 'warning');
return;
}
if (submit) submit.disabled = true;
try {
await savePeriods([period], el.manualMessage);
document.querySelector('#manual-vacation-label').value = '';
document.querySelector('#manual-vacation-start').value = '';
document.querySelector('#manual-vacation-end').value = '';
document.querySelector('#manual-vacation-zone').value = '';
} catch (error) {
setMessage(el.manualMessage, error.message, 'error');
} finally {
if (submit) submit.disabled = false;
}
}
/** Rend le tableau des périodes déjà enregistrées, avec leur origine. */
function renderSaved(periods) {
const el = elements();
if (!el.savedBody) return;
if (!periods.length) {
el.savedBody.innerHTML = '<tr><td colspan="6"><div class="empty-state">Aucune période de vacances n’est encore enregistrée.</div></td></tr>';
return;
}
el.savedBody.innerHTML = periods.map((period) => `
<tr data-period-id="${Number(period.id_periode)}">
<td><strong>${escapeHtml(period.libelle)}</strong></td>
<td>${escapeHtml(formatDate(period.date_debut))} → ${escapeHtml(formatDate(period.date_fin))}</td>
<td>${escapeHtml(period.annee_scolaire || '')}</td>
<td>${escapeHtml([period.academie, period.zone].filter(Boolean).join(' · ') || 'Non précisé')}</td>
<td><span class="vacation-source-badge">${escapeHtml(sourceLabel(period.source))}</span></td>
<td><button type="button" class="button button-danger delete-vacation-period">Supprimer</button></td>
</tr>
`).join('');
}
/** Recharge les périodes enregistrées en base. */
async function loadSaved() {
const el = elements();
try {
const response = await fetch('api/vacation_periods.php');
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les périodes enregistrées.'));
renderSaved(Array.isArray(data.periods) ? data.periods : []);
setMessage(el.savedMessage, '', 'info');
} catch (error) {
setMessage(el.savedMessage, error.message, 'error');
}
}
/**
* Supprime une période après confirmation.
* Les créneaux déjà planifiés ne sont pas touchés, mais le contexte
* scolaire/extrascolaire des journées concernées change.
*/
async function deletePeriod(button) {
const row = button.closest('tr[data-period-id]');
const id = Number(row?.dataset.periodId || 0);
if (!id || !confirm('Supprimer cette période de vacances ? Les journées concernées seront à nouveau considérées comme périscolaires.')) return;
button.disabled = true;
try {
const response = await fetch('api/delete_vacation_period.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
period_id: id,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Suppression impossible.'));
setMessage(elements().savedMessage, data.message, 'success');
await loadSaved();
} catch (error) {
setMessage(elements().savedMessage, error.message, 'error');
button.disabled = false;
}
}
function init() {
const el = elements();
el.previewButton?.addEventListener('click', preview);
el.saveSelected?.addEventListener('click', saveSelected);
el.refresh?.addEventListener('click', loadSaved);
el.manualForm?.addEventListener('submit', saveManual);
el.selectAll?.addEventListener('change', () => {
el.previewBody?.querySelectorAll('.vacation-import-check').forEach((checkbox) => {
checkbox.checked = el.selectAll.checked;
});
});
el.savedBody?.addEventListener('click', (event) => {
const button = event.target.closest('.delete-vacation-period');
if (button) deletePeriod(button);
});
loadSaved();
}
PTA.modules.vacationCalendar = { init, loadSaved, preview };
})();