Files
pta/assets/js/dashboard.js
PTA b68ac15de5 V7.9 — Tableau de bord et navigation regroupée pour le Service Enfance
Le Service Enfance ouvrait onze onglets à plat et atterrissait sur l'écran de
saisie, alors que son travail commence par le contrôle. Les chiffres qui
déclenchent une action vivaient sur trois écrans distincts.

- Nouvelle page dashboard.php, réservée au Service Enfance et devenue sa page
  d'atterrissage : quatre tuiles d'action, les brouillons les plus anciens avec
  ouverture directe sur la bonne semaine, les plages de couverture les plus
  déficitaires, et le suivi des quotas annuels par agent.
- Navigation regroupée par intention (Piloter / Planifier / Consulter /
  Paramétrer). Les rôles à quelques pages gardent la barre simple.
- Sous-titre propre à chaque page : le bandeau générique n'apprenait rien.
- Pastille « À valider » alimentée depuis toutes les pages, et non plus
  seulement depuis la page qu'elle annonce.

Aucune règle métier n'est modifiée : le tableau de bord relit ce que
PlanningModel, CoverageModel et QuotaModel calculent déjà, et son endpoint est
gardé par requireService(). Le droit d'accès à la page vient de la migration
V7.9 ; sans elle, l'onglet n'apparaît pas et homeUrl() se replie sur planning.

Vérifié sur la base réelle : les chiffres de quota du tableau de bord sont
identiques à ceux de api/annual_overview.php, les trois rôles rendent leurs
écrans sans erreur, le rendu JS a été exécuté dans un DOM avec la réponse réelle
de l'API, et la page ne produit aucun attribut style inline (CSP).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:48:50 +02:00

330 lines
16 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.
(() => {
'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'),
quotaRate: document.querySelector('#dashboard-quota-rate'),
quotaRateDetail: document.querySelector('#dashboard-quota-rate-detail'),
quotaRemaining: document.querySelector('#dashboard-quota-remaining'),
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);
}
function plural(count, singular, pluralForm) {
return Number(count) > 1 ? pluralForm : singular;
}
function weekParam() {
return elements.week?.value || '';
}
function weekValueOf(item) {
return `${item.annee}-W${String(item.numero_semaine).padStart(2, '0')}`;
}
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`
);
}
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)}`;
}
}
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('');
}
function renderCoverage(coverage, week) {
const gapCount = Number(coverage.gap_count || 0);
const unconfigured = Number(coverage.unconfigured_count || 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
? '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
? '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;
}
}
}
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
? `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.quotaRate) elements.quotaRate.textContent = `${Number(quota.taux_global || 0)} %`;
if (elements.quotaRateDetail) {
elements.quotaRateDetail.textContent =
`${formatDuration(Number(quota.minutes_affectees || 0))} sur ${formatDuration(Number(quota.minutes_cible || 0))}`;
}
if (elements.quotaRemaining) elements.quotaRemaining.textContent = formatDuration(Number(quota.minutes_restantes || 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('');
}
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()}`;
}
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);
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 };
})();