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>
330 lines
16 KiB
JavaScript
330 lines
16 KiB
JavaScript
(() => {
|
||
'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 };
|
||
})();
|