Files
pta/assets/js/annual-overview.js
PTA 66cc8c8ade Base de travail V7.8 reprise du répertoire de travail
Commit technique : reproduit à l'identique l'état non commité de la copie
de travail (sécurisation V7.8, CSP à nonce, Session, migration des droits)
afin que le travail d'ergonomie qui suit soit isolable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:32:10 +02:00

304 lines
15 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) 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;
function show(text, type = 'info') {
const box = el('annual-message');
if (!box) return;
box.className = `message message-${type}`;
box.textContent = text;
}
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' });
}
function formatContract(value) {
if (value === 'PERMANENT') return 'Permanent';
if (value === 'TEMPORAIRE') return 'Temporaire';
return value || 'Non renseigné';
}
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()}`;
}
function cellHtml(cell, day) {
const content = cell.display ? escapeHtml(cell.display) : '&nbsp;';
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>`;
}
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>`;
}
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('');
}
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;
}
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');
}
}
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()}`;
}
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>`;
}
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>`;
}
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>`;
}
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;
}
}
function handleCalendarClick(event) {
const row = event.target.closest('tr[data-url]');
if (!row) return;
window.location.href = row.dataset.url;
}
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 };
})();