Files
pta/assets/js/agent-overview.js
Loic Masi 5fbf76868f pour prod
2026-08-07 16:13:43 +02:00

536 lines
26 KiB
JavaScript
Raw Permalink 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) 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 };
})();