pour prod

This commit is contained in:
Loic Masi
2026-08-07 16:13:43 +02:00
commit 5fbf76868f
157 changed files with 24085 additions and 0 deletions

535
assets/js/agent-overview.js Normal file
View File

@@ -0,0 +1,535 @@
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant agent-overview.js');
const permissions = window.PTA_CONFIG?.permissions || {};
const accessProfile = window.PTA_CONFIG?.access || {};
const canEditDraft = Boolean(permissions.can_edit_draft);
const canEditCrossStructure = Boolean(permissions.can_edit_cross_structure);
const canCopyFullWeek = Boolean(permissions.can_copy_full_week);
const { shared } = PTA;
const {
escapeHtml,
readJsonResponse,
apiErrorMessage,
formatDate,
timeToMinutes,
formatDuration,
} = PTA.utils;
let draggedEntry = null;
let suppressClickUntil = 0;
let activeSourceWeek = '';
const typeLabel = (type) => type === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire';
function setMessage(element, text = '', type = 'info') {
if (!element) return;
element.className = text ? `message message-${type}` : 'message';
element.textContent = text;
}
function canEditEntry(entry) {
if (!canEditDraft) return false;
if (canEditCrossStructure) return true;
return Number(entry.id_structure) === Number(accessProfile.structure_id || 0);
}
function entryBadge(entry) {
const editable = canEditEntry(entry);
const interactionAttributes = editable
? `draggable="true" tabindex="0" role="button" data-editable="1" title="Glissez vers un autre jour pour déplacer. Ctrl/⌘ + glisser pour dupliquer."`
: 'data-editable="0"';
return `
<article class="overview-entry agent-month-entry${editable ? ' planning-entry-interactive' : ''} motif-${escapeHtml(String(entry.motif_code || 'autre').toLowerCase())}"
${interactionAttributes} data-entry-id="${Number(entry.id_creneau)}">
${editable ? '<span class="entry-drag-hint" aria-hidden="true">⋮⋮</span>' : ''}
<div class="agent-month-entry-content">
<strong>${escapeHtml(entry.heure_debut)}${escapeHtml(entry.heure_fin)}</strong>
<span>${escapeHtml(entry.motif_libelle)}</span>
<small>📍 ${escapeHtml(entry.structure_nom)} · ${typeLabel(entry.structure_type_affectation)}</small>
${entry.statut === 'BROUILLON' ? '<em>Brouillon</em>' : ''}
</div>
<div class="agent-month-entry-actions">
${editable
? `<button type="button" class="button button-secondary overview-entry-edit" data-entry-id="${Number(entry.id_creneau)}">Modifier</button>`
: '<span class="entry-locked">Lecture seule</span>'}
</div>
</article>
`;
}
function countedMinutes(entries) {
return entries.reduce((sum, entry) => {
if (!Number(entry.compte_dans_quota)) return sum;
return sum + (timeToMinutes(entry.heure_fin) - timeToMinutes(entry.heure_debut));
}, 0);
}
function render(data) {
const container = document.querySelector('#agent-overview-content');
const empty = document.querySelector('#agent-overview-empty');
if (!container || !empty) return;
shared.lastAgentOverviewData = data;
empty.hidden = true;
container.hidden = false;
const monthMinutes = countedMinutes(data.entries || []);
const weekBlocks = (data.weeks || []).map((week) => {
const days = week.days.map((day) => {
const entries = (data.entries || []).filter((entry) => entry.date_jour === day.date);
return `
<article class="agent-month-day-card${day.in_month ? '' : ' is-outside-month'}" data-date="${escapeHtml(day.date)}">
<header class="agent-month-day-header">
<strong>${escapeHtml(day.label)}</strong>
<span>${escapeHtml(day.display)}</span>
</header>
<div class="agent-month-day-body">
${entries.length ? entries.map(entryBadge).join('') : '<p class="day-empty">Aucune affectation</p>'}
</div>
</article>
`;
}).join('');
return `
<section class="agent-month-week" data-week="${escapeHtml(week.value)}">
<header class="agent-month-week-header">
<div>
<strong>Semaine ${Number(week.number)}</strong>
<span>du ${formatDate(week.date_debut)} au ${formatDate(week.date_fin)}</span>
</div>
${canCopyFullWeek ? `<button type="button" class="button button-secondary agent-copy-week" data-source-week="${escapeHtml(week.value)}">Dupliquer cette semaine</button>` : ''}
</header>
<div class="agent-month-week-grid">${days}</div>
</section>
`;
}).join('');
container.innerHTML = `
<div class="overview-title-row agent-month-title-row">
<div>
<h3>${escapeHtml(data.agent.prenom)} ${escapeHtml(data.agent.nom)}</h3>
<p>${escapeHtml(data.agent.matricule)}${data.agent.structure_principale_nom ? ` · lieu principal : ${escapeHtml(data.agent.structure_principale_nom)} (${typeLabel(data.agent.structure_principale_type_affectation)})` : ''} · ${escapeHtml(data.month.label)}</p>
<p class="agent-contact-summary">${Number(data.agent.est_diplome) ? `🎓 Diplômé${data.agent.diplome_libelle ? `${escapeHtml(data.agent.diplome_libelle)}` : ''}` : 'Non diplômé'}${data.agent.telephone ? ` · 📞 ${escapeHtml(data.agent.telephone)}` : ''}${data.agent.adresse ? ` · 📍 ${escapeHtml(data.agent.adresse)}` : ''}${data.agent.date_debut_contrat ? ` · Contrat depuis le ${escapeHtml(formatDate(data.agent.date_debut_contrat))}` : ' · ⚠ Date de début de contrat non renseignée'}</p>
</div>
<div class="agent-overview-actions">
<button type="button" class="button button-secondary button-pdf agent-hours-contract-pdf">Contrat horaire PDF</button>
<button type="button" class="button button-primary button-pdf agent-time-summary-pdf">Bilan des heures PDF</button>
</div>
</div>
<div class="quota-strip overview-quota-strip">
<article class="quota-card"><span>Quotité</span><strong>${Number(data.quota.quotite_travail).toLocaleString('fr-FR')} %</strong></article>
<article class="quota-card"><span>Quota de la période</span><strong>${escapeHtml(data.quota.quota_cible.libelle)}</strong><small>${escapeHtml(data.quota.periode_libelle || '')}</small></article>
<article class="quota-card"><span>Décompté sur le mois affiché</span><strong>${escapeHtml(formatDuration(monthMinutes))}</strong><small>Travail + motifs comptant dans le quota</small></article>
<article class="quota-card quota-card-emphasis"><span>Reste à affecter</span><strong class="${data.quota.minutes_restantes < 0 ? 'quota-negative' : ''}">${escapeHtml(data.quota.restantes.libelle)}</strong><small>Calcul sur lannée civile du 1er janvier au 31 décembre.</small></article>
</div>
<p class="agent-month-help">${canEditDraft
? (canCopyFullWeek
? 'Glissez un créneau modifiable vers un autre jour pour le déplacer. Maintenez <strong>Ctrl</strong> (ou <strong>⌘</strong> sur Mac) pour le dupliquer. La duplication complète dune semaine est réservée au Service Enfance.'
: 'Vous pouvez modifier les créneaux de votre lieu. Les créneaux saisis sur un autre lieu restent en lecture seule et toute modification est enregistrée en brouillon.')
: 'Cette vue est en lecture seule. Vous pouvez consulter votre planning et télécharger les documents disponibles.'}</p>
<div class="agent-month-planner">${weekBlocks}</div>
`;
bindMonthInteractions();
}
function findEntry(entryId) {
return shared.lastAgentOverviewData?.entries?.find((item) => Number(item.id_creneau) === Number(entryId)) || null;
}
async function persistEntryMove(entryId, targetDate, duplicate) {
const response = await fetch('api/move_or_duplicate_entry.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
entry_id: Number(entryId),
target_date: targetDate,
action: duplicate ? 'duplicate' : 'move',
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de déplacer ce créneau.'));
return data;
}
function clearDropTargets() {
document.querySelectorAll('#agent-overview-content .agent-month-day-card').forEach((card) => {
card.classList.remove('is-drop-target', 'is-copy-target');
});
}
function bindMonthInteractions() {
const container = document.querySelector('#agent-overview-content');
if (!container) return;
container.querySelectorAll('.agent-month-entry[data-editable="1"]').forEach((item) => {
item.addEventListener('dragstart', (event) => {
draggedEntry = findEntry(Number(item.dataset.entryId));
item.classList.add('is-dragging');
event.dataTransfer.effectAllowed = 'copyMove';
event.dataTransfer.setData('text/plain', item.dataset.entryId || '');
});
item.addEventListener('dragend', () => {
item.classList.remove('is-dragging');
draggedEntry = null;
suppressClickUntil = Date.now() + 250;
clearDropTargets();
});
item.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openModal(Number(item.dataset.entryId));
}
});
item.addEventListener('click', (event) => {
if (Date.now() < suppressClickUntil || event.target.closest('button')) return;
openModal(Number(item.dataset.entryId));
});
});
if (!canEditDraft) return;
container.querySelectorAll('.agent-month-day-card').forEach((card) => {
card.addEventListener('dragover', (event) => {
if (!draggedEntry) return;
event.preventDefault();
const duplicate = event.ctrlKey || event.metaKey;
event.dataTransfer.dropEffect = duplicate ? 'copy' : 'move';
clearDropTargets();
card.classList.add('is-drop-target');
if (duplicate) card.classList.add('is-copy-target');
});
card.addEventListener('dragleave', (event) => {
if (!card.contains(event.relatedTarget)) card.classList.remove('is-drop-target', 'is-copy-target');
});
card.addEventListener('drop', async (event) => {
if (!draggedEntry) return;
event.preventDefault();
const entry = draggedEntry;
const targetDate = card.dataset.date || '';
const duplicate = event.ctrlKey || event.metaKey;
clearDropTargets();
try {
const result = await persistEntryMove(entry.id_creneau, targetDate, duplicate);
await load();
setMessage(document.querySelector('#agent-overview-message'), result.message || (duplicate ? 'Créneau dupliqué.' : 'Créneau déplacé.'), 'success');
} catch (error) {
setMessage(document.querySelector('#agent-overview-message'), error.message, 'error');
}
});
});
}
function openHoursContractPdf() {
const data = shared.lastAgentOverviewData;
const agentId = data?.agent?.id_agent || document.querySelector('#overview-agent')?.value;
const date = data?.month?.date_debut;
if (!agentId || !date) return;
const params = new URLSearchParams({ agent_id: String(agentId), date: String(date) });
window.open(`api/agent_hours_contract_pdf.php?${params.toString()}`, '_blank', 'noopener');
}
function openTimeSummaryPdf() {
const data = shared.lastAgentOverviewData;
const agentId = data?.agent?.id_agent || document.querySelector('#overview-agent')?.value;
const date = data?.month?.date_debut;
if (!agentId || !date) return;
const params = new URLSearchParams({ agent_id: String(agentId), date: String(date) });
window.open(`api/agent_time_summary_pdf.php?${params.toString()}`, '_blank', 'noopener');
}
function closeModal() {
const modal = document.querySelector('#agent-entry-modal');
const form = document.querySelector('#agent-entry-edit-form');
const message = document.querySelector('#agent-entry-edit-message');
if (!modal) return;
modal.hidden = true;
document.body.classList.remove('modal-open');
form?.reset();
setMessage(message);
}
function openModal(entryId) {
const entry = findEntry(entryId);
if (!entry || !canEditEntry(entry)) return;
const modal = document.querySelector('#agent-entry-modal');
if (!entry || !modal) return;
document.querySelector('#edit-agent-entry-id').value = String(Number(entry.id_creneau));
document.querySelector('#edit-agent-entry-date').value = entry.date_jour;
document.querySelector('#edit-agent-entry-structure').value = String(Number(entry.id_structure));
document.querySelector('#edit-agent-entry-motif').value = String(Number(entry.id_motif));
document.querySelector('#edit-agent-entry-start').value = entry.heure_debut;
document.querySelector('#edit-agent-entry-end').value = entry.heure_fin;
setMessage(document.querySelector('#agent-entry-edit-message'));
modal.hidden = false;
document.body.classList.add('modal-open');
document.querySelector('#edit-agent-entry-motif')?.focus();
}
async function deleteEntry(entryId) {
const entry = findEntry(entryId);
if (!entry || !canEditEntry(entry)) return;
if (!entry) return;
const details = `${entry.motif_libelle} · ${entry.heure_debut}${entry.heure_fin} · ${entry.structure_nom}`;
if (!window.confirm(`Supprimer définitivement ce créneau ?\n\n${details}\n${formatDate(entry.date_jour)}`)) return;
const button = document.querySelector('#delete-agent-entry-edit');
const message = document.querySelector('#agent-entry-edit-message');
if (button) button.disabled = true;
setMessage(message, 'Suppression du créneau en cours…', 'info');
try {
const response = await fetch('api/delete_agent_entry.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
entry_id: Number(entryId),
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de supprimer ce créneau.'));
await load();
closeModal();
setMessage(document.querySelector('#agent-overview-message'), data.message || 'Créneau supprimé.', 'success');
// La suppression peut modifier la file de validation et créer un trou de couverture.
PTA.modules.pendingValidation?.refresh?.({ silent: true });
PTA.modules.coverage?.refresh?.({ silent: true });
} catch (error) {
setMessage(message, error.message, 'error');
} finally {
if (button) button.disabled = false;
}
}
async function saveEntry(event) {
event.preventDefault();
if (!canEditDraft) return;
const submit = document.querySelector('#save-agent-entry-edit');
const message = document.querySelector('#agent-entry-edit-message');
if (!submit || !message) return;
submit.disabled = true;
setMessage(message);
try {
const response = await fetch('api/update_agent_entry.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
entry_id: Number(document.querySelector('#edit-agent-entry-id').value),
structure_id: Number(document.querySelector('#edit-agent-entry-structure').value),
motif_id: Number(document.querySelector('#edit-agent-entry-motif').value),
date: document.querySelector('#edit-agent-entry-date').value,
start: document.querySelector('#edit-agent-entry-start').value,
end: document.querySelector('#edit-agent-entry-end').value,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de modifier ce créneau.'));
setMessage(message, data.message || 'Créneau modifié.', 'success');
await load();
setTimeout(closeModal, 450);
} catch (error) {
setMessage(message, error.message, 'error');
} finally {
submit.disabled = false;
}
}
function isoWeekValueFromDate(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const day = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}
function weekMonday(weekValue) {
const match = /^(\d{4})-W(\d{2})$/.exec(weekValue);
if (!match) return null;
const year = Number(match[1]);
const week = Number(match[2]);
const jan4 = new Date(year, 0, 4);
const day = jan4.getDay() || 7;
const monday = new Date(jan4);
monday.setDate(jan4.getDate() - day + 1 + ((week - 1) * 7));
return monday;
}
function openWeekCopyModal(sourceWeek) {
if (!canCopyFullWeek) return;
const modal = document.querySelector('#agent-week-copy-modal');
const sourceInput = document.querySelector('#agent-week-copy-source');
const targetInput = document.querySelector('#agent-week-copy-target');
const summary = document.querySelector('#agent-week-copy-summary');
if (!modal || !sourceInput || !targetInput) return;
activeSourceWeek = sourceWeek;
sourceInput.value = sourceWeek;
const monday = weekMonday(sourceWeek);
if (monday) {
monday.setDate(monday.getDate() + 7);
targetInput.value = isoWeekValueFromDate(monday);
}
if (summary) summary.textContent = `La semaine ${sourceWeek.replace('-W', ' / S')} sera recopiée avec ses lieux, motifs et horaires.`;
setMessage(document.querySelector('#agent-week-copy-message'));
modal.hidden = false;
document.body.classList.add('modal-open');
targetInput.focus();
}
function closeWeekCopyModal() {
const modal = document.querySelector('#agent-week-copy-modal');
if (!modal) return;
modal.hidden = true;
document.body.classList.remove('modal-open');
activeSourceWeek = '';
document.querySelector('#agent-week-copy-form')?.reset();
setMessage(document.querySelector('#agent-week-copy-message'));
}
async function copyWeek(event) {
event.preventDefault();
if (!canCopyFullWeek) return;
const agentId = Number(document.querySelector('#overview-agent')?.value || 0);
const sourceWeek = document.querySelector('#agent-week-copy-source')?.value || activeSourceWeek;
const targetWeek = document.querySelector('#agent-week-copy-target')?.value || '';
const mode = document.querySelector('input[name="agent-week-copy-mode"]:checked')?.value || 'merge';
const message = document.querySelector('#agent-week-copy-message');
const submit = document.querySelector('#confirm-agent-week-copy');
if (!agentId || !sourceWeek || !targetWeek) {
setMessage(message, 'Sélectionnez une semaine cible.', 'warning');
return;
}
if (sourceWeek === targetWeek) {
setMessage(message, 'La semaine cible doit être différente de la semaine source.', 'warning');
return;
}
if (mode === 'replace' && !window.confirm('Les créneaux existants de la semaine cible seront supprimés avant la copie. Continuer ?')) return;
if (submit) submit.disabled = true;
setMessage(message, 'Duplication de la semaine en cours...', 'info');
try {
const response = await fetch('api/copy_agent_week.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
agent_id: agentId,
source_week: sourceWeek,
target_week: targetWeek,
mode,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de dupliquer cette semaine.'));
setMessage(message, data.message || 'Semaine dupliquée.', 'success');
await load();
setMessage(document.querySelector('#agent-overview-message'), data.message || 'Semaine dupliquée.', 'success');
setTimeout(closeWeekCopyModal, 500);
} catch (error) {
setMessage(message, error.message, 'error');
} finally {
if (submit) submit.disabled = false;
}
}
async function load() {
const agentId = document.querySelector('#overview-agent')?.value;
const month = document.querySelector('#agent-overview-month')?.value;
const container = document.querySelector('#agent-overview-content');
const empty = document.querySelector('#agent-overview-empty');
if (!container || !empty) return;
if (!agentId || !month) {
empty.hidden = false;
empty.textContent = 'Choisissez un agent et un mois.';
container.hidden = true;
return;
}
setMessage(document.querySelector('#agent-overview-message'));
empty.hidden = false;
empty.textContent = 'Chargement du planning mensuel de lagent…';
container.hidden = true;
try {
const params = new URLSearchParams({ agent_id: agentId, month });
const response = await fetch(`api/agent_month_overview.php?${params.toString()}`);
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger la vue mensuelle de lagent.'));
render(data);
} catch (error) {
empty.hidden = false;
empty.textContent = error.message;
}
}
function init() {
document.querySelector('#load-agent-overview')?.addEventListener('click', load);
document.querySelector('#overview-agent')?.addEventListener('change', load);
document.querySelector('#agent-overview-month')?.addEventListener('change', load);
document.querySelector('#agent-overview-content')?.addEventListener('click', (event) => {
if (event.target.closest('.agent-hours-contract-pdf')) {
openHoursContractPdf();
return;
}
if (event.target.closest('.agent-time-summary-pdf')) {
openTimeSummaryPdf();
return;
}
const copyButton = event.target.closest('.agent-copy-week');
if (copyButton && canCopyFullWeek) {
openWeekCopyModal(copyButton.dataset.sourceWeek || '');
return;
}
const editButton = event.target.closest('.overview-entry-edit');
if (editButton && canEditDraft) openModal(Number(editButton.dataset.entryId));
});
if (canEditDraft) {
document.querySelector('#agent-entry-edit-form')?.addEventListener('submit', saveEntry);
document.querySelector('#delete-agent-entry-edit')?.addEventListener('click', () => {
const entryId = Number(document.querySelector('#edit-agent-entry-id')?.value || 0);
if (entryId) deleteEntry(entryId);
});
document.querySelector('#close-agent-entry-modal')?.addEventListener('click', closeModal);
document.querySelector('#cancel-agent-entry-edit')?.addEventListener('click', closeModal);
document.querySelector('#agent-entry-modal')?.addEventListener('click', (event) => {
if (event.target.id === 'agent-entry-modal') closeModal();
});
}
if (canCopyFullWeek) {
document.querySelector('#agent-week-copy-form')?.addEventListener('submit', copyWeek);
document.querySelector('#close-agent-week-copy-modal')?.addEventListener('click', closeWeekCopyModal);
document.querySelector('#cancel-agent-week-copy')?.addEventListener('click', closeWeekCopyModal);
document.querySelector('#agent-week-copy-modal')?.addEventListener('click', (event) => {
if (event.target.id === 'agent-week-copy-modal') closeWeekCopyModal();
});
}
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
if (!document.querySelector('#agent-entry-modal')?.hidden) closeModal();
if (!document.querySelector('#agent-week-copy-modal')?.hidden) closeWeekCopyModal();
});
}
PTA.modules.agentOverview = { init, load };
})();