Ajout des commentaires dans le code

This commit is contained in:
PTA
2026-08-20 13:22:49 +02:00
parent 4201894e28
commit 5d8c0993a6
154 changed files with 5071 additions and 47 deletions

View File

@@ -1,3 +1,21 @@
/**
* Écran d'administration des référentiels (`PTA.modules.administration`) :
* agents, lieux d'affectation, postes et horaires de couverture.
*
* PRINCIPE DE MISE À JOUR — la page ne se recharge JAMAIS après une création ou
* une modification. Les nouveaux éléments sont insérés à la main dans TOUS les
* sélecteurs et tableaux concernés, d'où les fonctions `add*Everywhere()`. Un
* lieu créé ici doit apparaître immédiatement dans les listes de l'écran de
* saisie, de la vue par lieu, du formulaire agent et des règles de couverture.
*
* CONSÉQUENCE POUR LA MAINTENANCE : ajouter un sélecteur de lieu ou d'agent
* ailleurs dans l'application impose de l'inscrire dans `addStructureEverywhere()`
* ou `addAgentEverywhere()`, faute de quoi il restera périmé jusqu'au prochain
* rechargement complet de la page.
*
* Cet écran charge aussi le module `coverage`, les règles de couverture étant
* un référentiel de lieu.
*/
(() => {
'use strict';
@@ -14,6 +32,11 @@
const typeLabel = (type) => type === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire';
/**
* Enregistre le lieu de rattachement principal d'un agent depuis sa ligne de
* tableau. Un lieu vide est valide : il détache l'agent, qui reste
* planifiable ponctuellement ailleurs.
*/
async function saveAgentStructure(button) {
const row = button.closest('tr[data-agent-id]');
const select = row?.querySelector('.agent-structure-select');
@@ -51,6 +74,10 @@
}
}
/**
* Enregistre le type d'un lieu, puis répercute le nouveau libellé dans TOUS
* les sélecteurs de la page — le type fait partie du texte affiché.
*/
async function saveStructureType(button) {
const row = button.closest('tr[data-structure-id]');
const select = row?.querySelector('.structure-type-select');
@@ -91,6 +118,10 @@
}
}
/**
* Ajoute une option à un `<select>`, ou met à jour son libellé si elle
* existe déjà. Idempotent : appelable sans risque de doublon.
*/
function addOptionIfMissing(select, value, label, selected = false) {
if (!select) return;
let option = Array.from(select.options).find((item) => item.value === String(value));
@@ -103,10 +134,18 @@
if (selected) option.selected = true;
}
/** Libellé unifié d'un lieu : « Nom — Type », identique partout. */
function structureOptionLabel(structure) {
return `${structure.nom} — ${typeLabel(structure.type_affectation)}`;
}
/**
* Insère un lieu nouvellement créé dans TOUS les sélecteurs de la page.
*
* La liste des sélecteurs est écrite en dur : tout nouvel écran ou modale
* proposant un choix de lieu doit y être ajouté, sinon il ne verra pas les
* lieux créés dans la session courante.
*/
function addStructureEverywhere(structure) {
const id = Number(structure.id_structure);
const label = structureOptionLabel(structure);
@@ -126,6 +165,11 @@
});
}
/**
* Met à jour le libellé d'un lieu dans tous les `<select>` de la page après
* un changement de type. Parcourt l'ensemble des sélecteurs plutôt qu'une
* liste nommée : le libellé peut apparaître n'importe où.
*/
function updateStructureLabels(structureId, type) {
const source = document.querySelector(`#structure-settings-body tr[data-structure-id="${Number(structureId)}"]`);
const structureName = source?.querySelector('td strong')?.textContent?.trim();
@@ -142,6 +186,7 @@
});
}
/** Options de lieu, reconstruites depuis les lieux actuellement affichés. */
function structureOptionsHtml(selectedStructureId = null) {
const source = document.querySelector('#new-agent-structure');
if (!source) return '<option value="">Sans lieu principal</option>';
@@ -155,6 +200,7 @@
return options.join('');
}
/** Ajoute la ligne d'un lieu au tableau de paramétrage, sans doublon. */
function addStructureSettingsRow(structure) {
const body = document.querySelector('#structure-settings-body');
if (!body || body.querySelector(`tr[data-structure-id="${Number(structure.id_structure)}"]`)) return;
@@ -175,17 +221,27 @@
body.appendChild(row);
}
/** « Diplômé — Libellé », « Diplômé » ou « Non diplômé ». */
function qualificationLabel(agent) {
if (!Number(agent.est_diplome)) return 'Non diplômé';
return agent.diplome_libelle ? `Diplômé — ${agent.diplome_libelle}` : 'Diplômé';
}
/**
* « 2026-02-17 » → « 17/02/2026 », « Non renseignée » si vide.
* Découpage de chaîne plutôt que `new Date()`, qui décalerait la date.
*/
function formatContractDate(value) {
if (!value) return 'Non renseignée';
const [year, month, day] = String(value).split('-');
return year && month && day ? `${day}/${month}/${year}` : value;
}
/**
* Cellules d'une ligne d'agent.
* Toutes les valeurs passent par escapeHtml() : elles viennent de la base et
* sont insérées en HTML brut.
*/
function agentRowHtml(agent) {
const structureLabel = agent.structure_nom || agent.structure_principale_nom || 'Sans lieu principal';
return `
@@ -200,6 +256,18 @@
`;
}
/**
* Recopie la fiche de l'agent dans les `data-*` de sa ligne.
*
* Ces attributs sont la SOURCE de la modale d'édition : elle s'ouvre
* uniquement à partir d'eux, sans nouvel appel réseau. Ajouter un champ
* modifiable impose donc de l'ajouter ici ET dans
* `openAgentProfileModal()`, sinon il s'ouvrira vide et sera écrasé à
* l'enregistrement.
*
* Les noms sont en camelCase, conversion automatique des `data-*` par le DOM
* (`data-date-debut-contrat` → `dataset.dateDebutContrat`).
*/
function setAgentRowData(row, agent) {
row.dataset.agentId = String(Number(agent.id_agent));
row.dataset.email = agent.email || '';
@@ -216,6 +284,7 @@
row.dataset.structureId = agent.id_structure ? String(Number(agent.id_structure)) : '';
}
/** Insère un agent nouvellement créé dans les sélecteurs et le tableau. */
function addAgentEverywhere(agent) {
const overviewAgent = document.querySelector('#overview-agent');
addOptionIfMissing(
@@ -233,6 +302,10 @@
}
}
/**
* Le libellé du diplôme n'a de sens que si l'agent est diplômé : le champ
* est désactivé et VIDÉ sinon, pour ne pas enregistrer une valeur résiduelle.
*/
function toggleDiplomaField(select, input) {
if (!select || !input) return;
const qualified = select.value === '1';
@@ -240,6 +313,10 @@
if (!qualified) input.value = '';
}
/**
* Ouvre la modale d'édition d'un agent, préremplie depuis les `data-*` de sa
* ligne (voir `setAgentRowData()`).
*/
function openAgentProfileModal(row) {
const modal = document.querySelector('#agent-profile-modal');
if (!modal || !row) return;
@@ -268,11 +345,17 @@
modal.hidden = false;
}
/** Ferme la modale d'édition d'un agent. */
function closeAgentProfileModal() {
const modal = document.querySelector('#agent-profile-modal');
if (modal) modal.hidden = true;
}
/**
* Enregistre la fiche d'un agent, puis met à jour sa ligne et ses `data-*`
* avec la fiche renvoyée par le serveur — et non avec les valeurs saisies :
* le serveur peut avoir normalisé ou refusé certains champs.
*/
async function saveAgentProfile(event) {
event.preventDefault();
const form = event.currentTarget;
@@ -335,6 +418,10 @@
}
}
/**
* Crée un lieu d'affectation, puis le diffuse dans toute la page.
* Un code déjà pris ressort en 409 et le message le dit explicitement.
*/
async function createStructure(event) {
event.preventDefault();
const form = event.currentTarget;
@@ -376,6 +463,11 @@
}
}
/**
* Crée un agent, puis le diffuse dans toute la page.
* Le serveur ouvre au passage son quota annuel et son PTA ; un matricule
* déjà pris ressort en 409.
*/
async function createAgent(event) {
event.preventDefault();
const form = event.currentTarget;
@@ -439,6 +531,11 @@
}
}
/**
* Point d'entrée appelé par page-bootstrap.js.
* Les tableaux sont écoutés par DÉLÉGATION : leurs lignes sont ajoutées
* dynamiquement et n'existent pas au moment où les écouteurs sont posés.
*/
function init() {
document.querySelector('#agent-assignment-body')?.addEventListener('click', (event) => {
const button = event.target.closest('.edit-agent-profile');

View File

@@ -1,3 +1,20 @@
/**
* Vue mensuelle d'un agent (`PTA.modules.agentOverview`), écran agents.php.
*
* Affiche le planning d'un agent mois par mois, TOUS LIEUX CONFONDUS, et permet
* de modifier ses créneaux sans passer par l'écran de saisie.
*
* DIFFÉRENCE ESSENTIELLE AVEC L'ÉDITEUR DE PLANNING : ici, toute modification
* part IMMÉDIATEMENT au serveur. Il n'y a pas de brouillon local, pas de bouton
* « Enregistrer » : la page ne travaille pas sur une semaine mais sur des
* créneaux déjà en base, éventuellement répartis sur plusieurs lieux et
* plusieurs plannings. Chaque geste est donc suivi d'un rechargement complet.
*
* Trois niveaux de droits se combinent : `can_edit_draft` pour modifier,
* `can_edit_cross_structure` pour toucher un créneau d'un autre lieu que le
* sien, `can_copy_full_week` pour dupliquer une semaine entière. L'interface
* masque en conséquence ; le serveur retranche de toute façon.
*/
(() => {
'use strict';
@@ -26,18 +43,34 @@
const typeLabel = (type) => type === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire';
/** Message dans un conteneur donné : la page en a plusieurs (bandeau, modales). */
function setMessage(element, text = '', type = 'info') {
if (!element) return;
element.className = text ? `message message-${type}` : 'message';
element.textContent = text;
}
/**
* Un créneau n'est modifiable que si l'utilisateur peut éditer les
* brouillons ET que le créneau relève de son périmètre : hors
* `can_edit_cross_structure`, seul son propre lieu.
*/
function canEditEntry(entry) {
if (!canEditDraft) return false;
if (canEditCrossStructure) return true;
return Number(entry.id_structure) === Number(accessProfile.structure_id || 0);
}
/**
* Balisage d'un créneau dans la grille mensuelle.
*
* Un créneau non modifiable est rendu SANS attribut de glisser-déposer et
* porte la mention « Lecture seule » : le droit se voit, plutôt que de se
* découvrir par un refus au clic.
*
* Toutes les valeurs issues de la base passent par escapeHtml(), y compris
* `motif_code` qui alimente un nom de classe CSS.
*/
function entryBadge(entry) {
const editable = canEditEntry(entry);
const interactionAttributes = editable
@@ -62,6 +95,7 @@
`;
}
/** Total des minutes s'imputant sur le quota, motifs hors quota exclus. */
function countedMinutes(entries) {
return entries.reduce((sum, entry) => {
if (!Number(entry.compte_dans_quota)) return sum;
@@ -69,6 +103,13 @@
}, 0);
}
/**
* Rend la grille mensuelle : une carte par jour, groupée par semaine ISO.
*
* La grille déborde du mois pour rester alignée sur les semaines complètes
* (voir AgentController::monthOverview) ; les jours hors mois sont grisés
* mais bien comptés dans les totaux hebdomadaires.
*/
function render(data) {
const container = document.querySelector('#agent-overview-content');
const empty = document.querySelector('#agent-overview-empty');
@@ -138,10 +179,19 @@
bindMonthInteractions();
}
/**
* Retrouve un créneau dans les données du dernier chargement.
* `shared.lastAgentOverviewData` sert de cache : le glisser-déposer a besoin
* du créneau complet à partir du seul identifiant porté par le DOM.
*/
function findEntry(entryId) {
return shared.lastAgentOverviewData?.entries?.find((item) => Number(item.id_creneau) === Number(entryId)) || null;
}
/**
* Envoie un déplacement ou une duplication au serveur.
* Laisse remonter l'exception : l'appelant décide de l'affichage.
*/
async function persistEntryMove(entryId, targetDate, duplicate) {
const response = await fetch('api/move_or_duplicate_entry.php', {
method: 'POST',
@@ -158,12 +208,22 @@
return data;
}
/** Retire les marques visuelles de dépôt sur toutes les cartes de jour. */
function clearDropTargets() {
document.querySelectorAll('#agent-overview-content .agent-month-day-card').forEach((card) => {
card.classList.remove('is-drop-target', 'is-copy-target');
});
}
/**
* Attache le glisser-déposer aux créneaux et aux jours après chaque rendu.
*
* À rappeler à CHAQUE `render()` : la grille est reconstruite entièrement et
* les écouteurs précédents disparaissent avec les anciens nœuds.
*
* Ctrl/⌘ enfoncé pendant le glisser transforme le déplacement en
* duplication, convention reprise des gestionnaires de fichiers.
*/
function bindMonthInteractions() {
const container = document.querySelector('#agent-overview-content');
if (!container) return;
@@ -225,6 +285,7 @@
});
}
/** Ouvre le contrat horaire annuel de l'agent dans un nouvel onglet. */
function openHoursContractPdf() {
const data = shared.lastAgentOverviewData;
const agentId = data?.agent?.id_agent || document.querySelector('#overview-agent')?.value;
@@ -234,6 +295,7 @@
window.open(`api/agent_hours_contract_pdf.php?${params.toString()}`, '_blank', 'noopener');
}
/** Ouvre le bilan d'heures annuel de l'agent dans un nouvel onglet. */
function openTimeSummaryPdf() {
const data = shared.lastAgentOverviewData;
const agentId = data?.agent?.id_agent || document.querySelector('#overview-agent')?.value;
@@ -260,6 +322,7 @@
* droit à l'enveloppe de formation. Le motif déjà porté par le créneau reste
* proposé, sans quoi le modal changerait silencieusement de motif.
*/
/** Options du sélecteur de motif de la modale d'édition. */
function renderMotifOptions(selectedMotifId) {
const select = document.querySelector('#edit-agent-entry-motif');
const motifs = window.PTA_CONFIG?.motifs || [];
@@ -276,6 +339,7 @@
.join('');
}
/** Ouvre la modale d'édition d'un créneau, préremplie de ses valeurs. */
function openModal(entryId) {
const entry = findEntry(entryId);
if (!entry || !canEditEntry(entry)) return;
@@ -295,6 +359,11 @@
document.querySelector('#edit-agent-entry-motif')?.focus();
}
/**
* Supprime un créneau après confirmation, puis recharge la vue.
* Si le planning porteur était validé, le serveur le repasse en brouillon et
* le signale dans son message.
*/
async function deleteEntry(entryId) {
const entry = findEntry(entryId);
if (!entry || !canEditEntry(entry)) return;
@@ -334,6 +403,7 @@
}
}
/** Enregistre les modifications du créneau, puis recharge la vue. */
async function saveEntry(event) {
event.preventDefault();
if (!canEditDraft) return;
@@ -371,6 +441,14 @@
}
}
/**
* Date → « 2026-W08 », numérotation ISO 8601.
*
* L'algorithme décale la date au JEUDI de sa semaine (`+ 4 - jour`), car
* c'est le jeudi qui détermine à quelle année appartient une semaine ISO.
* Sans ce décalage, une semaine à cheval sur deux années serait mal
* numérotée. Calcul en UTC pour éviter tout décalage de fuseau.
*/
function isoWeekValueFromDate(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const day = d.getUTCDay() || 7;
@@ -380,6 +458,7 @@
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}
/** « 2026-W08 » → la Date du lundi correspondant (semaine 1 = celle du 4 janvier). */
function weekMonday(weekValue) {
const match = /^(\d{4})-W(\d{2})$/.exec(weekValue);
if (!match) return null;
@@ -392,6 +471,11 @@
return monday;
}
/**
* Ouvre la modale de duplication d'une semaine entière.
* Réservée à `can_copy_full_week` : la copie porte sur TOUS les lieux de
* l'agent, elle déborde du périmètre d'un responsable de structure.
*/
function openWeekCopyModal(sourceWeek) {
if (!canCopyFullWeek) return;
const modal = document.querySelector('#agent-week-copy-modal');
@@ -414,6 +498,7 @@
targetInput.focus();
}
/** Ferme la modale de duplication de semaine. */
function closeWeekCopyModal() {
const modal = document.querySelector('#agent-week-copy-modal');
if (!modal) return;
@@ -424,6 +509,12 @@
setMessage(document.querySelector('#agent-week-copy-message'));
}
/**
* Duplique la semaine source vers la semaine cible.
* Deux modes : « merge » complète l'existant, « replace » l'écrase. En mode
* merge, le moindre chevauchement fait échouer toute l'opération côté
* serveur — il n'y a pas de copie partielle.
*/
async function copyWeek(event) {
event.preventDefault();
if (!canCopyFullWeek) return;
@@ -471,6 +562,11 @@
}
}
/**
* Charge et rend la vue mensuelle de l'agent sélectionné.
* Sans agent ni mois, affiche une invite plutôt qu'une erreur : c'est
* l'état normal à l'ouverture de la page.
*/
async function load() {
const agentId = document.querySelector('#overview-agent')?.value;
const month = document.querySelector('#agent-overview-month')?.value;
@@ -502,6 +598,12 @@
}
}
/**
* Point d'entrée appelé par page-bootstrap.js.
* Les clics dans la grille sont traités par DÉLÉGATION sur le conteneur :
* elle est redessinée à chaque chargement, des écouteurs individuels
* devraient être reposés à chaque fois.
*/
function init() {
document.querySelector('#load-agent-overview')?.addEventListener('click', load);
document.querySelector('#overview-agent')?.addEventListener('change', load);

View File

@@ -1,7 +1,23 @@
/**
* Vue annuelle d'un agent (`PTA.modules.annualOverview`), écran annual.php.
*
* Affiche les douze mois de l'année civile et permet de valider TOUTE l'année en
* une opération.
*
* PARTICULARITÉ — ce module ne calcule presque rien. Le calendrier arrive du
* serveur entièrement préparé (classes CSS, libellés, infobulles, totaux) :
* voir AnnualOverviewModel. Le rôle du JavaScript se limite à assembler le HTML
* et à gérer les interactions. C'est ce qui garantit un rendu identique à
* l'écran, à l'impression et en plein écran.
*
* Chaque jour est cliquable et ouvre l'écran de saisie sur la bonne semaine et
* le bon lieu.
*/
(() => {
'use strict';
const PTA = window.PTA;
// core.js absent : la page reste lisible, simplement non interactive.
if (!PTA) return;
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils;
const el = (id) => document.getElementById(id);
@@ -10,6 +26,7 @@
const escapeAttr = escapeHtml;
let current = null;
/** Message dans le bandeau de la page. */
function show(text, type = 'info') {
const box = el('annual-message');
if (!box) return;
@@ -17,17 +34,29 @@
box.textContent = text;
}
/**
* Horodatage SQL « 2026-02-17 09:30:00 » → date et heure en français.
* Le remplacement de l'espace par « T » rend la chaîne conforme à ISO 8601,
* seule forme que tous les navigateurs analysent de la même façon.
*/
function formatGenerated(value) {
const date = new Date(String(value).replace(' ', 'T'));
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' });
}
/** Libellé du type de contrat. */
function formatContract(value) {
if (value === 'PERMANENT') return 'Permanent';
if (value === 'TEMPORAIRE') return 'Temporaire';
return value || 'Non renseigné';
}
/**
* Lien profond d'une journée vers l'écran de saisie.
* Le lieu retenu est celui du premier créneau du jour, à défaut le
* rattachement de l'agent. `notice` rappelle d'où vient l'utilisateur, sans
* quoi il arriverait sur une semaine sans savoir pourquoi.
*/
function buildPlanningUrl(day) {
const agentId = Number(current?.agent?.id_agent || 0);
const structureId = Number(day.structure_id || current?.agent?.id_structure || 0);
@@ -38,6 +67,10 @@
return `planning.php?${params.toString()}`;
}
/**
* Une cellule du calendrier. Contenu, classe et infobulle viennent du
* serveur ; seule la marque « brouillon » est ajoutée ici.
*/
function cellHtml(cell, day) {
const content = cell.display ? escapeHtml(cell.display) : '&nbsp;';
const title = cell.title ? ` title="${escapeAttr(cell.title)}"` : '';
@@ -45,6 +78,7 @@
return `<td class="annual-data-cell ${escapeHtml(cell.class || '')}${draftClass}" data-date="${escapeHtml(day.date)}"${title}>${content}</td>`;
}
/** Tableau d'un mois : une ligne par jour, quatre colonnes AP/PP/AE/PE. */
function monthHtml(month) {
const rows = month.days.map((day) => {
const classes = [
@@ -80,6 +114,7 @@
</article>`;
}
/** Rappel du ou des quotas couvrant l'année affichée. */
function quotaPeriodsHtml(periods) {
return periods.map((period) => `
<article class="annual-quota-chip">
@@ -89,6 +124,7 @@
</article>`).join('');
}
/** Rend l'ensemble de la vue et mémorise les données dans `current`. */
function render(data) {
current = data;
const agent = data.agent || {};
@@ -123,6 +159,7 @@
if (el('annual-validate-year')) el('annual-validate-year').disabled = false;
}
/** Charge la vue annuelle de l'agent et de l'année sélectionnés. */
async function load(options = {}) {
const agentId = Number(el('annual-agent')?.value || 0);
const year = Number(el('annual-year')?.value || 0);
@@ -147,6 +184,7 @@
}
}
/** Lien de correction d'une semaine en erreur, ouvert dans un nouvel onglet. */
function validationPlanningUrl(planning) {
const agentId = Number(current?.agent?.id_agent || el('annual-agent')?.value || 0);
const week = `${Number(planning.annee)}-W${String(planning.numero_semaine).padStart(2, '0')}`;
@@ -160,6 +198,11 @@
return `planning.php?${params.toString()}`;
}
/**
* Rend l'échec de validation annuelle : AUCUNE semaine n'a été validée.
* Chaque semaine fautive est listée avec ses erreurs et un lien direct pour
* la corriger — c'est ce qui rend l'échec exploitable.
*/
function renderValidationIssues(data) {
const container = el('annual-validation-result');
if (!container) return;
@@ -188,6 +231,7 @@
</div>`;
}
/** Rend l'avertissement de quota dépassé, en attente de confirmation. */
function renderValidationWarning(data) {
const container = el('annual-validation-result');
if (!container) return;
@@ -198,6 +242,7 @@
</div>`;
}
/** Rend le compte rendu d'une validation annuelle réussie. */
function renderValidationSuccess(data) {
const container = el('annual-validation-result');
if (!container) return;
@@ -208,6 +253,17 @@
</div>`;
}
/**
* Lance la validation de l'année entière.
*
* Vérifie d'abord que la vue affichée correspond bien à l'agent et à l'année
* sélectionnés : sans ce contrôle, changer le sélecteur sans recharger
* validerait une année différente de celle qu'on regarde.
*
* Trois issues, alignées sur AnnualOverviewController::validateYear() :
* 422 erreurs, 409 quota dépassé (rappel avec `forceQuota = true` après
* confirmation), 200 validé.
*/
async function validateWholeYear(forceQuota = false) {
const agentId = Number(el('annual-agent')?.value || 0);
const year = Number(el('annual-year')?.value || 0);
@@ -266,12 +322,22 @@
}
}
/**
* Clic sur une ligne du calendrier : suit le lien porté par `data-url`.
* Délégation sur le conteneur, le calendrier étant redessiné à chaque
* chargement.
*/
function handleCalendarClick(event) {
const row = event.target.closest('tr[data-url]');
if (!row) return;
window.location.href = row.dataset.url;
}
/**
* Bascule le calendrier en plein écran — utile sur douze mois affichés d'un
* coup. L'API Fullscreen peut être refusée (navigateur, iframe, réglages) :
* l'échec est signalé sans conséquence pour le reste de la page.
*/
async function toggleFullscreen() {
const target = el('annual-content');
if (!target) return;

View File

@@ -1,6 +1,37 @@
/**
* Socle JavaScript commun — À CHARGER EN PREMIER, tous les autres modules en
* dépendent.
*
* Il n'y a ni bundler ni module ES dans ce projet : chaque fichier est une IIFE,
* et la seule interface entre eux est l'objet global `window.PTA` publié en fin
* de fichier. Un module s'enregistre en écrivant dans `PTA.modules` :
*
* PTA.modules.agentOverview = { init, load };
*
* `page-bootstrap.js` appelle ensuite `init()` sur chaque module déclaré par le
* PageController, et les modules se rafraîchissent entre eux par des appels
* optionnels : `PTA.modules.autre?.refresh?.({ silent: true })`.
*
* Chemin des données : PHP publie `window.PTA_CONFIG` (layouts/footer.php) et
* `window.PTA_PAGE_CONFIG` ; `PTA.config` en est la copie, et contient
* notamment `csrfToken`, `motifs` et `permissions`.
*
* Rappel de versionnement : après toute modification d'un fichier JS ou CSS, il
* faut incrémenter le `?v=` dans layouts/header.php, layouts/footer.php ET
* Views/pages/role.php, sinon nginx (expires 7d) et les navigateurs continuent
* de servir l'ancienne version.
*/
(() => {
'use strict';
/**
* Références DOM résolues UNE FOIS au chargement.
*
* Le même objet sert à toutes les pages : la plupart des entrées valent donc
* `null` sur un écran donné. C'est voulu — d'où les `if (!element) return;`
* systématiques dans les modules. Ces sélecteurs concernent principalement
* l'éditeur de planning, seul écran assez partagé pour justifier un cache.
*/
const elements = {
structureSelect: document.querySelector('#structure'),
agentSelect: document.querySelector('#agent'),
@@ -27,6 +58,14 @@
quotaRemaining: document.querySelector('#quota-remaining'),
};
/**
* État de l'éditeur de planning, PARTAGÉ ET MUTABLE.
*
* Plusieurs modules le lisent et l'écrivent (planning-editor, week-templates,
* week-template-editor). C'est la contrepartie assumée de l'absence de
* framework : avant de modifier une de ces clés, vérifier qui d'autre s'en
* sert.
*/
const state = {
entries: [],
otherStructureEntries: [],
@@ -39,10 +78,15 @@
savedCurrentCountedMinutes: 0,
};
/**
* Cache de données brutes qu'un module expose aux autres, pour éviter de
* refaire un appel réseau déjà effectué.
*/
const shared = {
lastAgentOverviewData: null,
};
/** Semaine ouvrée : le modèle métier s'arrête au vendredi. */
const dayNames = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi'];
/**
@@ -62,18 +106,40 @@
.replaceAll("'", '&#039;');
}
/**
* Affiche un message dans le bandeau de la page.
* @param {string} text
* @param {'info'|'success'|'error'|'warning'} type Suffixe de la classe CSS.
*/
function showMessage(text, type = 'info') {
if (!elements.messageBox) return;
elements.messageBox.className = `message message-${type}`;
elements.messageBox.textContent = text;
}
/** Vide le bandeau de message. */
function clearMessage() {
if (!elements.messageBox) return;
elements.messageBox.className = 'message';
elements.messageBox.textContent = '';
}
/**
* Lit une réponse d'API en JSON, en produisant un message LISIBLE quand ce
* n'en est pas.
*
* Le corps est lu en texte avant d'être analysé : `response.json()` échouerait
* sur une page d'erreur HTML avec un message incompréhensible. Ici, le HTML
* est dépouillé de ses balises pour faire remonter le message PHP réel.
*
* Ce filet ne devrait jamais servir — `JsonResponse::bootstrap()` garantit
* du JSON côté serveur — mais il reste la seule façon de diagnostiquer une
* erreur survenue AVANT ce bootstrap (erreur de syntaxe, page nginx).
*
* @param {Response} response
* @returns {Promise<object>}
* @throws {Error} Réponse vide ou non-JSON.
*/
async function readJsonResponse(response) {
const raw = await response.text();
@@ -102,11 +168,20 @@
}
}
/**
* Compose le message d'erreur à afficher à partir d'une réponse d'API.
* `details` n'est présent que si le mode debug est actif côté serveur.
*/
function apiErrorMessage(data, fallback) {
const base = data?.error || fallback;
return data?.details ? `${base} Détail : ${data.details}` : base;
}
/**
* Date → « AAAA-MM-JJ » en heure LOCALE.
* `toISOString()` convertirait en UTC et décalerait la date d'un jour selon
* l'heure et le fuseau.
*/
function toIsoDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -114,6 +189,18 @@
return `${year}-${month}-${day}`;
}
/**
* « 2026-W08 » → les cinq jours ouvrés de cette semaine ISO.
*
* Le calcul s'appuie sur la définition ISO 8601 : la semaine 1 est celle qui
* contient le 4 JANVIER. On recule donc jusqu'au lundi de cette semaine-là,
* puis on avance de (semaine - 1) × 7 jours. `getDay() || 7` ramène dimanche
* de 0 à 7, la numérotation ISO commençant au lundi.
*
* @param {string} value Semaine au format de l'input HTML `type="week"`.
* @returns {Array<{date: string, label: string, display: string}>} Vide si
* le format ne correspond pas.
*/
function parseIsoWeek(value) {
const match = /^(\d{4})-W(\d{2})$/.exec(value);
if (!match) return [];
@@ -136,11 +223,16 @@
});
}
/** « 09:30 » → 570. Comparer des minutes évite les pièges de tri de chaînes. */
function timeToMinutes(time) {
const [hours, minutes] = String(time).split(':').map(Number);
return hours * 60 + minutes;
}
/**
* 450 → « 7 h 30 ». Gère les valeurs négatives (dépassement de quota) : le
* signe est extrait avant la division.
*/
function formatDuration(minutes) {
const sign = minutes < 0 ? '-' : '';
const absolute = Math.abs(Math.round(minutes));
@@ -149,12 +241,22 @@
return `${sign}${hours} h ${String(mins).padStart(2, '0')}`;
}
/**
* « 2026-02-17 » → « 17/02/2026 ».
* Découpage de chaîne plutôt que `new Date()`, qui interpréterait la valeur
* en UTC et pourrait afficher la veille.
*/
function formatDate(value) {
if (!value) return '';
const [year, month, day] = String(value).split('-');
return `${day}/${month}/${year}`;
}
/**
* Retrouve un motif dans le référentiel publié par PHP.
* @returns {{id: number, code: string, label: string, quota: boolean}|null}
* `quota` indique si le motif s'impute sur le quota annuel.
*/
function motifInfo(id) {
const motif = (window.PTA_CONFIG?.motifs || []).find((item) => Number(item.id) === Number(id));
return motif ? {
@@ -165,6 +267,11 @@
} : null;
}
/**
* Interface publique, unique point de contact entre les modules.
* Ne jamais y ajouter de logique métier : `utils` ne contient que des
* fonctions sans état.
*/
window.PTA = {
config: window.PTA_CONFIG || {},
elements,

View File

@@ -1,3 +1,21 @@
/**
* Contrôle de couverture (`PTA.modules.coverage`).
*
* Chargé par DEUX écrans, avec des rôles différents :
* - coverage.php : consultation des alertes de la semaine ;
* - administration.php : édition des règles de couverture des lieux.
* Les sélecteurs des deux moitiés sont donc absents l'un chez l'autre, d'où les
* `if (!element) return;` systématiques.
*
* Une « alerte » est une plage où le nombre d'agents planifiés passe sous le
* minimum attendu du lieu. Le serveur découpe au quart d'heure puis refusionne
* les créneaux consécutifs identiques ; ce module se contente d'afficher les
* intervalles reçus.
*
* L'ÉDITEUR DE RÈGLES fonctionne en REMPLACEMENT INTÉGRAL : `collectRules()` lit
* la totalité de l'écran et l'envoie d'un bloc. Un jour vidé de ses plages
* supprime les règles correspondantes — c'est le comportement voulu.
*/
(() => {
'use strict';
@@ -34,6 +52,7 @@
let alerts = [];
let loading = false;
/** Pastille d'onglet « Couverture », masquée quand il n'y a rien à signaler. */
function setBadge(count) {
if (!badge) return;
badge.textContent = String(count);
@@ -47,6 +66,11 @@
messageElement.textContent = text;
}
/**
* Rend le tableau des alertes.
* Les lieux NON CONFIGURÉS sont affichés séparément : sans règle, ils ne
* peuvent produire aucune alerte et passeraient à tort pour conformes.
*/
function renderAlerts(data) {
alerts = Array.isArray(data.alerts) ? data.alerts : [];
const summary = data.summary || {};
@@ -93,6 +117,13 @@
}
}
/**
* Recharge les alertes de la semaine et du lieu sélectionnés.
* Appelée aussi par l'éditeur de planning après un enregistrement, en mode
* silencieux : la couverture dépend de ce qui vient d'être saisi.
*
* @param {{silent?: boolean}} options
*/
async function refresh(options = {}) {
if (loading) return;
const week = weekInput?.value;
@@ -119,6 +150,11 @@
}
}
/**
* Ouvre l'écran de saisie sur le lieu et la semaine du manque, avec un
* `notice` qui rappelle la plage à couvrir : sans lui, l'utilisateur
* arriverait sur une semaine complète sans savoir où agir.
*/
function openGap(alert) {
if (!alert) return;
const params = new URLSearchParams({
@@ -129,6 +165,10 @@
window.location.href = `planning.php?${params.toString()}`;
}
/**
* Balisage d'une plage de couverture : début, fin, effectif minimal.
* `step="900"` contraint la saisie au quart d'heure, comme partout ailleurs.
*/
function coverageRuleRow(rule = {}) {
return `
<div class="coverage-rule-row">
@@ -148,6 +188,10 @@
</div>`;
}
/**
* Rend l'éditeur : une section par jour ouvré, TOUJOURS les cinq, même sans
* règle. C'est ce qui permet d'ajouter une plage sur un jour vide.
*/
function renderRuleEditor(rules = []) {
if (!ruleEditor) return;
const grouped = new Map();
@@ -168,6 +212,7 @@
if (saveRulesButton) saveRulesButton.disabled = false;
}
/** Charge les règles du lieu sélectionné dans l'éditeur. */
async function loadRules() {
const structureId = ruleStructureSelect?.value;
if (!structureId || !ruleEditor) {
@@ -195,6 +240,11 @@
}
}
/**
* Lit la totalité de l'éditeur et produit la liste à envoyer.
* Une ligne entièrement vide est ignorée sans erreur : c'est une plage
* ajoutée puis abandonnée, pas une saisie incomplète.
*/
function collectRules() {
const rules = [];
ruleEditor?.querySelectorAll('.coverage-rule-day').forEach((daySection) => {
@@ -210,6 +260,11 @@
return rules;
}
/**
* Enregistre les règles du lieu — REMPLACEMENT INTÉGRAL.
* Chevauchements et horaires incohérents sont refusés côté serveur, dont le
* message est affiché tel quel.
*/
async function saveRules() {
const structureId = ruleStructureSelect?.value;
if (!structureId || !saveRulesButton || !ruleMessage) return;

View File

@@ -1,3 +1,19 @@
/**
* Tableau de bord du Service Enfance (`PTA.modules.dashboard`), écran dashboard.php.
*
* N'introduit AUCUNE règle métier : il rassemble sur un écran les compteurs déjà
* calculés ailleurs (plannings à valider, couverture, quotas) et les transforme
* en liens directs vers l'action correspondante. Chaque élément affiché est
* cliquable et ouvre l'écran concerné, pré-positionné.
*
* Tout provient d'un unique appel à `api/dashboard_summary.php`, qui agrège les
* quatre blocs côté serveur : un appel par bloc multiplierait les allers-retours
* pour un écran de consultation.
*
* Ce module alimente aussi la pastille « À valider » de la barre de navigation
* et court-circuite pour cela le rafraîchissement générique de
* `page-bootstrap.js`, qui s'efface quand ce module est présent.
*/
(() => {
'use strict';
@@ -65,18 +81,22 @@
tile.classList.toggle('is-clear', Number(count) === 0);
}
/** Accord du pluriel français : singulier jusqu'à 1 inclus. */
function plural(count, singular, pluralForm) {
return Number(count) > 1 ? pluralForm : singular;
}
/** Semaine actuellement affichée, au format « 2026-W08 ». */
function weekParam() {
return elements.week?.value || '';
}
/** Recompose « 2026-W08 » depuis l'année et le numéro de semaine d'un élément. */
function weekValueOf(item) {
return `${item.annee}-W${String(item.numero_semaine).padStart(2, '0')}`;
}
/** Met à jour la pastille de navigation, libellé accessible compris. */
function setNavBadge(count) {
if (!elements.navBadge) return;
elements.navBadge.textContent = String(count);
@@ -87,6 +107,11 @@
);
}
/**
* Bandeau de contexte de la semaine : dates et caractère périscolaire,
* extrascolaire ou MIXTE — une semaine peut basculer en cours de route quand
* les vacances commencent un mercredi.
*/
function renderWeek(week) {
if (!week) return;
const contexteLabel = {
@@ -113,6 +138,10 @@
}
}
/**
* Bloc « À valider » : compteurs et aperçu des brouillons les plus anciens,
* qui sont ceux à traiter en premier (l'API les trie déjà ainsi).
*/
function renderPending(validation) {
const count = Number(validation.count || 0);
pendingItems = Array.isArray(validation.apercu) ? validation.apercu : [];
@@ -148,6 +177,11 @@
`).join('');
}
/**
* Bloc « Couverture » : plages sous-effectif, triées par manque décroissant.
* Les lieux SANS RÈGLE sont signalés à part : ils ne peuvent produire aucune
* alerte, et les taire les ferait passer pour conformes.
*/
function renderCoverage(coverage, week, perimetre) {
const gapCount = Number(coverage.gap_count || 0);
const unconfigured = Number(coverage.unconfigured_count || 0);
@@ -209,6 +243,11 @@
}
}
/**
* Bloc « Quotas » : agents en dépassement et agents sans aucune heure
* planifiée. Aucun cumul tous agents confondus n'est affiché — une somme
* d'agents aux quotités différentes ne décrirait aucune situation réelle.
*/
function renderQuota(quota) {
const overCount = Number(quota.depassement_count || 0);
setTileTone(elements.quotaTile, overCount);
@@ -265,6 +304,11 @@
}).join('');
}
/**
* Ouvre l'écran de saisie sur le planning choisi.
* `focus=validation` fait défiler la page jusqu'aux boutons d'action à
* l'arrivée : l'utilisateur vient pour valider, pas pour saisir.
*/
function openPendingPlanning(item) {
if (!item) return;
const params = new URLSearchParams({
@@ -277,6 +321,14 @@
window.location.href = `planning.php?${params.toString()}`;
}
/**
* Charge et rend l'ensemble du tableau de bord.
* Le drapeau `loading` empêche deux chargements concurrents, l'appel étant
* coûteux côté serveur.
*
* @param {{silent?: boolean}} options `silent` supprime l'indicateur de
* chargement, pour les rafraîchissements déclenchés par un autre module.
*/
async function load(options = {}) {
if (loading) return;
loading = true;

View File

@@ -1,7 +1,24 @@
/**
* Amorçage de page — À CHARGER EN DERNIER, après core.js et tous les modules.
*
* Deux responsabilités :
* 1. appeler `init()` sur chaque module déclaré par le PageController ;
* 2. appliquer les filtres venus de l'URL (structure_id, agent_id, week, month,
* focus…), que PHP a assainis et republiés dans `window.PTA_PAGE_CONFIG`.
*
* Ce second point est ce qui rend les LIENS PROFONDS opérants : le tableau de
* bord, la file de validation et la vue annuelle renvoient tous vers un écran
* pré-positionné sur le bon lieu, le bon agent et la bonne semaine.
*
* Les initialisations sont `async` mais lancées SANS `await` en fin de fichier :
* elles concernent des écrans différents et jamais plus d'une n'agit sur une
* page donnée.
*/
(() => {
'use strict';
const PTA = window.PTA;
// core.js absent ou en erreur : rien à amorcer.
if (!PTA) return;
const page = window.PTA_PAGE_CONFIG || {};
@@ -10,6 +27,13 @@
modules.forEach((name) => PTA.modules[name]?.init?.());
/**
* Écran de saisie : positionne lieu, agent et semaine depuis l'URL.
*
* L'ordre des étapes est CONTRAINT : la liste des agents dépend du lieu, et
* la semaine ne peut être chargée qu'une fois l'agent connu — d'où les
* `await` successifs.
*/
async function initialisePlanning() {
const planning = PTA.modules.planning;
if (!planning) return;
@@ -46,6 +70,7 @@
}
}
/** Planning par lieu : pré-sélectionne le lieu et charge la semaine. */
async function initialiseStructureOverview() {
if (!params.structureId) return;
const select = document.querySelector('#overview-structure');
@@ -56,6 +81,11 @@
await PTA.modules.structureOverview?.load?.();
}
/**
* Planning par agent : pré-sélectionne l'agent et le mois.
* Le chargement n'est déclenché que si un agent est désigné ; sans agent, il
* n'y a rien à afficher.
*/
async function initialiseAgentOverview() {
if (params.agentId) {
const select = document.querySelector('#overview-agent');
@@ -71,6 +101,7 @@
if (params.agentId) await PTA.modules.agentOverview?.load?.();
}
/** Contrôle de couverture : pré-sélectionne le lieu et rafraîchit. */
function initialiseCoverage() {
if (!params.structureId) return;
const select = document.querySelector('#coverage-structure-filter');

View File

@@ -1,3 +1,18 @@
/**
* File des plannings à valider (`PTA.modules.pendingValidation`), écran pending.php.
*
* Liste les brouillons NON VIDES, du plus ancien au plus récent — l'ordre de
* traitement attendu, garanti par l'API. Cliquer sur une ligne ouvre l'écran de
* saisie positionné sur ce planning, défilé jusqu'aux boutons d'action.
*
* Ce module est aussi rafraîchi en mode silencieux par l'éditeur de planning
* après chaque enregistrement ou validation, et alimente la pastille « À
* valider » de la barre de navigation.
*
* Deux totaux distincts sont affichés : les heures SAISIES et les heures
* DÉCOMPTÉES du quota. Congés et absences non décomptées figurent au planning
* sans peser sur le quota annuel.
*/
(() => {
'use strict';
@@ -21,10 +36,16 @@
let items = [];
let loading = false;
/** Recompose « 2026-W08 » depuis l'année et le numéro de semaine. */
function weekValue(item) {
return `${item.annee}-W${String(item.numero_semaine).padStart(2, '0')}`;
}
/**
* Horodatage SQL → date et heure en français.
* Le remplacement de l'espace par « T » rend la chaîne conforme à ISO 8601 ;
* une valeur non analysable est renvoyée telle quelle, échappée.
*/
function formatDateTime(value) {
if (!value) return '—';
const normalized = String(value).replace(' ', 'T');
@@ -36,10 +57,12 @@
});
}
/** Libellé du type de lieu. */
function structureTypeLabel(value) {
return value === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire';
}
/** Pastille de navigation, masquée quand la file est vide. */
function setBadge(count) {
if (!badge) return;
badge.textContent = String(count);
@@ -53,6 +76,7 @@
messageElement.textContent = text;
}
/** Rend le tableau de la file et ses compteurs de synthèse. */
function render(data) {
items = Array.isArray(data.plannings) ? data.plannings : [];
const summary = data.summary || {};
@@ -98,6 +122,11 @@
}).join('');
}
/**
* Recharge la file d'attente.
* @param {{silent?: boolean}} options `silent` pour les rafraîchissements
* déclenchés par un autre module, sans indicateur de chargement.
*/
async function refresh(options = {}) {
if (loading) return;
loading = true;
@@ -123,6 +152,11 @@
}
}
/**
* Ouvre l'écran de saisie sur ce planning.
* `focus=validation` fait défiler jusqu'aux boutons d'action : l'utilisateur
* vient pour valider, pas pour saisir.
*/
function openPlanning(item) {
if (!item) return;
const params = new URLSearchParams({
@@ -135,6 +169,11 @@
window.location.href = `planning.php?${params.toString()}`;
}
/**
* Retrouve le planning correspondant à l'élément cliqué, via le
* `data-planning-id` porté par la ligne. Support de la délégation
* d'événements sur le corps du tableau.
*/
function findItemFromEventTarget(target) {
const trigger = target.closest('[data-planning-id]');
if (!trigger) return null;

View File

@@ -1,3 +1,31 @@
/**
* Éditeur de planning hebdomadaire — module le plus important de l'interface.
*
* Il expose `PTA.modules.planning` et gère toute la saisie d'une semaine :
* formulaire jour par jour, tableau de visualisation, glisser-déposer,
* projection du quota, enregistrement, validation et réouverture.
*
* DEUX REPRÉSENTATIONS DES MÊMES DONNÉES, à ne pas confondre :
* - le FORMULAIRE (`#weekly-entry-body`), une ligne par jour et des « plages »
* dans chaque ligne : c'est ce que l'utilisateur remplit ;
* - `state.entries`, la liste à plat des créneaux : c'est ce qui est envoyé au
* serveur et ce qu'affiche le tableau.
* Les deux sens de conversion sont assurés par `syncEntriesFromWeeklyForm()`
* (formulaire → état, avec validation) et `fillWeeklyEntryRowsFromEntries()`
* (état → formulaire). Modifier l'un sans l'autre désynchronise l'écran.
*
* DEUX CHEMINS D'ÉCRITURE, également distincts :
* - LOCAL : la modification reste dans `state.entries` et n'est envoyée qu'à
* l'enregistrement du brouillon. C'est le cas nominal ;
* - SERVEUR IMMÉDIAT : un créneau appartenant à un AUTRE lieu, ou à un planning
* déjà VALIDÉ, est modifié par un appel direct à l'API. Il ne fait pas partie
* du brouillon courant, l'enregistrement local ne le toucherait pas.
* L'arbitrage est fait par `applyEntryDayAction()`.
*
* Les permissions (`can_edit_draft`, `can_validate`,
* `can_edit_cross_structure`) ne font que masquer et désactiver : le serveur
* refait tous les contrôles.
*/
(() => {
'use strict';
@@ -47,10 +75,12 @@
motifInfo,
} = PTA.utils;
/** Vrai quand lieu, agent et semaine sont tous les trois choisis. */
function isPlanningContextReady() {
return Boolean(structureSelect?.value && agentSelect?.value && weekInput?.value);
}
/** Le PDF agent ne dépend pas du lieu : agent et semaine y suffisent. */
function updateAgentPdfButton() {
if (openAgentPdfButton) {
openAgentPdfButton.disabled = !(agentSelect?.value && weekInput?.value);
@@ -132,10 +162,16 @@
return Number(state.currentAgent?.droit_enveloppe_formation ?? 1) !== 0;
}
/** Seul « Formation » est conditionné à un droit ; tout le reste est ouvert. */
function isMotifAllowedForAgent(motif) {
return agentHasTrainingRight() || String(motif.code).toUpperCase() !== 'FORMATION';
}
/**
* Options du sélecteur de motif d'une plage.
* Les libellés passent par escapeHtml() : ils viennent de la base et sont
* insérés en HTML brut.
*/
function motifOptionsHtml(selectedMotifId = null) {
return (window.PTA_CONFIG?.motifs || [])
// Un motif interdit reste proposé s'il est déjà celui de la plage :
@@ -149,6 +185,14 @@
.join('');
}
/**
* Balisage d'une plage horaire du formulaire : motif, début, fin, suppression.
*
* `step="900"` contraint les champs horaires au quart d'heure, granularité de
* toute l'application. Chaque contrôle porte un `aria-label` nommant le jour
* et le rang de la plage, sans quoi un lecteur d'écran annoncerait des
* dizaines de champs « Début » indiscernables.
*/
function timeRangeHtml(dayLabel, index = 0, selectedMotifId = null) {
const suffix = `${dayLabel} plage ${index + 1}`;
const fallbackMotifId = defaultMotifId();
@@ -176,6 +220,13 @@
</div>`;
}
/**
* Reconstruit le formulaire : une ligne par jour ouvré, avec une plage vide.
*
* Chaque ligne affiche aussi son contexte périscolaire ou extrascolaire,
* déduit du calendrier des vacances renvoyé par l'API : c'est ce qui oriente
* le choix du motif.
*/
function renderWeeklyEntryRows() {
if (!weeklyEntryBody) return;
weeklyEntryBody.innerHTML = '';
@@ -209,6 +260,14 @@
updateEntryControlsState();
}
/**
* ÉTAT → FORMULAIRE. Reconstruit le formulaire puis y réinjecte les créneaux
* de `state.entries`, triés par heure de début.
*
* Reconstruction complète plutôt que mise à jour ciblée : le nombre de
* plages par jour varie, et une réconciliation fine serait plus fragile que
* ce redessin.
*/
function fillWeeklyEntryRowsFromEntries() {
if (!state.weekDays.length || !weeklyEntryBody) return;
@@ -239,6 +298,10 @@
updateEntryControlsState();
}
/**
* Active ou désactive les contrôles de saisie.
* Un planning VALIDÉ est verrouillé : il faut le rouvrir explicitement.
*/
function updateEntryControlsState() {
if (!weeklyEntryBody) return;
const locked = state.planningStatus === 'VALIDE';
@@ -248,6 +311,10 @@
});
}
/**
* Recalcule les jours de la semaine choisie et redessine le formulaire.
* @returns {boolean} false si la semaine saisie est invalide.
*/
function populateDays() {
state.weekDays = parseIsoWeek(weekInput?.value || '');
renderWeeklyEntryRows();
@@ -263,6 +330,11 @@
return false;
}
/**
* Total des minutes qui S'IMPUTENT SUR LE QUOTA, motifs hors quota exclus.
* Le drapeau du référentiel prime ; `compte_dans_quota` du créneau sert de
* repli pour un motif absent de la configuration publiée.
*/
function countedMinutes(entries = state.entries) {
return entries.reduce((total, entry) => {
const motif = motifInfo(entry.motif_id) ?? { quota: Boolean(entry.compte_dans_quota) };
@@ -271,6 +343,15 @@
}, 0);
}
/**
* Met à jour le panneau de quota avec une PROJECTION, et non l'état en base.
*
* Le calcul retire du total serveur les minutes déjà enregistrées pour cette
* semaine (`savedCurrentCountedMinutes`) puis rajoute celles actuellement
* saisies. L'utilisateur voit ainsi l'effet de sa saisie EN COURS, avant
* même d'enregistrer — sans cette soustraction, la semaine courante serait
* comptée deux fois.
*/
function updateQuotaPanel() {
if (!quotaPanel) return;
if (!state.quota) {
@@ -304,12 +385,25 @@
let activeEntryAction = null;
let suppressEntryClickUntil = 0;
/**
* Compare deux créneaux.
* Un créneau enregistré porte un `id_creneau` ; un créneau saisi mais pas
* encore envoyé n'a qu'un `local_id` généré par crypto.randomUUID(). Les
* deux cas doivent être gérés, l'éditeur mêlant les uns et les autres.
*/
function sameEntry(a, b) {
if (!a || !b) return false;
if (a.id_creneau && b.id_creneau) return Number(a.id_creneau) === Number(b.id_creneau);
return String(a.local_id || '') === String(b.local_id || '');
}
/**
* Réunit les créneaux du lieu courant et ceux des autres lieux, en marquant
* ces derniers par `is_external`.
*
* C'est la vue à utiliser pour toute recherche de conflit : l'agent ne peut
* pas être à deux endroits à la fois, quel que soit le lieu saisi.
*/
function allGlobalEntries() {
const currentStructureName = state.currentStructure?.nom
|| structureSelect?.options[structureSelect.selectedIndex]?.textContent?.trim()
@@ -326,6 +420,15 @@
];
}
/**
* Cherche un créneau qui empêcherait de poser celui-ci sur $targetDate.
*
* En DÉPLACEMENT, le créneau lui-même est exclu — il libère sa place ; en
* DUPLICATION il est retenu, la copie entrerait en collision avec lui.
*
* Ce contrôle local ne remplace pas celui du serveur : il évite un
* aller-retour réseau pour un geste manifestement impossible.
*/
function findEntryConflict(entry, targetDate, duplicate) {
const startMinutes = timeToMinutes(entry.start);
const endMinutes = timeToMinutes(entry.end);
@@ -338,6 +441,7 @@
}) || null;
}
/** Résumé « Motif · 09:00–12:00 · Lieu », pour la modale et les aria-label. */
function entryActionSummary(entry) {
const motif = motifInfo(entry.motif_id) ?? { label: entry.motif_label || 'Affectation' };
const location = entry.structure_name
@@ -347,6 +451,11 @@
return `${motif.label} · ${entry.start}–${entry.end} · ${location}`;
}
/**
* Ferme la modale de déplacement/duplication.
* `modal-open` n'est retiré du body que s'il ne reste aucune autre modale
* ouverte, sinon le défilement de la page serait rétabli trop tôt.
*/
function closeEntryActionModal() {
const modal = document.querySelector('#planning-entry-action-modal');
const message = document.querySelector('#planning-entry-action-message');
@@ -385,6 +494,19 @@
targetSelect.focus();
}
/**
* Chemin LOCAL : déplace ou duplique un créneau dans `state.entries`, sans
* appel réseau.
*
* Le clone d'une duplication est débarrassé de son `id_creneau` et de ses
* métadonnées de lieu : c'est un créneau NEUF du planning courant, pas une
* copie d'un créneau externe.
*
* Le message rappelle explicitement qu'il reste à enregistrer : rien n'est
* persisté à ce stade.
*
* @returns {boolean} false en cas de conflit ou de créneau introuvable.
*/
function applyLocalEntryDayAction(entry, targetDate, duplicate) {
const conflict = findEntryConflict(entry, targetDate, duplicate);
if (conflict) {
@@ -427,6 +549,16 @@
return true;
}
/**
* Chemin SERVEUR : envoie le déplacement ou la duplication à l'API.
*
* Subtilité importante — avant de toucher un créneau EXTERNE, le brouillon
* courant est d'abord enregistré. L'appel serveur provoque en effet un
* `loadPlanning()` qui écrase l'état local ; sans cette sauvegarde
* préalable, la saisie en cours serait perdue.
*
* @returns {Promise<boolean>} Succès de l'opération.
*/
async function persistEntryDayAction(entry, targetDate, duplicate) {
if (!entry.id_creneau) {
return applyLocalEntryDayAction(entry, targetDate, duplicate);
@@ -463,6 +595,16 @@
}
}
/**
* Aiguille entre le chemin local et le chemin serveur.
*
* Le serveur est saisi dans deux cas seulement : créneau d'un AUTRE lieu, ou
* planning déjà VALIDÉ. Dans les deux cas, le créneau échappe au brouillon
* courant et l'écriture locale n'aurait aucun effet.
*
* Déposer sur le jour d'origine est un non-événement : on renvoie un succès
* sans rien faire.
*/
async function applyEntryDayAction(entry, targetDate, duplicate = false) {
if (!entry || !targetDate) return false;
if (!duplicate && entry.date === targetDate) {
@@ -483,6 +625,11 @@
: applyLocalEntryDayAction(entry, targetDate, duplicate);
}
/**
* Valide la modale : le bouton est désactivé pendant l'opération pour
* empêcher un double envoi, et rétabli dans un `finally` afin de ne jamais
* rester bloqué en cas d'erreur.
*/
async function submitEntryActionModal(event) {
event.preventDefault();
if (!activeEntryAction) return;
@@ -505,12 +652,20 @@
}
}
/** Retire les marques visuelles de dépôt sur toutes les colonnes. */
function clearDropTargets() {
planningBoard?.querySelectorAll('.day-column').forEach((column) => {
column.classList.remove('is-drop-target', 'is-copy-target');
});
}
/**
* Rend un créneau déplaçable, au clavier comme à la souris.
*
* Un créneau externe n'est interactif que si l'utilisateur a le droit
* `can_edit_cross_structure`. Le `role="button"` et le `tabIndex` assurent
* l'accès clavier : le glisser-déposer seul exclurait ces utilisateurs.
*/
function makeEntryInteractive(item, entry) {
if (!canEditDraft || (entry.is_external && !canEditCrossStructure)) return;
item.classList.add('planning-entry-interactive');
@@ -811,6 +966,17 @@
}
}
/**
* Cherche un chevauchement avec les créneaux connus du navigateur.
*
* @param {Array} additionalEntries Créneaux en cours de construction, pas
* encore dans l'état — c'est ce qui permet de détecter deux plages
* fautives dans une même saisie.
* @param {boolean} includeCurrentEntries false pendant une resynchronisation
* complète, où `state.entries` est sur le point d'être remplacé et ne
* doit pas se comparer à lui-même.
* @returns {object|null} Le créneau en conflit, ou null.
*/
function hasLocalOverlap(date, start, end, additionalEntries = [], includeCurrentEntries = true) {
const startMinutes = timeToMinutes(start);
const endMinutes = timeToMinutes(end);
@@ -827,6 +993,22 @@
)) || null;
}
/**
* FORMULAIRE → ÉTAT. Lit toutes les plages saisies, les valide, et remplace
* `state.entries`.
*
* TOUTES les erreurs sont collectées avant d'être affichées d'un bloc :
* corriger une plage à la fois serait pénible sur une semaine complète. Une
* plage entièrement vide est ignorée sans erreur — c'est une ligne non
* utilisée, pas une saisie incomplète.
*
* L'état n'est remplacé QUE si tout est valide : une saisie partiellement
* fautive laisse l'état intact.
*
* @param {{silent?: boolean}} options `silent` supprime le message de
* succès, pour les appels enchaînés avant un enregistrement.
* @returns {boolean} false si au moins une erreur a été relevée.
*/
function syncEntriesFromWeeklyForm({ silent = false } = {}) {
clearMessage();
@@ -911,10 +1093,26 @@
return true;
}
/**
* Bouton « Mettre à jour le planning » : resynchronise depuis le formulaire.
* Le nom est un héritage de la version où l'on ajoutait les créneaux un par un.
*/
function addEntry() {
syncEntriesFromWeeklyForm();
}
/**
* Enregistre le brouillon de la semaine.
*
* Resynchronise d'abord depuis le formulaire : l'utilisateur peut avoir
* modifié une plage sans cliquer sur « Mettre à jour », et ce qui est à
* l'écran doit être ce qui est enregistré.
*
* Rafraîchit ensuite les modules voisins (file de validation, couverture) en
* mode silencieux, leurs compteurs dépendant de cette semaine.
*
* @returns {Promise<number|null>} Identifiant du planning, ou null en échec.
*/
async function saveDraft({ silent = false } = {}) {
if (!canEditDraft) {
showMessage('Votre rôle ne permet pas de modifier ce planning.', 'warning');
@@ -968,6 +1166,7 @@
}
}
/** Met à plat la liste de contrôles {niveau, code, message} en texte affichable. */
function controlsToText(controls) {
return controls
.filter((control) => control.niveau !== 'INFO')
@@ -975,6 +1174,18 @@
.join('\n');
}
/**
* Demande la validation du planning au serveur.
*
* Trois réponses possibles, alignées sur PlanningController::validate() :
* - 422 : erreurs bloquantes, rien n'est validé ;
* - 409 : quota dépassé — l'utilisateur est invité à confirmer, ce qui
* rappelle cette fonction avec `forceQuota = true` ;
* - 200 : validé.
*
* @param {boolean} forceQuota true uniquement après confirmation explicite
* de l'utilisateur, jamais par défaut.
*/
async function validatePlanning(forceQuota = false) {
if (!canValidate) {
showMessage('Seul le Service Enfance peut valider un planning.', 'warning');
@@ -1021,6 +1232,7 @@
}
}
/** Repasse un planning validé en brouillon pour le corriger. */
async function reopenPlanningForEdit() {
if (!canEditDraft) return;
clearMessage();
@@ -1062,6 +1274,7 @@
}
}
/** Changement de semaine : recalcule les jours puis recharge le planning. */
async function handleWeekSelection() {
clearMessage();
updateAgentPdfButton();
@@ -1073,6 +1286,11 @@
await loadPlanning();
}
/**
* Changement d'agent : réinitialise l'état de saisie.
* Les plages de l'agent précédent ne doivent en aucun cas subsister, elles
* seraient enregistrées sur le nouveau.
*/
function handleAgentChange() {
if (!weekInput) return;
weekInput.disabled = !agentSelect?.value;
@@ -1094,6 +1312,13 @@
}
}
/**
* Clics du formulaire, traités par DÉLÉGATION sur le conteneur.
*
* Les lignes sont redessinées en permanence : attacher des écouteurs à
* chaque bouton obligerait à les reposer après chaque rendu. Un seul
* écouteur sur le parent survit à tous les redessins.
*/
function handleWeeklyEntryClick(event) {
const addRangeButton = event.target.closest('.add-time-range');
if (addRangeButton) {
@@ -1120,6 +1345,10 @@
}
}
/**
* Point d'entrée appelé par page-bootstrap.js : pose les écouteurs et
* l'état initial de l'écran.
*/
function init() {
structureSelect?.addEventListener('change', loadAgents);
agentSelect?.addEventListener('change', handleAgentChange);

View File

@@ -1,7 +1,22 @@
/**
* PTA détaillé d'un agent (`PTA.modules.pta`), écran pta.php.
*
* Affiche la répartition annuelle des temps d'un agent, ses contraintes
* prioritaires (médicales, temps partiel thérapeutique) et ses souhaits de
* lieux, accompagnés des contrôles réglementaires calculés par PtaModel.
*
* Les contrôles suivent la forme commune {niveau, code, message}. Aucun n'est
* bloquant ici : le PTA est un écran d'analyse, l'arbitrage revient à l'agent de
* planification.
*
* Attention à `quotaLabel()` ci-dessous : les durées arrivent sous forme
* d'OBJET et non de chaîne, piège déjà rencontré.
*/
(() => {
'use strict';
const PTA = window.PTA;
// core.js absent : la page reste lisible, simplement non interactive.
if (!PTA) return;
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = PTA.utils;
let current = null;
@@ -9,6 +24,7 @@
const el = (id) => document.getElementById(id);
const selectedAgentId = () => Number(el('pta-agent')?.value || 0);
/** Message dans le bandeau de la page. */
function show(text, type = 'info') {
const box = el('pta-message');
if (!box) return;
@@ -16,6 +32,7 @@
box.textContent = text;
}
/** Vignette de synthèse : libellé, valeur, précision facultative. */
function card(label, value, detail = '') {
return `<article class="summary-card"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong>${detail ? `<small>${escapeHtml(detail)}</small>` : ''}</article>`;
}
@@ -31,6 +48,7 @@
return enriched?.libelle || formatDuration(Number(fallbackMinutes || 0));
}
/** Rend la synthèse : catégories de temps, compteurs et contrôles. */
function renderSummary(data) {
current = data;
const agent = data.agent || {};
@@ -75,6 +93,7 @@
el('pta-constraint-end').value ||= quota.date_fin_periode || '';
}
/** Liste les contraintes prioritaires ; sans date de fin, elles courent indéfiniment. */
function renderConstraints(items) {
el('pta-constraint-list').innerHTML = items.map((item) => `
<article class="stack-item">
@@ -85,6 +104,7 @@
`).join('') || '<div class="empty-state">Aucune contrainte active sur cette période.</div>';
}
/** Liste les souhaits de lieux ; les interdictions sont présentées en premier. */
function renderWishes(items) {
el('pta-wish-list').innerHTML = items.map((item) => `
<article class="stack-item">
@@ -95,6 +115,7 @@
`).join('') || '<div class="empty-state">Aucune préférence ou interdiction enregistrée.</div>';
}
/** Charge le PTA de l'agent et de la date de référence sélectionnés. */
async function load() {
const agentId = selectedAgentId();
const date = el('pta-reference-date')?.value;
@@ -114,6 +135,10 @@
}
}
/**
* POST JSON mutualisé, jeton CSRF inclus d'office : c'est ce qui garantit
* qu'aucune écriture de cet écran ne l'oublie.
*/
async function post(url, payload) {
const response = await fetch(url, {
method: 'POST',
@@ -125,6 +150,10 @@
return data;
}
/**
* Enregistre une contrainte prioritaire, puis recharge le PTA — les
* contrôles en dépendent.
*/
async function saveConstraint(event) {
event.preventDefault();
if (!selectedAgentId()) return;
@@ -147,6 +176,7 @@
} catch (error) { show(error.message, 'error'); }
}
/** Enregistre un souhait de lieu, puis recharge le PTA. */
async function saveWish(event) {
event.preventDefault();
if (!selectedAgentId()) return;
@@ -165,6 +195,11 @@
} catch (error) { show(error.message, 'error'); }
}
/**
* Supprime une contrainte ou un souhait, par délégation. L'action est portée
* par les `data-*` du bouton, les deux listes étant redessinées à chaque
* chargement.
*/
async function handleDelete(event) {
const constraint = event.target.closest('.pta-delete-constraint');
const wish = event.target.closest('.pta-delete-wish');

View File

@@ -1,3 +1,17 @@
/**
* Planning par lieu (`PTA.modules.structureOverview`), écran structures.php.
*
* Vue EN LECTURE SEULE : aucun créneau n'est modifiable ici, la saisie se fait
* dans planning.php. C'est la vue complémentaire de la vue par agent — ici on
* regarde qui est présent sur un lieu, là où va un agent.
*
* La liste d'agents ne se limite pas aux agents rattachés au lieu : elle inclut
* ceux qui y sont ponctuellement planifiés, un agent pouvant travailler sur
* plusieurs lieux la même semaine.
*
* Un rapport de couverture est intégré à la page : il évite d'avoir à ouvrir
* l'écran dédié pour vérifier qu'un lieu est correctement pourvu.
*/
(() => {
'use strict';
@@ -8,6 +22,11 @@
const { escapeHtml, readJsonResponse, apiErrorMessage, parseIsoWeek, formatDate } = PTA.utils;
const typeLabel = (type) => type === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire';
/**
* Balisage d'un créneau : horaires, motif, et la mention « Brouillon » si le
* planning n'est pas validé — la présence n'est alors pas garantie.
* `motif_code` alimente un nom de classe CSS et passe donc par escapeHtml().
*/
function entryBadge(entry) {
return `
<div class="overview-entry motif-${escapeHtml(String(entry.motif_code || 'autre').toLowerCase())}">
@@ -18,6 +37,11 @@
`;
}
/**
* Bandeau de couverture, en trois états : lieu NON CONFIGURÉ (aucune règle,
* donc aucun contrôle possible), couverture complète, ou liste des manques.
* La liste est plafonnée à huit plages, le reste étant dénombré.
*/
function coveragePanel(coverage) {
if (!coverage?.configured) {
return `
@@ -47,6 +71,7 @@
</div>`;
}
/** Rend le tableau agents × jours de la semaine, plus le bandeau de couverture. */
function render(data) {
const container = document.querySelector('#structure-overview-content');
const empty = document.querySelector('#structure-overview-empty');
@@ -118,6 +143,7 @@
`;
}
/** Le PDF du lieu exige un lieu ET une semaine. */
function updatePdfButton() {
if (!openStructurePdfButton) return;
const structureId = document.querySelector('#overview-structure')?.value;
@@ -125,6 +151,7 @@
openStructurePdfButton.disabled = !(structureId && week);
}
/** Ouvre le PDF du lieu dans un nouvel onglet (`noopener` par principe). */
function openPdf() {
const structureId = document.querySelector('#overview-structure')?.value;
const week = document.querySelector('#overview-week')?.value;
@@ -134,6 +161,7 @@
window.open(`api/structure_planning_pdf.php?${params.toString()}`, '_blank', 'noopener');
}
/** Charge et rend la semaine du lieu sélectionné. */
async function load() {
const structureId = document.querySelector('#overview-structure')?.value;
const week = document.querySelector('#overview-week')?.value;

View File

@@ -1,10 +1,26 @@
/**
* Équipes des mercredis et des vacances (`PTA.modules.teams`), écran teams.php.
*
* Une équipe rassemble des agents sur un lieu et une période, avec des règles
* d'encadrement propres à l'accueil collectif de mineurs (effectifs par tranche
* d'âge, taux d'encadrement, proportion d'agents diplômés). Le serveur calcule
* l'effectif requis et les contrôles ; ce module se contente de les afficher.
*
* Les contrôles suivent la forme commune {niveau, code, message}. AUCUN n'est
* bloquant : ils signalent, l'arbitrage reste humain.
*
* La page se recharge intégralement (`load()`) après chaque modification :
* ajouter ou retirer un membre change les contrôles de toute l'équipe.
*/
(() => {
'use strict';
const PTA = window.PTA;
// core.js absent : la page reste lisible, simplement non interactive.
if (!PTA) return;
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils;
const el = (id) => document.getElementById(id);
/** Message dans le bandeau de la page. */
function message(text, type = 'info') {
const box = el('team-list-message');
if (!box) return;
@@ -12,6 +28,10 @@
box.textContent = text;
}
/**
* Appel d'API mutualisé : lit le JSON et transforme toute réponse non-OK en
* exception. Toutes les actions de la page passent par ici.
*/
async function api(url, options = {}) {
const response = await fetch(url, options);
const data = await readJsonResponse(response);
@@ -19,11 +39,13 @@
return data;
}
/** Rend un contrôle, sa classe CSS dérivant de son niveau. */
function controlHtml(control) {
const level = String(control.niveau || 'INFO').toLowerCase();
return `<div class="control-item control-${escapeHtml(level)}"><strong>${escapeHtml(control.niveau)}</strong><span>${escapeHtml(control.message)}</span></div>`;
}
/** Carte complète d'une équipe : paramètres, membres, contrôles, actions. */
function teamHtml(team) {
const options = el('team-agent-options')?.innerHTML || '';
const members = (team.members || []).map((member) => `
@@ -58,6 +80,7 @@
</article>`;
}
/** Recharge toutes les équipes de la période filtrée. */
async function load() {
const start = el('team-filter-start')?.value || '';
const end = el('team-filter-end')?.value || '';
@@ -72,6 +95,7 @@
} catch (error) { message(error.message, 'error'); }
}
/** Crée une équipe (toujours en BROUILLON), puis recharge la liste. */
async function create(event) {
event.preventDefault();
const box = el('team-create-message');
@@ -101,6 +125,10 @@
} catch (error) { box.className = 'message message-error'; box.textContent = error.message; }
}
/**
* Ajout et retrait de membres, par délégation sur la liste des équipes —
* elle est reconstruite à chaque chargement.
*/
async function handle(event) {
const card = event.target.closest('.team-card');
if (!card) return;
@@ -130,6 +158,11 @@
}
}
/**
* Fait avancer une équipe dans son cycle de vie
* (BROUILLON → A_CONTROLER → VALIDEE). Aucune transition n'est imposée : le
* suivi est indicatif, pas un verrou.
*/
async function status(event) {
const select = event.target.closest('.team-status');
if (!select) return;

View File

@@ -1,3 +1,23 @@
/**
* 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';
@@ -7,6 +27,11 @@
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'),
@@ -29,10 +54,16 @@
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;
@@ -62,6 +93,12 @@
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() || '';
@@ -95,6 +132,13 @@
}
}
/**
* 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') || [])
@@ -113,6 +157,10 @@
});
}
/**
* 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',
@@ -129,6 +177,7 @@
return data;
}
/** Enregistre les périodes cochées dans l'aperçu. */
async function saveSelected() {
const el = elements();
const periods = selectedPreviewPeriods();
@@ -149,6 +198,7 @@
}
}
/** Enregistre une période saisie à la main, pour les cas hors API. */
async function saveManual(event) {
event.preventDefault();
const el = elements();
@@ -182,6 +232,7 @@
}
}
/** Rend le tableau des périodes déjà enregistrées, avec leur origine. */
function renderSaved(periods) {
const el = elements();
if (!el.savedBody) return;
@@ -202,6 +253,7 @@
`).join('');
}
/** Recharge les périodes enregistrées en base. */
async function loadSaved() {
const el = elements();
try {
@@ -215,6 +267,11 @@
}
}
/**
* 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);

View File

@@ -1,3 +1,24 @@
/**
* Éditeur de semaine type (`PTA.modules.weekTemplateEditor`, V7.10).
*
* Permet de COMPOSER un modèle lundi→vendredi directement, sans partir d'une
* semaine réellement planifiée. À distinguer de `week-templates.js`, qui
* enregistre et applique les modèles.
*
* MODES EXCLUSIFS DE planning.php — ce module pilote la bascule entre l'éditeur
* de semaine type (ouvert par défaut) et la saisie semaine par semaine, via
* `setMode()`. `page-bootstrap.js` force le second dès que l'URL porte `week` ou
* `focus` : un utilisateur venant corriger un planning identifié doit atterrir
* sur la saisie, pas sur l'éditeur de modèles.
*
* ÉTAT LOCAL — `draft` est une Map indexée par jour (1 à 5), contenant les
* plages en cours d'édition. Elle est indépendante de `PTA.state`, qui décrit la
* semaine réellement planifiée : un modèle n'est pas un planning.
*
* Le DOM fait autorité entre deux rendus : `syncDraftFromDom()` est appelé avant
* toute opération sur `draft`, car l'utilisateur peut avoir tapé dans les champs
* sans qu'aucun événement n'ait été traité.
*/
(() => {
'use strict';
@@ -40,16 +61,22 @@
let currentTemplateId = null;
let loading = false;
/** Message propre à l'éditeur, distinct du bandeau global de la page. */
function setMessage(text = '', type = 'info') {
if (!messageElement) return;
messageElement.className = text ? `message message-${type}` : 'message';
messageElement.textContent = text;
}
/** Agent auquel le modèle appartient ; un modèle est toujours nominatif. */
function agentId() {
return Number(agentSelect?.value || 0) || null;
}
/**
* Lieu proposé par défaut sur une nouvelle plage : celui sélectionné dans
* l'écran, sinon le premier disponible. Une plage sans lieu est refusée.
*/
function defaultStructureId() {
const selected = Number(structureSelect?.value || 0);
if (selected) return selected;
@@ -67,6 +94,7 @@
return Number(state.currentAgent?.droit_enveloppe_formation ?? 1) !== 0;
}
/** Motifs proposables à CET agent, « Formation » exclue s'il n'y a pas droit. */
function motifs() {
return window.PTA_CONFIG?.motifs || [];
}
@@ -79,6 +107,7 @@
return motif ? Number(motif.id) : null;
}
/** Options du sélecteur de motif d'une plage. */
function motifOptionsHtml(selectedMotifId = null) {
return motifs()
// Un motif interdit reste proposé s'il est déjà celui de la plage :
@@ -94,6 +123,11 @@
.join('');
}
/**
* Options du sélecteur de lieu.
* Un modèle peut porter des plages sur PLUSIEURS lieux : c'est ce qui le
* réserve au Service Enfance.
*/
function structureOptionsHtml(selectedStructureId = null) {
return [...(structureSelect?.querySelectorAll('option[value]:not([value=""])') || [])]
.map((option) => {
@@ -103,6 +137,11 @@
.join('');
}
/**
* Cibles de recopie d'un jour : chacun des autres jours, ou tous d'un coup.
* Un agent a souvent le même rythme du lundi au vendredi ; recopier évite de
* ressaisir cinq fois la même chose.
*/
function copyTargetOptionsHtml(weekday) {
const others = DAYS.filter((day) => day.weekday !== weekday);
return [
@@ -112,16 +151,19 @@
].join('');
}
/** Plages d'un jour du brouillon (tableau vide si le jour est libre). */
function rangesFor(weekday) {
return draft.get(weekday) || [];
}
/** Brouillon vierge : les cinq jours présents, tous sans plage. */
function emptyDraft() {
const next = new Map();
DAYS.forEach((day) => next.set(day.weekday, []));
return next;
}
/** Plage neuve, horaires vides mais motif et lieu déjà proposés. */
function newRange() {
return {
heure_debut: '',
@@ -131,15 +173,21 @@
};
}
/** Durée d'une plage ; 0 tant qu'elle est incomplète, jamais négatif. */
function rangeMinutes(range) {
if (!range.heure_debut || !range.heure_fin) return 0;
return Math.max(0, timeToMinutes(range.heure_fin) - timeToMinutes(range.heure_debut));
}
/** Total des minutes d'un jour. */
function dayMinutes(weekday) {
return rangesFor(weekday).reduce((total, range) => total + rangeMinutes(range), 0);
}
/**
* Totaux du modèle : toutes les minutes, et celles qui s'imputeraient sur le
* quota. La distinction compte, un modèle pouvant contenir des congés.
*/
function totals() {
let all = 0;
let counted = 0;
@@ -153,6 +201,7 @@
return { all, counted };
}
/** Balisage d'une plage : motif, lieu, horaires, suppression. */
function rangeHtml(weekday, index, range) {
const label = `${DAYS.find((day) => day.weekday === weekday)?.label || 'Jour'} plage ${index + 1}`;
return `
@@ -182,6 +231,11 @@
</div>`;
}
/**
* Redessine toute la grille depuis `draft`, puis met à jour les totaux.
* Rendu complet plutôt que ciblé : le nombre de plages par jour varie sans
* cesse, et une mise à jour fine serait plus fragile.
*/
function render() {
if (!gridElement) return;
@@ -224,10 +278,15 @@
refreshButtons();
}
/** Vrai si le brouillon contient au moins une plage. */
function hasAnyRange() {
return DAYS.some((day) => rangesFor(day.weekday).length > 0);
}
/**
* Active ou désactive les actions selon l'état : agent choisi, nom saisi,
* modèle non vide, absence d'appel réseau en cours.
*/
function refreshButtons() {
const ready = Boolean(agentId());
const named = Boolean(nameInput?.value.trim());
@@ -242,6 +301,7 @@
if (applyWeekButton) applyWeekButton.disabled = !ready || currentTemplateId === null || loading;
}
/** Remplit le sélecteur des modèles existants de l'agent. */
function renderTemplateSelect() {
if (!templateSelect) return;
const options = ['<option value="">➕ Nouvelle semaine type</option>'];
@@ -253,6 +313,7 @@
templateSelect.innerHTML = options.join('');
}
/** Recharge la liste des modèles de l'agent courant. */
async function loadTemplates() {
const agent = agentId();
if (!agent) {
@@ -274,6 +335,11 @@
}
}
/**
* Charge un modèle existant dans le brouillon pour le modifier.
* Les plages sont réparties par jour dans la Map ; les horaires arrivent en
* HH:MM, format attendu par les champs `type="time"`.
*/
async function openTemplate(templateId) {
const agent = agentId();
if (!agent || !templateId) {
@@ -361,6 +427,10 @@
return null;
}
/**
* Met à plat la Map en la liste attendue par l'API : un objet par plage,
* portant son numéro de jour.
*/
function flatEntries() {
const entries = [];
DAYS.forEach((day) => {
@@ -377,6 +447,12 @@
return entries;
}
/**
* Enregistre le 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`.
*/
async function save(replace = false) {
const agent = agentId();
const name = nameInput?.value.trim() || '';
@@ -430,6 +506,7 @@
}
}
/** Supprime le modèle ouvert, après confirmation. */
async function removeTemplate() {
const agent = agentId();
if (!agent || currentTemplateId === null) return;
@@ -466,6 +543,12 @@
}
}
/**
* Recopie les plages d'un jour vers un autre, ou vers tous les autres.
*
* Les plages sont CLONÉES (`{ ...range }`) : sans cette copie, les jours
* partageraient les mêmes objets et modifier l'un modifierait les autres.
*/
function copyDay(sourceWeekday, target) {
syncDraftFromDom();
const source = rangesFor(sourceWeekday);
@@ -487,6 +570,13 @@
render();
}
/**
* Bascule entre les deux modes exclusifs de planning.php.
* Appelée aussi par `page-bootstrap.js`, qui force le mode « week » sur les
* liens profonds portant une semaine ou un focus.
*
* @param {'template'|'week'} mode
*/
function setMode(mode) {
const isTemplate = mode === 'template';
if (panel) panel.hidden = !isTemplate;
@@ -497,6 +587,11 @@
modeWeekButton?.setAttribute('aria-selected', String(!isTemplate));
}
/**
* Changement d'agent : le brouillon est vidé et les modèles rechargés.
* Un modèle appartient à un seul agent, celui de l'agent précédent n'a plus
* de sens.
*/
function onAgentChanged() {
currentTemplateId = null;
draft = emptyDraft();
@@ -505,6 +600,7 @@
loadTemplates().then(render);
}
/** Point d'entrée appelé par page-bootstrap.js. */
function init() {
draft = emptyDraft();

View File

@@ -1,3 +1,24 @@
/**
* 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';
@@ -41,14 +62,17 @@
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;
@@ -56,12 +80,17 @@
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);
@@ -80,10 +109,16 @@
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;
@@ -93,6 +128,7 @@
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');
@@ -120,6 +156,13 @@
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) {
@@ -173,6 +216,7 @@
}
}
/** Recharge la liste des modèles de l'agent courant. */
async function loadTemplates() {
if (!agentSelect?.value) return [];
@@ -183,6 +227,7 @@
return templates;
}
/** Remplit le sélecteur de la modale d'application. */
function renderTemplateSelect() {
if (!templateSelect) return;
if (!templates.length) {
@@ -200,6 +245,7 @@
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);
@@ -223,6 +269,7 @@
* `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');
@@ -249,6 +296,10 @@
}
}
/**
* 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';
@@ -307,14 +358,23 @@
}
}
/** 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;
@@ -327,6 +387,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();
@@ -335,12 +396,23 @@
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 = '';
@@ -353,6 +425,10 @@
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),
@@ -363,6 +439,7 @@
});
}
/** Remplit le sélecteur de la modale « période ». */
function renderPeriodTemplateSelect() {
if (!periodTemplateSelect) return;
if (!templates.length) {
@@ -376,6 +453,10 @@
)).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;
@@ -387,6 +468,7 @@
}
}
/** 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');
@@ -415,10 +497,23 @@
}
}
/** 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 || {};
@@ -484,6 +579,7 @@
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 || '';
@@ -532,6 +628,11 @@
}
}
/**
* 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');
@@ -591,6 +692,10 @@
}
}
/**
* 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);
@@ -622,6 +727,7 @@
}
}
/** 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';