/** * File des plannings à valider (`PTA.modules.pendingValidation`), écran pending.php. * * L'API renvoie un brouillon NON VIDE par ligne, du plus ancien au plus récent * — l'ordre de traitement attendu. Le module REGROUPE ces lignes PAR AGENT : * une ligne par agent (nom, lieu d'affectation, nombre de semaines), dépliable * sur le détail semaine par semaine. C'est le seul niveau de regroupement : * un agent planifié sur plusieurs lieux la même période reste une seule ligne, * la colonne « lieu » énumère alors ses lieux. * * L'ordre des agents suit celui de l'API : le premier agent est celui qui porte * le brouillon le plus ancien. Ouvrir une semaine 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. Les agents dépliés le restent d'un * rafraîchissement à l'autre (`expandedAgents`), sans quoi une validation * refermerait la liste sous les yeux de l'utilisateur. * * 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'; const PTA = window.PTA; if (!PTA) throw new Error('PTA core doit être chargé avant pending-validation.js'); const { utils } = PTA; const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = utils; const badge = document.querySelector('#pending-validation-tab-badge'); const refreshButton = document.querySelector('#refresh-pending-validation'); const countElement = document.querySelector('#pending-validation-count'); const agentsElement = document.querySelector('#pending-validation-agents'); const hoursElement = document.querySelector('#pending-validation-hours'); const countedHoursElement = document.querySelector('#pending-validation-counted-hours'); const loadingElement = document.querySelector('#pending-validation-loading'); const emptyElement = document.querySelector('#pending-validation-empty'); const contentElement = document.querySelector('#pending-validation-content'); const bodyElement = document.querySelector('#pending-validation-body'); const messageElement = document.querySelector('#pending-validation-message'); let items = []; let groups = []; let loading = false; /** Agents dépliés, conservés entre deux rendus. */ const expandedAgents = new Set(); /** 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'); const date = new Date(normalized); if (Number.isNaN(date.getTime())) return escapeHtml(value); return date.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } /** 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); badge.hidden = count === 0; badge.setAttribute('aria-label', `${count} planning${count > 1 ? 's' : ''} en attente de validation`); } function setMessage(text = '', type = 'info') { if (!messageElement) return; messageElement.className = text ? `message message-${type}` : 'message'; messageElement.textContent = text; } /** * Regroupe les plannings par agent en conservant l'ordre d'arrivée : le * premier groupe est donc celui du brouillon le plus ancien, et à * l'intérieur d'un groupe les semaines restent chronologiques. */ function groupByAgent(list) { const byAgent = new Map(); list.forEach((item) => { const agentId = Number(item.id_agent); let group = byAgent.get(agentId); if (!group) { group = { id_agent: agentId, agent_nom: item.agent_nom, agent_prenom: item.agent_prenom, matricule: item.matricule || '', structures: new Map(), plannings: [], total_minutes: 0, minutes_decomptees: 0, nombre_creneaux: 0, date_modification: null, }; byAgent.set(agentId, group); } group.plannings.push(item); group.total_minutes += Number(item.total_minutes || 0); group.minutes_decomptees += Number(item.minutes_decomptees || 0); group.nombre_creneaux += Number(item.nombre_creneaux || 0); if (!group.structures.has(Number(item.id_structure))) { group.structures.set(Number(item.id_structure), { nom: item.structure_nom, type: item.structure_type_affectation, }); } if (!group.date_modification || String(item.date_modification) > String(group.date_modification)) { group.date_modification = item.date_modification; } }); return [...byAgent.values()]; } /** Colonne « lieu » : le lieu unique, ou l'énumération des lieux concernés. */ function structureCell(group) { const structures = [...group.structures.values()]; if (structures.length === 1) { return `${escapeHtml(structures[0].nom)} ${escapeHtml(structureTypeLabel(structures[0].type))}`; } return `${structures.length} lieux ${escapeHtml(structures.map((structure) => structure.nom).join(' · '))}`; } /** Une semaine du détail déplié. */ function weekRow(item) { const week = weekValue(item); const rowId = Number(item.id_planning); return `
  • Semaine ${Number(item.numero_semaine)} ${formatDate(item.date_debut)} → ${formatDate(item.date_fin_ouvrable || item.date_fin)}
    ${escapeHtml(item.structure_nom)} ${escapeHtml(structureTypeLabel(item.structure_type_affectation))}
    ${formatDuration(Number(item.total_minutes || 0))} ${Number(item.nombre_creneaux)} créneau(x) · ${formatDuration(Number(item.minutes_decomptees || 0))} décomptées
    Modifié le ${formatDateTime(item.date_modification)}
  • `; } /** Ligne d'agent + ligne de détail associée (masquée si l'agent est replié). */ function agentRows(group) { const expanded = expandedAgents.has(group.id_agent); const weeks = group.plannings.length; const identite = `${escapeHtml(group.agent_prenom)} ${escapeHtml(group.agent_nom)}`; return ` ${identite} ${escapeHtml(group.matricule)} ${structureCell(group)} ${weeks} ${group.nombre_creneaux} créneau(x) ${formatDuration(group.total_minutes)} ${formatDuration(group.minutes_decomptees)} décomptées ${formatDateTime(group.date_modification)} ${expanded ? 'Masquer le détail' : 'Voir le détail'} `; } /** Rend le tableau de la file et ses compteurs de synthèse. */ function render(data) { items = Array.isArray(data.plannings) ? data.plannings : []; groups = groupByAgent(items); const summary = data.summary || {}; const count = Number(summary.count ?? items.length); // Un agent replié entre-temps ne doit pas rester dans le jeu d'agents // dépliés : ses plannings ont pu être validés depuis. const presents = new Set(groups.map((group) => group.id_agent)); [...expandedAgents].forEach((agentId) => { if (!presents.has(agentId)) expandedAgents.delete(agentId); }); setBadge(count); if (countElement) countElement.textContent = String(count); if (agentsElement) agentsElement.textContent = String(groups.length); if (hoursElement) hoursElement.textContent = formatDuration(Number(summary.total_minutes || 0)); if (countedHoursElement) countedHoursElement.textContent = formatDuration(Number(summary.counted_minutes || 0)); if (loadingElement) loadingElement.hidden = true; if (emptyElement) emptyElement.hidden = items.length !== 0; if (contentElement) contentElement.hidden = items.length === 0; if (!bodyElement) return; bodyElement.innerHTML = groups.map(agentRows).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; if (!options.silent) setMessage(''); if (loadingElement) { loadingElement.hidden = false; if (emptyElement) emptyElement.hidden = true; if (contentElement) contentElement.hidden = true; } if (refreshButton) refreshButton.disabled = true; try { const response = await fetch('api/pending_plannings.php', { headers: { Accept: 'application/json' } }); const data = await readJsonResponse(response); if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les plannings en attente de validation.')); render(data); } catch (error) { if (loadingElement) loadingElement.hidden = true; setMessage(error.message, 'error'); } finally { loading = false; if (refreshButton) refreshButton.disabled = false; } } /** * 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({ structure_id: String(item.id_structure), agent_id: String(item.id_agent), week: weekValue(item), focus: 'validation', notice: `Planning de ${item.agent_prenom} ${item.agent_nom} chargé depuis la file de validation.`, }); window.location.href = `planning.php?${params.toString()}`; } /** Déplie ou replie un agent, sans recharger la file. */ function toggleAgent(agentId, row) { const expanded = expandedAgents.has(agentId); if (expanded) expandedAgents.delete(agentId); else expandedAgents.add(agentId); const group = groups.find((candidate) => candidate.id_agent === agentId); const identite = group ? `${group.agent_prenom} ${group.agent_nom}` : ''; const weeks = group ? group.plannings.length : 0; row.classList.toggle('is-expanded', !expanded); row.setAttribute('aria-expanded', expanded ? 'false' : 'true'); row.setAttribute('aria-label', `${expanded ? 'Déplier' : 'Replier'} les ${weeks} semaine(s) à valider de ${identite}`); const hint = row.querySelector('.pending-toggle-hint'); if (hint) hint.textContent = expanded ? 'Voir le détail' : 'Masquer le détail'; const chevron = row.querySelector('.pending-toggle-chevron'); if (chevron) chevron.textContent = expanded ? '▼' : '▲'; const detail = bodyElement?.querySelector(`[data-agent-detail-id="${agentId}"]`); if (detail) detail.hidden = expanded; } /** * Retrouve le planning correspondant à l'élément cliqué, via le * `data-planning-id` porté par la semaine. 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; const id = Number(trigger.dataset.planningId); return items.find((item) => Number(item.id_planning) === id) || null; } function init() { refreshButton?.addEventListener('click', () => refresh()); bodyElement?.addEventListener('click', (event) => { const item = findItemFromEventTarget(event.target); if (item) { openPlanning(item); return; } const row = event.target.closest('.pending-validation-row'); if (row) toggleAgent(Number(row.dataset.agentId), row); }); bodyElement?.addEventListener('keydown', (event) => { if (!['Enter', ' '].includes(event.key)) return; if (event.target.closest('button')) return; const row = event.target.closest('.pending-validation-row'); if (!row) return; event.preventDefault(); toggleAgent(Number(row.dataset.agentId), row); }); refresh({ silent: true }); } PTA.modules.pendingValidation = { init, refresh, openPlanning }; })();