Ajout des commentaires dans le code
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user