/** * 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'; const PTA = window.PTA; if (!PTA) throw new Error('PTA core doit être chargé avant dashboard.js'); const { utils } = PTA; const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = utils; const elements = { week: document.querySelector('#dashboard-week'), weekLabel: document.querySelector('#dashboard-week-label'), weekContext: document.querySelector('#dashboard-week-context'), refresh: document.querySelector('#dashboard-refresh'), message: document.querySelector('#dashboard-message'), loading: document.querySelector('#dashboard-loading'), pendingCount: document.querySelector('#dashboard-pending-count'), pendingDetail: document.querySelector('#dashboard-pending-detail'), pendingTile: document.querySelector('#dashboard-tile-pending'), pendingList: document.querySelector('#dashboard-pending-list'), pendingEmpty: document.querySelector('#dashboard-pending-empty'), coverageCount: document.querySelector('#dashboard-coverage-count'), coverageDetail: document.querySelector('#dashboard-coverage-detail'), coverageTile: document.querySelector('#dashboard-tile-coverage'), coverageList: document.querySelector('#dashboard-coverage-list'), coverageEmpty: document.querySelector('#dashboard-coverage-empty'), coverageLink: document.querySelector('#dashboard-coverage-link'), coverageUnconfigured: document.querySelector('#dashboard-coverage-unconfigured'), quotaCount: document.querySelector('#dashboard-quota-count'), quotaDetail: document.querySelector('#dashboard-quota-detail'), quotaTile: document.querySelector('#dashboard-tile-quota'), quotaSubtitle: document.querySelector('#dashboard-quota-subtitle'), quotaAgents: document.querySelector('#dashboard-quota-agents'), quotaEmptyAgents: document.querySelector('#dashboard-quota-empty'), quotaBody: document.querySelector('#dashboard-quota-body'), quotaEmptyState: document.querySelector('#dashboard-quota-empty-state'), setupCount: document.querySelector('#dashboard-setup-count'), setupDetail: document.querySelector('#dashboard-setup-detail'), setupTile: document.querySelector('#dashboard-tile-setup'), navBadge: document.querySelector('#pending-validation-tab-badge'), }; let pendingItems = []; let loading = false; function setMessage(text = '', type = 'info') { if (!elements.message) return; elements.message.className = text ? `message message-${type}` : 'message'; elements.message.textContent = text; } /** * Une tuile est neutre quand il n'y a rien à faire, alertante dès qu'un * compteur remonte. La couleur ne dit rien de plus que le chiffre : elle * évite juste d'avoir à lire les quatre tuiles pour savoir où agir. */ function setTileTone(tile, count) { if (!tile) return; tile.classList.toggle('is-alert', Number(count) > 0); 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); elements.navBadge.hidden = Number(count) === 0; elements.navBadge.setAttribute( 'aria-label', `${count} planning${Number(count) > 1 ? 's' : ''} en attente de validation` ); } /** * 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 = { PERISCOLAIRE: 'Semaine scolaire — contexte périscolaire', EXTRASCOLAIRE: 'Semaine de vacances — contexte extrascolaire', MIXTE: 'Semaine à cheval entre période scolaire et vacances', }[week.contexte] || ''; if (elements.weekLabel) { elements.weekLabel.textContent = `Semaine ${week.numero} · du ${formatDate(week.date_debut)} au ${formatDate(week.date_fin)}`; } if (elements.weekContext) { const vacances = Array.isArray(week.vacances_libelles) ? week.vacances_libelles : []; const suffix = vacances.length ? ` (${vacances.join(', ')})` : ''; elements.weekContext.textContent = contexteLabel ? `${contexteLabel}${suffix}` : ''; elements.weekContext.hidden = !contexteLabel; } if (elements.coverageLink) { elements.coverageLink.href = `coverage.php?week=${encodeURIComponent(week.valeur)}`; } if (elements.coverageTile) { elements.coverageTile.href = `coverage.php?week=${encodeURIComponent(week.valeur)}`; } } /** * 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 : []; setNavBadge(count); setTileTone(elements.pendingTile, count); if (elements.pendingCount) elements.pendingCount.textContent = String(count); if (elements.pendingDetail) { elements.pendingDetail.textContent = count === 0 ? 'Aucun brouillon en attente' : `${formatDuration(Number(validation.minutes_decomptees || 0))} décomptées · ` + `${validation.agent_count} ${plural(validation.agent_count, 'agent', 'agents')} · ` + `${validation.structure_count} ${plural(validation.structure_count, 'lieu', 'lieux')}`; } if (elements.pendingEmpty) elements.pendingEmpty.hidden = count !== 0; if (!elements.pendingList) return; elements.pendingList.innerHTML = pendingItems.map((item) => `
  • ${escapeHtml(item.agent_prenom)} ${escapeHtml(item.agent_nom)} ${escapeHtml(item.structure_nom)} · semaine ${Number(item.numero_semaine)} du ${formatDate(item.date_debut)}
    ${Number(item.nombre_creneaux)} ${plural(item.nombre_creneaux, 'créneau', 'créneaux')} ${formatDuration(Number(item.total_minutes || 0))}
  • `).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); // Sans aucun lieu enregistré, « tous les minimums sont atteints » serait // vrai mais trompeur : il n'y a simplement rien à contrôler. const sansLieu = Number(perimetre?.structures || 0) === 0; setTileTone(elements.coverageTile, gapCount); setTileTone(elements.setupTile, unconfigured); if (elements.coverageCount) elements.coverageCount.textContent = String(gapCount); if (elements.coverageDetail) { elements.coverageDetail.textContent = gapCount === 0 ? (sansLieu ? 'Aucun lieu d’affectation enregistré' : 'Tous les minimums sont atteints') : `${formatDuration(Number(coverage.gap_minutes || 0))} non couvertes · ` + `${coverage.structure_count} ${plural(coverage.structure_count, 'lieu concerné', 'lieux concernés')}`; } if (elements.setupCount) elements.setupCount.textContent = String(unconfigured); if (elements.setupDetail) { elements.setupDetail.textContent = unconfigured === 0 ? (sansLieu ? 'Aucun lieu à paramétrer pour l’instant' : 'Tous les lieux ont des horaires attendus') : 'Aucun minimum d’agents n’y est contrôlé'; } const items = Array.isArray(coverage.apercu) ? coverage.apercu : []; if (elements.coverageEmpty) elements.coverageEmpty.hidden = items.length !== 0; if (elements.coverageList) { const weekValue = week?.valeur || ''; elements.coverageList.innerHTML = items.map((gap) => { const href = `coverage.php?week=${encodeURIComponent(weekValue)}&structure_id=${Number(gap.id_structure)}`; return `
  • ${escapeHtml(gap.structure_nom)} ${escapeHtml(gap.jour_libelle)} ${formatDate(gap.date)} · ${escapeHtml(gap.heure_debut)} → ${escapeHtml(gap.heure_fin)}
    ${Number(gap.agents_planifies)} / ${Number(gap.minimum_agents)} agents −${Number(gap.agents_manquants)}
    Analyser
  • `; }).join(''); } if (elements.coverageUnconfigured) { const list = Array.isArray(coverage.unconfigured) ? coverage.unconfigured : []; if (list.length === 0) { elements.coverageUnconfigured.hidden = true; elements.coverageUnconfigured.textContent = ''; } else { const names = list.map((structure) => escapeHtml(structure.nom)).join(', '); const reste = unconfigured - list.length; elements.coverageUnconfigured.innerHTML = `Non contrôlés : ${names}${reste > 0 ? ` et ${reste} autre${reste > 1 ? 's' : ''}` : ''}. ` + 'Définissez leurs horaires de couverture dans l’administration.'; elements.coverageUnconfigured.hidden = false; } } } /** * 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); if (elements.quotaCount) elements.quotaCount.textContent = String(overCount); if (elements.quotaDetail) { elements.quotaDetail.textContent = overCount === 0 ? (Number(quota.agent_count || 0) === 0 ? 'Aucun agent actif à suivre' : `Aucun dépassement sur ${quota.annee}`) : `Sur ${quota.agent_count} ${plural(quota.agent_count, 'agent suivi', 'agents suivis')} en ${quota.annee}`; } if (elements.quotaTile) { elements.quotaTile.href = `annual.php?year=${Number(quota.annee)}`; } if (elements.quotaSubtitle) { elements.quotaSubtitle.textContent = `Année civile ${quota.annee} · heures validées et brouillon comprises, motifs décomptés uniquement.`; } if (elements.quotaAgents) elements.quotaAgents.textContent = String(quota.agent_count || 0); if (elements.quotaEmptyAgents) elements.quotaEmptyAgents.textContent = String(quota.sans_heure_count || 0); const items = Array.isArray(quota.apercu) ? quota.apercu : []; if (elements.quotaEmptyState) elements.quotaEmptyState.hidden = items.length !== 0; if (!elements.quotaBody) return; elements.quotaBody.innerHTML = items.map((agent) => { const over = Number(agent.minutes_restantes) < 0; const rate = Number(agent.taux_consommation || 0); const href = `annual.php?agent_id=${Number(agent.id_agent)}&year=${Number(quota.annee)}`; return ` ${escapeHtml(agent.prenom)} ${escapeHtml(agent.nom)} ${escapeHtml(agent.poste_libelle || 'Poste non renseigné')} ${escapeHtml(agent.structure_nom || 'Non rattaché')} ${formatDuration(Number(agent.quota_cible_minutes || 0))} quotité ${Number(agent.quotite_travail)} % ${formatDuration(Number(agent.minutes_affectees || 0))} ${formatDuration(Number(agent.minutes_brouillon || 0))} en brouillon ${rate} % Ouvrir `; }).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({ structure_id: String(item.id_structure), agent_id: String(item.id_agent), week: weekValueOf(item), focus: 'validation', notice: `Planning de ${item.agent_prenom} ${item.agent_nom} ouvert depuis le tableau de bord.`, }); 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; if (!options.silent) setMessage(''); if (elements.loading) elements.loading.hidden = false; if (elements.refresh) elements.refresh.disabled = true; try { const query = new URLSearchParams(); const week = weekParam(); if (week) query.set('week', week); const response = await fetch(`api/dashboard_summary.php?${query.toString()}`, { headers: { Accept: 'application/json' }, }); const data = await readJsonResponse(response); if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger le tableau de bord.')); renderWeek(data.semaine); renderPending(data.validation || {}); renderCoverage(data.couverture || {}, data.semaine, data.perimetre); renderQuota(data.quota || {}); } catch (error) { setMessage(error.message, 'error'); } finally { loading = false; if (elements.loading) elements.loading.hidden = true; if (elements.refresh) elements.refresh.disabled = false; } } function init() { elements.refresh?.addEventListener('click', () => load()); elements.week?.addEventListener('change', () => load()); elements.pendingList?.addEventListener('click', (event) => { const trigger = event.target.closest('[data-planning-id]'); if (!trigger) return; const id = Number(trigger.dataset.planningId); openPendingPlanning(pendingItems.find((item) => Number(item.id_planning) === id)); }); load({ silent: true }); } PTA.modules.dashboard = { init, load, refresh: load }; })();