Files
pta/assets/js/annual-overview.js
PTA 4201894e28 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>
2026-08-17 16:53:04 +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 };
})();