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

661 lines
33 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 mensuelle d'un agent (`PTA.modules.agentOverview`), écran agents.php.
*
* Affiche le planning d'un agent mois par mois, TOUS LIEUX CONFONDUS, et permet
* de modifier ses créneaux sans passer par l'écran de saisie.
*
* DIFFÉRENCE ESSENTIELLE AVEC L'ÉDITEUR DE PLANNING : ici, toute modification
* part IMMÉDIATEMENT au serveur. Il n'y a pas de brouillon local, pas de bouton
* « Enregistrer » : la page ne travaille pas sur une semaine mais sur des
* créneaux déjà en base, éventuellement répartis sur plusieurs lieux et
* plusieurs plannings. Chaque geste est donc suivi d'un rechargement complet.
*
* Trois niveaux de droits se combinent : `can_edit_draft` pour modifier,
* `can_edit_cross_structure` pour toucher un créneau d'un autre lieu que le
* sien, `can_copy_full_week` pour dupliquer une semaine entière. L'interface
* masque en conséquence ; le serveur retranche de toute façon.
*/
(() => {
'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';
/** Message dans un conteneur donné : la page en a plusieurs (bandeau, modales). */
function setMessage(element, text = '', type = 'info') {
if (!element) return;
element.className = text ? `message message-${type}` : 'message';
element.textContent = text;
}
/**
* Un créneau n'est modifiable que si l'utilisateur peut éditer les
* brouillons ET que le créneau relève de son périmètre : hors
* `can_edit_cross_structure`, seul son propre lieu.
*/
function canEditEntry(entry) {
if (!canEditDraft) return false;
if (canEditCrossStructure) return true;
return Number(entry.id_structure) === Number(accessProfile.structure_id || 0);
}
/**
* Balisage d'un créneau dans la grille mensuelle.
*
* Un créneau non modifiable est rendu SANS attribut de glisser-déposer et
* porte la mention « Lecture seule » : le droit se voit, plutôt que de se
* découvrir par un refus au clic.
*
* Toutes les valeurs issues de la base passent par escapeHtml(), y compris
* `motif_code` qui alimente un nom de classe CSS.
*/
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>
`;
}
/** Total des minutes s'imputant sur le quota, motifs hors quota exclus. */
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);
}
/**
* Rend la grille mensuelle : une carte par jour, groupée par semaine ISO.
*
* La grille déborde du mois pour rester alignée sur les semaines complètes
* (voir AgentController::monthOverview) ; les jours hors mois sont grisés
* mais bien comptés dans les totaux hebdomadaires.
*/
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 l’anné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 d’une 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();
}
/**
* Retrouve un créneau dans les données du dernier chargement.
* `shared.lastAgentOverviewData` sert de cache : le glisser-déposer a besoin
* du créneau complet à partir du seul identifiant porté par le DOM.
*/
function findEntry(entryId) {
return shared.lastAgentOverviewData?.entries?.find((item) => Number(item.id_creneau) === Number(entryId)) || null;
}
/**
* Envoie un déplacement ou une duplication au serveur.
* Laisse remonter l'exception : l'appelant décide de l'affichage.
*/
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;
}
/** Retire les marques visuelles de dépôt sur toutes les cartes de jour. */
function clearDropTargets() {
document.querySelectorAll('#agent-overview-content .agent-month-day-card').forEach((card) => {
card.classList.remove('is-drop-target', 'is-copy-target');
});
}
/**
* Attache le glisser-déposer aux créneaux et aux jours après chaque rendu.
*
* À rappeler à CHAQUE `render()` : la grille est reconstruite entièrement et
* les écouteurs précédents disparaissent avec les anciens nœuds.
*
* Ctrl/⌘ enfoncé pendant le glisser transforme le déplacement en
* duplication, convention reprise des gestionnaires de fichiers.
*/
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');
}
});
});
}
/** Ouvre le contrat horaire annuel de l'agent dans un nouvel onglet. */
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');
}
/** Ouvre le bilan d'heures annuel de l'agent dans un nouvel onglet. */
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);
}
/**
* Le sélecteur de motif est rendu côté serveur avec tous les motifs actifs :
* on le reconstruit à l'ouverture pour retirer « Formation » aux agents sans
* droit à l'enveloppe de formation. Le motif déjà porté par le créneau reste
* proposé, sans quoi le modal changerait silencieusement de motif.
*/
/** Options du sélecteur de motif de la modale d'édition. */
function renderMotifOptions(selectedMotifId) {
const select = document.querySelector('#edit-agent-entry-motif');
const motifs = window.PTA_CONFIG?.motifs || [];
if (!select || motifs.length === 0) return;
const agent = shared.lastAgentOverviewData?.agent;
const hasTrainingRight = Number(agent?.droit_enveloppe_formation ?? 1) !== 0;
select.innerHTML = motifs
.filter((motif) => hasTrainingRight
|| String(motif.code).toUpperCase() !== 'FORMATION'
|| Number(motif.id) === Number(selectedMotifId))
.map((motif) => `<option value="${Number(motif.id)}">${escapeHtml(motif.label)}</option>`)
.join('');
}
/** Ouvre la modale d'édition d'un créneau, préremplie de ses valeurs. */
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));
renderMotifOptions(Number(entry.id_motif));
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();
}
/**
* Supprime un créneau après confirmation, puis recharge la vue.
* Si le planning porteur était validé, le serveur le repasse en brouillon et
* le signale dans son message.
*/
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;
}
}
/** Enregistre les modifications du créneau, puis recharge la vue. */
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;
}
}
/**
* Date → « 2026-W08 », numérotation ISO 8601.
*
* L'algorithme décale la date au JEUDI de sa semaine (`+ 4 - jour`), car
* c'est le jeudi qui détermine à quelle année appartient une semaine ISO.
* Sans ce décalage, une semaine à cheval sur deux années serait mal
* numérotée. Calcul en UTC pour éviter tout décalage de fuseau.
*/
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')}`;
}
/** « 2026-W08 » → la Date du lundi correspondant (semaine 1 = celle du 4 janvier). */
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;
}
/**
* Ouvre la modale de duplication d'une semaine entière.
* Réservée à `can_copy_full_week` : la copie porte sur TOUS les lieux de
* l'agent, elle déborde du périmètre d'un responsable de structure.
*/
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();
}
/** Ferme la modale de duplication de semaine. */
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'));
}
/**
* Duplique la semaine source vers la semaine cible.
* Deux modes : « merge » complète l'existant, « replace » l'écrase. En mode
* merge, le moindre chevauchement fait échouer toute l'opération côté
* serveur — il n'y a pas de copie partielle.
*/
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;
}
}
/**
* Charge et rend la vue mensuelle de l'agent sélectionné.
* Sans agent ni mois, affiche une invite plutôt qu'une erreur : c'est
* l'état normal à l'ouverture de la page.
*/
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 l’agent…';
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 l’agent.'));
render(data);
} catch (error) {
empty.hidden = false;
empty.textContent = error.message;
}
}
/**
* Point d'entrée appelé par page-bootstrap.js.
* Les clics dans la grille sont traités par DÉLÉGATION sur le conteneur :
* elle est redessinée à chaque chargement, des écouteurs individuels
* devraient être reposés à chaque fois.
*/
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 };
})();