V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation
Reprise dans le répertoire de production du travail mené dans le worktree ux-service-enfance : - tableau de bord Service Enfance (dashboard.php) et navigation regroupée ; - guide d'utilisation (faq.php) accessible depuis toute la navigation ; - éditeur de semaine type mis en avant sur l'écran de saisie ; - droit à l'enveloppe de formation par agent, en remplacement de la case « formation déjà prévue dans la répartition annuelle » ; - correctif de « [object Object] » sur les cartes de quota du PTA ; - motif de travail par défaut à la saisie, heures travaillées complètes dans les PDF. Le docker-compose.yml de production est conservé : celui du worktree décrit une pile de test (conteneurs et volume suffixés « test », ports 9080/9081), qui aurait fait redémarrer l'application sur un volume MariaDB vide. Les migrations database/migration_v7_9*.sql sont obligatoires ; le service db-migrate les applique au déploiement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
325
assets/js/dashboard.js
Normal file
325
assets/js/dashboard.js
Normal file
@@ -0,0 +1,325 @@
|
||||
(() => {
|
||||
'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);
|
||||
}
|
||||
|
||||
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, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
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, 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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user