Files
pta/assets/js/dashboard.js
2026-08-20 13:22:49 +02:00

378 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) => `
<li class="dashboard-list-item">
<div class="dashboard-list-main">
<strong>${escapeHtml(item.agent_prenom)} ${escapeHtml(item.agent_nom)}</strong>
<small>${escapeHtml(item.structure_nom)} · semaine ${Number(item.numero_semaine)} du ${formatDate(item.date_debut)}</small>
</div>
<div class="dashboard-list-meta">
<span>${Number(item.nombre_creneaux)} ${plural(item.nombre_creneaux, 'créneau', 'créneaux')}</span>
<strong>${formatDuration(Number(item.total_minutes || 0))}</strong>
</div>
<button class="button button-success dashboard-list-action" type="button" data-planning-id="${Number(item.id_planning)}">
Contrôler
</button>
</li>
`).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 `
<li class="dashboard-list-item">
<div class="dashboard-list-main">
<strong>${escapeHtml(gap.structure_nom)}</strong>
<small>${escapeHtml(gap.jour_libelle)} ${formatDate(gap.date)} · ${escapeHtml(gap.heure_debut)} → ${escapeHtml(gap.heure_fin)}</small>
</div>
<div class="dashboard-list-meta">
<span>${Number(gap.agents_planifies)} / ${Number(gap.minimum_agents)} agents</span>
<strong class="dashboard-gap-count">−${Number(gap.agents_manquants)}</strong>
</div>
<a class="button button-secondary dashboard-list-action" href="${href}">Analyser</a>
</li>`;
}).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 =
`<strong>Non contrôlés :</strong> ${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 `
<tr class="${over ? 'is-over-quota' : ''}">
<td>
<strong>${escapeHtml(agent.prenom)} ${escapeHtml(agent.nom)}</strong>
<small>${escapeHtml(agent.poste_libelle || 'Poste non renseigné')}</small>
</td>
<td>${escapeHtml(agent.structure_nom || 'Non rattaché')}</td>
<td>
<strong>${formatDuration(Number(agent.quota_cible_minutes || 0))}</strong>
<small>quotité ${Number(agent.quotite_travail)} %</small>
</td>
<td>
<strong>${formatDuration(Number(agent.minutes_affectees || 0))}</strong>
<small>${formatDuration(Number(agent.minutes_brouillon || 0))} en brouillon</small>
</td>
<td class="dashboard-quota-gauge-cell">
<progress class="dashboard-quota-gauge${over ? ' is-over' : ''}" max="100" value="${Math.min(100, Math.max(0, rate))}" aria-hidden="true"></progress>
<span>${rate} %</span>
</td>
<td class="dashboard-quota-action-cell">
<a class="button button-secondary" href="${href}">Ouvrir</a>
</td>
</tr>`;
}).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 };
})();