Files
pta/assets/js/dashboard.js
PTA 13ee16e25d Démarrage de la pile par un simple docker compose up
La pile de test de la branche ne démarrait pas : « Connexion à la base
impossible ». Trois causes cumulées, toutes corrigées.

1. PTA_DB_PORT et DB_PORT étaient passés à 9306. Ces variables désignent le
   port joint À L'INTÉRIEUR du réseau Docker, où MariaDB écoute sur 3306, et
   non un port publié sur l'hôte : la connexion était refusée. Elles reviennent
   à 3306, et le port hôte 9306 est désormais réellement publié par le service
   db (sur la boucle locale), pour un client SQL externe.

2. Le service db-migrate manquait sur la branche. Le schéma d'initialisation ne
   contient aucune des tables et vues d'accès V7.8 : sur un volume neuf,
   l'application refusait de démarrer. db-migrate applique maintenant tous les
   database/migration_*.sql, dont celle du tableau de bord V7.9 — plus aucune
   étape SQL manuelle.

3. Le défaut de PTA_DB_PASS (change_me) différait de celui de MARIADB_PASSWORD
   (pta). Sans fichier .env — et ce répertoire n'en a pas, il est ignoré par
   git — l'authentification échouait. Les deux défauts sont alignés.

Le bandeau du tableau de bord ne prétend plus que « tous les lieux ont des
horaires attendus » quand aucun lieu n'est enregistré.

Vérifié : volume détruit puis « docker compose up -d --build » seul suffit à
obtenir les onze pages en 200, l'atterrissage sur le tableau de bord et les deux
migrations appliquées ; un second up les ignore ; la base répond sur
127.0.0.1:9306 ; la pile principale sur 8080 est restée intacte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:53:19 +02:00

335 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, 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.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, 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 };
})();