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

370 lines
18 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.
/**
* Vue annuelle d'un agent (`PTA.modules.annualOverview`), écran annual.php.
*
* Affiche les douze mois de l'année civile et permet de valider TOUTE l'année en
* une opération.
*
* PARTICULARITÉ — ce module ne calcule presque rien. Le calendrier arrive du
* serveur entièrement préparé (classes CSS, libellés, infobulles, totaux) :
* voir AnnualOverviewModel. Le rôle du JavaScript se limite à assembler le HTML
* et à gérer les interactions. C'est ce qui garantit un rendu identique à
* l'écran, à l'impression et en plein écran.
*
* Chaque jour est cliquable et ouvre l'écran de saisie sur la bonne semaine et
* le bon lieu.
*/
(() => {
'use strict';
const PTA = window.PTA;
// core.js absent : la page reste lisible, simplement non interactive.
if (!PTA) return;
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils;
const el = (id) => document.getElementById(id);
// escapeHtml échappe désormais aussi les guillemets : une seule
// implémentation partagée, valable en contexte texte comme attribut.
const escapeAttr = escapeHtml;
let current = null;
/** Message dans le bandeau de la page. */
function show(text, type = 'info') {
const box = el('annual-message');
if (!box) return;
box.className = `message message-${type}`;
box.textContent = text;
}
/**
* Horodatage SQL « 2026-02-17 09:30:00 » → date et heure en français.
* Le remplacement de l'espace par « T » rend la chaîne conforme à ISO 8601,
* seule forme que tous les navigateurs analysent de la même façon.
*/
function formatGenerated(value) {
const date = new Date(String(value).replace(' ', 'T'));
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' });
}
/** Libellé du type de contrat. */
function formatContract(value) {
if (value === 'PERMANENT') return 'Permanent';
if (value === 'TEMPORAIRE') return 'Temporaire';
return value || 'Non renseigné';
}
/**
* Lien profond d'une journée vers l'écran de saisie.
* Le lieu retenu est celui du premier créneau du jour, à défaut le
* rattachement de l'agent. `notice` rappelle d'où vient l'utilisateur, sans
* quoi il arriverait sur une semaine sans savoir pourquoi.
*/
function buildPlanningUrl(day) {
const agentId = Number(current?.agent?.id_agent || 0);
const structureId = Number(day.structure_id || current?.agent?.id_structure || 0);
const week = `${Number(day.week_year)}-W${String(day.week).padStart(2, '0')}`;
const params = new URLSearchParams({ agent_id: String(agentId), week, focus: 'annual' });
if (structureId) params.set('structure_id', String(structureId));
params.set('notice', `Ouverture depuis la vue annuelle — ${formatDate(day.date)}.`);
return `planning.php?${params.toString()}`;
}
/**
* Une cellule du calendrier. Contenu, classe et infobulle viennent du
* serveur ; seule la marque « brouillon » est ajoutée ici.
*/
function cellHtml(cell, day) {
const content = cell.display ? escapeHtml(cell.display) : ' ';
const title = cell.title ? ` title="${escapeAttr(cell.title)}"` : '';
const draftClass = day.has_draft && cell.minutes > 0 ? ' annual-cell-draft' : '';
return `<td class="annual-data-cell ${escapeHtml(cell.class || '')}${draftClass}" data-date="${escapeHtml(day.date)}"${title}>${content}</td>`;
}
/** Tableau d'un mois : une ligne par jour, quatre colonnes AP/PP/AE/PE. */
function monthHtml(month) {
const rows = month.days.map((day) => {
const classes = [
day.is_weekend ? 'annual-weekend' : '',
day.is_vacation ? 'annual-vacation-row' : '',
day.has_entries ? 'annual-has-entry' : '',
].filter(Boolean).join(' ');
const vacationTitle = day.vacation_label ? ` title="${escapeAttr(day.vacation_label)}"` : '';
const weekText = day.show_week ? String(day.week).padStart(2, '0') : '';
return `
<tr class="${classes}" data-url="${escapeAttr(buildPlanningUrl(day))}"${vacationTitle}>
<th class="annual-day-cell"><span>${escapeHtml(day.weekday_short)}</span>${day.day}</th>
<td class="annual-week-cell">${weekText}</td>
${cellHtml(day.cells.AP, day)}
${cellHtml(day.cells.PP, day)}
${cellHtml(day.cells.AE, day)}
${cellHtml(day.cells.PE, day)}
</tr>`;
}).join('');
return `
<article class="annual-month" data-month="${month.number}">
<table>
<thead>
<tr><th colspan="6" class="annual-month-title">${escapeHtml(month.name)}</th></tr>
<tr class="annual-column-headings"><th>Jour</th><th>S</th><th>AP</th><th>PP</th><th>AE</th><th>PE</th></tr>
</thead>
<tbody>${rows}</tbody>
<tfoot>
<tr><th colspan="2">Total</th><td>${escapeHtml(month.totals.AP.compact || '0h')}</td><td>${escapeHtml(month.totals.PP.compact || '0h')}</td><td>${escapeHtml(month.totals.AE.compact || '0h')}</td><td>${escapeHtml(month.totals.PE.compact || '0h')}</td></tr>
</tfoot>
</table>
</article>`;
}
/** Rappel du ou des quotas couvrant l'année affichée. */
function quotaPeriodsHtml(periods) {
return periods.map((period) => `
<article class="annual-quota-chip">
<span>${escapeHtml(formatDate(period.date_debut_periode))} → ${escapeHtml(formatDate(period.date_fin_periode))}</span>
<strong>${escapeHtml(period.affectees?.libelle || '0 h 00')} affectées / ${escapeHtml(period.quota_cible?.libelle || '0 h 00')}</strong>
<small>Reste : ${escapeHtml(period.restantes?.libelle || '0 h 00')}</small>
</article>`).join('');
}
/** Rend l'ensemble de la vue et mémorise les données dans `current`. */
function render(data) {
current = data;
const agent = data.agent || {};
const periods = data.quota_periods || [];
const referencePeriod = periods[0] || {};
el('annual-content').hidden = false;
el('annual-title').textContent = `ANNUALISATION ${data.year}`;
el('annual-updated').textContent = `Mise à jour : ${formatGenerated(data.generated_at)}`;
el('annual-agent-name').textContent = `${agent.prenom || ''} ${agent.nom || ''}`.trim() || '—';
el('annual-agent-number').textContent = agent.matricule || '—';
el('annual-rate').textContent = referencePeriod.quotite_travail !== undefined ? `${Number(referencePeriod.quotite_travail).toLocaleString('fr-FR')} %` : '—';
el('annual-target').textContent = referencePeriod.quota_cible?.libelle || '—';
el('annual-contract').textContent = formatContract(agent.type_contrat);
el('annual-location').textContent = agent.structure_principale_nom || 'Sans lieu principal';
el('annual-post').textContent = agent.poste_libelle || 'Poste non renseigné';
el('annual-remaining').textContent = referencePeriod.restantes?.libelle || '—';
el('annual-quota-periods').innerHTML = quotaPeriodsHtml(periods);
el('annual-calendar-total').textContent = data.calendar_totals?.duration || '0 h 00';
el('annual-validated-total').textContent = data.calendar_totals?.validated || '0 h 00';
el('annual-draft-total').textContent = data.calendar_totals?.draft || '0 h 00';
el('annual-year-label').textContent = `01/01/${data.year} → 31/12/${data.year}`;
el('annual-months').innerHTML = (data.months || []).map(monthHtml).join('');
el('annual-legend').innerHTML = (data.legend || []).map((item) => `
<span class="annual-legend-item"><i class="${escapeHtml(item.class)}"></i><b>${escapeHtml(item.code)}</b> ${escapeHtml(item.label)}</span>
`).join('');
el('annual-open-pta').href = `pta.php?agent_id=${Number(agent.id_agent)}&date=${data.year}-07-01`;
el('annual-open-month').href = `agents.php?agent_id=${Number(agent.id_agent)}&month=${data.year}-01`;
el('annual-print').disabled = false;
el('annual-fullscreen').disabled = false;
if (el('annual-validate-year')) el('annual-validate-year').disabled = false;
}
/** Charge la vue annuelle de l'agent et de l'année sélectionnés. */
async function load(options = {}) {
const agentId = Number(el('annual-agent')?.value || 0);
const year = Number(el('annual-year')?.value || 0);
if (!agentId || year < 2000 || year > 2100) {
show('Choisissez un agent et une année valide.', 'error');
return;
}
if (options.clearValidation !== false && el('annual-validation-result')) {
el('annual-validation-result').innerHTML = '';
}
show('Construction de la vue annuelle…');
el('annual-content').hidden = true;
try {
const response = await fetch(`api/annual_overview.php?agent_id=${agentId}&year=${year}`);
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger la vue annuelle.'));
render(data);
show('Vue annuelle actualisée.', 'success');
} catch (error) {
show(error.message, 'error');
}
}
/** Lien de correction d'une semaine en erreur, ouvert dans un nouvel onglet. */
function validationPlanningUrl(planning) {
const agentId = Number(current?.agent?.id_agent || el('annual-agent')?.value || 0);
const week = `${Number(planning.annee)}-W${String(planning.numero_semaine).padStart(2, '0')}`;
const params = new URLSearchParams({
agent_id: String(agentId),
structure_id: String(Number(planning.id_structure)),
week,
focus: 'annual-validation',
notice: `Correction demandée depuis la vue annuelle — semaine ${String(planning.numero_semaine).padStart(2, '0')}.`,
});
return `planning.php?${params.toString()}`;
}
/**
* Rend l'échec de validation annuelle : AUCUNE semaine n'a été validée.
* Chaque semaine fautive est listée avec ses erreurs et un lien direct pour
* la corriger — c'est ce qui rend l'échec exploitable.
*/
function renderValidationIssues(data) {
const container = el('annual-validation-result');
if (!container) return;
const invalid = (data.plannings || []).filter((planning) => planning.has_errors);
container.innerHTML = `
<div class="annual-validation-summary is-error">
<strong>Aucune semaine n’a été validée.</strong><br>
${escapeHtml(data.error || 'Des erreurs doivent être corrigées avant la validation annuelle.')}
<div>${invalid.length} planning(s) à corriger.</div>
</div>
<div class="annual-validation-issues">
${invalid.map((planning) => {
const errors = (planning.controls || []).filter((control) => control.niveau === 'ERREUR');
return `
<article class="annual-validation-issue">
<header>
<h4>Semaine ${String(planning.numero_semaine).padStart(2, '0')} — ${escapeHtml(planning.structure_nom || 'Lieu non renseigné')}</h4>
<a class="button button-secondary annual-validation-open-link"
href="${escapeAttr(validationPlanningUrl(planning))}"
target="_blank" rel="noopener">Corriger cette semaine</a>
</header>
<div>${escapeHtml(formatDate(planning.date_debut))} → ${escapeHtml(formatDate(planning.date_fin))}</div>
<ul>${errors.map((control) => `<li>${escapeHtml(control.message)}</li>`).join('')}</ul>
</article>`;
}).join('')}
</div>`;
}
/** Rend l'avertissement de quota dépassé, en attente de confirmation. */
function renderValidationWarning(data) {
const container = el('annual-validation-result');
if (!container) return;
container.innerHTML = `
<div class="annual-validation-summary is-warning">
<strong>Avertissement avant validation.</strong><br>
${escapeHtml(data.message || 'Une confirmation est nécessaire.')}
</div>`;
}
/** Rend le compte rendu d'une validation annuelle réussie. */
function renderValidationSuccess(data) {
const container = el('annual-validation-result');
if (!container) return;
container.innerHTML = `
<div class="annual-validation-summary is-success">
<strong>Validation terminée.</strong><br>
${escapeHtml(data.message || 'Les plannings ont été validés.')}
</div>`;
}
/**
* Lance la validation de l'année entière.
*
* Vérifie d'abord que la vue affichée correspond bien à l'agent et à l'année
* sélectionnés : sans ce contrôle, changer le sélecteur sans recharger
* validerait une année différente de celle qu'on regarde.
*
* Trois issues, alignées sur AnnualOverviewController::validateYear() :
* 422 erreurs, 409 quota dépassé (rappel avec `forceQuota = true` après
* confirmation), 200 validé.
*/
async function validateWholeYear(forceQuota = false) {
const agentId = Number(el('annual-agent')?.value || 0);
const year = Number(el('annual-year')?.value || 0);
if (!agentId || !year || !current || Number(current.agent?.id_agent) !== agentId || Number(current.year) !== year) {
show('Affichez d’abord la vue annuelle de l’agent à contrôler.', 'error');
return;
}
const button = el('annual-validate-year');
if (button) button.disabled = true;
const container = el('annual-validation-result');
if (container) {
container.innerHTML = '<div class="annual-validation-summary">Contrôle de toutes les semaines en brouillon…</div>';
}
try {
const response = await fetch('api/annual_validate.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: PTA.config.csrfToken,
agent_id: agentId,
year,
force_quota: Boolean(forceQuota),
}),
});
const data = await readJsonResponse(response);
if (response.status === 422 && data.status === 'errors') {
renderValidationIssues(data);
show('Des semaines doivent être corrigées avant la validation annuelle.', 'error');
return;
}
if (response.status === 409 && data.status === 'warning') {
renderValidationWarning(data);
const confirmed = window.confirm(`${data.message || 'Le quota annuel présente un avertissement.'}\n\nValider malgré cet avertissement ?`);
if (confirmed) await validateWholeYear(true);
return;
}
if (!response.ok) {
throw new Error(apiErrorMessage(data, 'Impossible de valider l’année.'));
}
renderValidationSuccess(data);
show(data.message || 'Validation annuelle terminée.', 'success');
await load({ clearValidation: false });
} catch (error) {
if (container) {
container.innerHTML = `<div class="annual-validation-summary is-error">${escapeHtml(error.message)}</div>`;
}
show(error.message, 'error');
} finally {
if (button) button.disabled = false;
}
}
/**
* Clic sur une ligne du calendrier : suit le lien porté par `data-url`.
* Délégation sur le conteneur, le calendrier étant redessiné à chaque
* chargement.
*/
function handleCalendarClick(event) {
const row = event.target.closest('tr[data-url]');
if (!row) return;
window.location.href = row.dataset.url;
}
/**
* Bascule le calendrier en plein écran — utile sur douze mois affichés d'un
* coup. L'API Fullscreen peut être refusée (navigateur, iframe, réglages) :
* l'échec est signalé sans conséquence pour le reste de la page.
*/
async function toggleFullscreen() {
const target = el('annual-content');
if (!target) return;
try {
if (!document.fullscreenElement) {
await target.requestFullscreen?.();
} else {
await document.exitFullscreen?.();
}
} catch (error) {
show('Le mode plein écran n’est pas disponible dans ce navigateur.', 'error');
}
}
function init() {
el('load-annual')?.addEventListener('click', load);
el('annual-print')?.addEventListener('click', () => window.print());
el('annual-fullscreen')?.addEventListener('click', toggleFullscreen);
el('annual-validate-year')?.addEventListener('click', () => validateWholeYear(false));
el('annual-months')?.addEventListener('click', handleCalendarClick);
const params = window.PTA_PAGE_CONFIG?.params || {};
if (params.agentId) el('annual-agent').value = String(params.agentId);
if (params.year) el('annual-year').value = String(params.year);
if (params.agentId) load();
}
PTA.modules.annualOverview = { init, load };
})();