Files
pta/assets/js/pending-validation.js

340 lines
16 KiB
JavaScript

/**
* 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 `<strong>${escapeHtml(structures[0].nom)}</strong>
<small>${escapeHtml(structureTypeLabel(structures[0].type))}</small>`;
}
return `<strong>${structures.length} lieux</strong>
<small>${escapeHtml(structures.map((structure) => structure.nom).join(' · '))}</small>`;
}
/** Une semaine du détail déplié. */
function weekRow(item) {
const week = weekValue(item);
const rowId = Number(item.id_planning);
return `
<li class="pending-week-item" data-planning-id="${rowId}">
<div class="pending-week-main">
<strong>Semaine ${Number(item.numero_semaine)}</strong>
<small>${formatDate(item.date_debut)} → ${formatDate(item.date_fin_ouvrable || item.date_fin)}</small>
</div>
<div class="pending-week-structure">
<strong>${escapeHtml(item.structure_nom)}</strong>
<small>${escapeHtml(structureTypeLabel(item.structure_type_affectation))}</small>
</div>
<div class="pending-week-volume">
<strong>${formatDuration(Number(item.total_minutes || 0))}</strong>
<small>${Number(item.nombre_creneaux)} créneau(x) · ${formatDuration(Number(item.minutes_decomptees || 0))} décomptées</small>
</div>
<div class="pending-week-modified">
<small>Modifié le ${formatDateTime(item.date_modification)}</small>
</div>
<div class="pending-week-action">
<button class="button button-success open-pending-planning" type="button" data-planning-id="${rowId}"
aria-label="Ouvrir le planning de ${escapeHtml(item.agent_prenom)} ${escapeHtml(item.agent_nom)} pour la semaine ${escapeHtml(week)}">Consulter et valider</button>
</div>
</li>`;
}
/** 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 `
<tr class="pending-validation-row${expanded ? ' is-expanded' : ''}" data-agent-id="${group.id_agent}"
tabindex="0" role="button" aria-expanded="${expanded ? 'true' : 'false'}"
aria-label="${expanded ? 'Replier' : 'Déplier'} les ${weeks} semaine(s) à valider de ${identite}">
<td>
<strong>${identite}</strong>
<small>${escapeHtml(group.matricule)}</small>
</td>
<td>${structureCell(group)}</td>
<td>
<span class="pending-count-chip">${weeks}</span>
<small>${group.nombre_creneaux} créneau(x)</small>
</td>
<td>
<strong>${formatDuration(group.total_minutes)}</strong>
<small>${formatDuration(group.minutes_decomptees)} décomptées</small>
</td>
<td>${formatDateTime(group.date_modification)}</td>
<td class="pending-validation-action-cell">
<span class="pending-toggle-hint">${expanded ? 'Masquer le détail' : 'Voir le détail'}</span>
<span class="pending-toggle-chevron" aria-hidden="true">${expanded ? '▲' : '▼'}</span>
</td>
</tr>
<tr class="pending-validation-detail-row" data-agent-detail-id="${group.id_agent}"${expanded ? '' : ' hidden'}>
<td colspan="6">
<ul class="pending-week-list">${group.plannings.map(weekRow).join('')}</ul>
</td>
</tr>`;
}
/** 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 };
})();