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

206 lines
9.5 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.
/**
* Planning par lieu (`PTA.modules.structureOverview`), écran structures.php.
*
* Vue EN LECTURE SEULE : aucun créneau n'est modifiable ici, la saisie se fait
* dans planning.php. C'est la vue complémentaire de la vue par agent — ici on
* regarde qui est présent sur un lieu, là où va un agent.
*
* La liste d'agents ne se limite pas aux agents rattachés au lieu : elle inclut
* ceux qui y sont ponctuellement planifiés, un agent pouvant travailler sur
* plusieurs lieux la même semaine.
*
* Un rapport de couverture est intégré à la page : il évite d'avoir à ouvrir
* l'écran dédié pour vérifier qu'un lieu est correctement pourvu.
*/
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant structure-overview.js');
const { openStructurePdfButton } = PTA.elements;
const { escapeHtml, readJsonResponse, apiErrorMessage, parseIsoWeek, formatDate } = PTA.utils;
const typeLabel = (type) => type === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire';
/**
* Balisage d'un créneau : horaires, motif, et la mention « Brouillon » si le
* planning n'est pas validé — la présence n'est alors pas garantie.
* `motif_code` alimente un nom de classe CSS et passe donc par escapeHtml().
*/
function entryBadge(entry) {
return `
<div class="overview-entry motif-${escapeHtml(String(entry.motif_code || 'autre').toLowerCase())}">
<strong>${escapeHtml(entry.heure_debut)}–${escapeHtml(entry.heure_fin)}</strong>
<span>${escapeHtml(entry.motif_libelle)}</span>
${entry.statut === 'BROUILLON' ? '<em>Brouillon</em>' : ''}
</div>
`;
}
/**
* Bandeau de couverture, en trois états : lieu NON CONFIGURÉ (aucune règle,
* donc aucun contrôle possible), couverture complète, ou liste des manques.
* La liste est plafonnée à huit plages, le reste étant dénombré.
*/
function coveragePanel(coverage) {
if (!coverage?.configured) {
return `
<div class="coverage-inline-notice coverage-inline-info">
<strong>Couverture non configurée</strong>
<span>Définissez les horaires de présence minimale dans Administration pour détecter automatiquement les créneaux vides.</span>
</div>`;
}
const gaps = Array.isArray(coverage.gaps) ? coverage.gaps : [];
if (!gaps.length) {
return `
<div class="coverage-inline-notice coverage-inline-success">
<strong>Couverture complète</strong>
<span>Aucun manque d’agent détecté sur les plages de couverture configurées.</span>
</div>`;
}
const items = gaps.slice(0, 8).map((gap) => `
<li><strong>${escapeHtml(gap.jour_libelle)} ${escapeHtml(gap.heure_debut)}–${escapeHtml(gap.heure_fin)}</strong> : ${Number(gap.agents_manquants)} agent${Number(gap.agents_manquants) > 1 ? 's' : ''} manquant${Number(gap.agents_manquants) > 1 ? 's' : ''}</li>
`).join('');
const more = gaps.length > 8 ? `<li>… et ${gaps.length - 8} autre${gaps.length - 8 > 1 ? 's' : ''} plage${gaps.length - 8 > 1 ? 's' : ''}.</li>` : '';
return `
<div class="coverage-inline-notice coverage-inline-danger">
<strong>⚠ ${gaps.length} plage${gaps.length > 1 ? 's' : ''} insuffisamment couverte${gaps.length > 1 ? 's' : ''}</strong>
<ul>${items}${more}</ul>
</div>`;
}
/** Rend le tableau agents × jours de la semaine, plus le bandeau de couverture. */
function render(data) {
const container = document.querySelector('#structure-overview-content');
const empty = document.querySelector('#structure-overview-empty');
const days = parseIsoWeek(data.week.value);
if (!container || !empty) return;
empty.hidden = true;
container.hidden = false;
if (!data.agents.length) {
container.innerHTML = `
<div class="overview-title-row">
<div>
<h3>${escapeHtml(data.structure.nom)}</h3>
<p>${typeLabel(data.structure.type_affectation)} · semaine du ${formatDate(days[0]?.date)} au ${formatDate(days[days.length - 1]?.date)}</p>
</div>
<span class="overview-count">0 agent</span>
</div>
${coveragePanel(data.coverage)}
<div class="empty-state">Aucun agent n’est rattaché ou planifié sur ce lieu pour cette semaine.</div>`;
return;
}
const headerDays = days.map((day) => `<th><strong>${day.label}</strong><span>${day.display}</span></th>`).join('');
const rows = data.agents.map((agent) => {
const cells = days.map((day) => {
const entries = agent.entries.filter((entry) => entry.date_jour === day.date);
return `<td>${entries.length ? entries.map((entry) => entryBadge(entry)).join('') : '<span class="cell-empty">—</span>'}</td>`;
}).join('');
return `
<tr>
<th class="agent-cell">
<strong>${escapeHtml(agent.prenom)} ${escapeHtml(agent.nom)}</strong>
<span>${escapeHtml(agent.matricule)}</span>
<small>${agent.lieu_principal ? 'Lieu principal' : 'Affectation ponctuelle'}</small>
</th>
${cells}
<td class="quota-cell">
<strong>${escapeHtml(agent.quota.restantes.libelle)}</strong>
<span>sur ${escapeHtml(agent.quota.quota_cible.libelle)}</span>
<small>${Number(agent.quota.quotite_travail).toLocaleString('fr-FR')} %</small>
</td>
</tr>
`;
}).join('');
container.innerHTML = `
<div class="overview-title-row">
<div>
<h3>${escapeHtml(data.structure.nom)}</h3>
<p>${typeLabel(data.structure.type_affectation)} · semaine du ${formatDate(days[0]?.date)} au ${formatDate(days[days.length - 1]?.date)}</p>
</div>
<span class="overview-count">${data.agents.length} agent${data.agents.length > 1 ? 's' : ''}</span>
</div>
${coveragePanel(data.coverage)}
<div class="table-scroll">
<table class="planning-table">
<thead>
<tr>
<th>Agent</th>
${headerDays}
<th>Quota restant</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
/** Le PDF du lieu exige un lieu ET une semaine. */
function updatePdfButton() {
if (!openStructurePdfButton) return;
const structureId = document.querySelector('#overview-structure')?.value;
const week = document.querySelector('#overview-week')?.value;
openStructurePdfButton.disabled = !(structureId && week);
}
/** Ouvre le PDF du lieu dans un nouvel onglet (`noopener` par principe). */
function openPdf() {
const structureId = document.querySelector('#overview-structure')?.value;
const week = document.querySelector('#overview-week')?.value;
if (!structureId || !week) return;
const params = new URLSearchParams({ structure_id: structureId, week });
window.open(`api/structure_planning_pdf.php?${params.toString()}`, '_blank', 'noopener');
}
/** Charge et rend la semaine du lieu sélectionné. */
async function load() {
const structureId = document.querySelector('#overview-structure')?.value;
const week = document.querySelector('#overview-week')?.value;
const container = document.querySelector('#structure-overview-content');
const empty = document.querySelector('#structure-overview-empty');
updatePdfButton();
if (!container || !empty) return;
if (!structureId || !week) {
empty.hidden = false;
empty.textContent = 'Choisissez un lieu d’affectation et une semaine.';
container.hidden = true;
return;
}
empty.hidden = false;
empty.textContent = 'Chargement du planning du lieu…';
container.hidden = true;
try {
const params = new URLSearchParams({ structure_id: structureId, week });
const response = await fetch(`api/structure_overview.php?${params.toString()}`);
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger la vue du lieu.'));
render(data);
} catch (error) {
empty.hidden = false;
empty.textContent = error.message;
}
}
function init() {
document.querySelector('#load-structure-overview')?.addEventListener('click', load);
document.querySelector('#overview-structure')?.addEventListener('change', load);
document.querySelector('#overview-week')?.addEventListener('change', load);
openStructurePdfButton?.addEventListener('click', openPdf);
updatePdfButton();
}
PTA.modules.structureOverview = { init, load, render, updatePdfButton };
})();