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