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,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);