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

482
assets/js/administration.js Normal file
View File

@@ -0,0 +1,482 @@
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant administration.js');
const { structureSelect } = PTA.elements;
const {
escapeHtml,
readJsonResponse,
apiErrorMessage,
formatDuration,
} = PTA.utils;
const typeLabel = (type) => type === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire';
async function saveAgentStructure(button) {
const row = button.closest('tr[data-agent-id]');
const select = row?.querySelector('.agent-structure-select');
const message = document.querySelector('#assignment-message');
if (!row || !select || !message) return;
button.disabled = true;
message.className = 'message';
message.textContent = '';
try {
const response = await fetch('api/agent_assignments.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
agent_id: Number(row.dataset.agentId),
structure_id: select.value ? Number(select.value) : null,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de modifier le lieu principal de lagent.'));
message.className = 'message message-success';
message.textContent = data.message;
if (structureSelect?.value) await PTA.modules.planning?.loadAgents?.();
if (document.querySelector('#overview-structure')?.value) await PTA.modules.structureOverview?.load?.();
if (document.querySelector('#overview-agent')?.value) await PTA.modules.agentOverview?.load?.();
} catch (error) {
message.className = 'message message-error';
message.textContent = error.message;
} finally {
button.disabled = false;
}
}
async function saveStructureType(button) {
const row = button.closest('tr[data-structure-id]');
const select = row?.querySelector('.structure-type-select');
const message = document.querySelector('#structure-settings-message');
if (!row || !select || !message) return;
button.disabled = true;
message.className = 'message';
message.textContent = '';
try {
const response = await fetch('api/update_structure.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
structure_id: Number(row.dataset.structureId),
type_affectation: select.value,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de modifier le type du lieu.'));
updateStructureLabels(Number(row.dataset.structureId), select.value);
message.className = 'message message-success';
message.textContent = data.message;
if (structureSelect?.value) await PTA.modules.planning?.loadAgents?.();
if (document.querySelector('#overview-structure')?.value === String(row.dataset.structureId)) {
await PTA.modules.structureOverview?.load?.();
}
if (document.querySelector('#overview-agent')?.value) await PTA.modules.agentOverview?.load?.();
} catch (error) {
message.className = 'message message-error';
message.textContent = error.message;
} finally {
button.disabled = false;
}
}
function addOptionIfMissing(select, value, label, selected = false) {
if (!select) return;
let option = Array.from(select.options).find((item) => item.value === String(value));
if (!option) {
option = document.createElement('option');
option.value = String(value);
select.appendChild(option);
}
option.textContent = label;
if (selected) option.selected = true;
}
function structureOptionLabel(structure) {
return `${structure.nom}${typeLabel(structure.type_affectation)}`;
}
function addStructureEverywhere(structure) {
const id = Number(structure.id_structure);
const label = structureOptionLabel(structure);
[
document.querySelector('#structure'),
document.querySelector('#overview-structure'),
document.querySelector('#new-agent-structure'),
document.querySelector('#edit-agent-entry-structure'),
document.querySelector('#edit-agent-profile-structure'),
document.querySelector('#coverage-structure-filter'),
document.querySelector('#coverage-rules-structure'),
].forEach((select) => addOptionIfMissing(select, id, label));
document.querySelectorAll('.agent-structure-select').forEach((select) => {
addOptionIfMissing(select, id, label);
});
}
function updateStructureLabels(structureId, type) {
const source = document.querySelector(`#structure-settings-body tr[data-structure-id="${Number(structureId)}"]`);
const structureName = source?.querySelector('td strong')?.textContent?.trim();
if (!structureName) return;
const label = `${structureName}${typeLabel(type)}`;
document.querySelectorAll('select').forEach((select) => {
const option = Array.from(select.options).find((item) => item.value === String(structureId));
if (option && (
select.matches('#structure, #overview-structure, #new-agent-structure, #edit-agent-entry-structure, #edit-agent-profile-structure, #coverage-structure-filter, #coverage-rules-structure, .agent-structure-select')
)) {
option.textContent = label;
}
});
}
function structureOptionsHtml(selectedStructureId = null) {
const source = document.querySelector('#new-agent-structure');
if (!source) return '<option value="">Sans lieu principal</option>';
const options = ['<option value="">Sans lieu principal</option>'];
Array.from(source.options).forEach((option) => {
if (!option.value) return;
const selected = Number(option.value) === Number(selectedStructureId) ? ' selected' : '';
options.push(`<option value="${Number(option.value)}"${selected}>${escapeHtml(option.textContent.trim())}</option>`);
});
return options.join('');
}
function addStructureSettingsRow(structure) {
const body = document.querySelector('#structure-settings-body');
if (!body || body.querySelector(`tr[data-structure-id="${Number(structure.id_structure)}"]`)) return;
const row = document.createElement('tr');
row.dataset.structureId = String(Number(structure.id_structure));
row.innerHTML = `
<td><strong>${escapeHtml(structure.nom)}</strong></td>
<td>${escapeHtml(structure.code)}</td>
<td>
<select class="structure-type-select" aria-label="Type daffectation de ${escapeHtml(structure.nom)}">
<option value="PERISCOLAIRE" ${structure.type_affectation === 'PERISCOLAIRE' ? 'selected' : ''}>Périscolaire</option>
<option value="EXTRASCOLAIRE" ${structure.type_affectation === 'EXTRASCOLAIRE' ? 'selected' : ''}>Extrascolaire</option>
</select>
</td>
<td><button type="button" class="button button-secondary save-structure-type">Enregistrer</button></td>
`;
body.appendChild(row);
}
function qualificationLabel(agent) {
if (!Number(agent.est_diplome)) return 'Non diplômé';
return agent.diplome_libelle ? `Diplômé — ${agent.diplome_libelle}` : 'Diplômé';
}
function formatContractDate(value) {
if (!value) return 'Non renseignée';
const [year, month, day] = String(value).split('-');
return year && month && day ? `${day}/${month}/${year}` : value;
}
function agentRowHtml(agent) {
const structureLabel = agent.structure_nom || agent.structure_principale_nom || 'Sans lieu principal';
return `
<td><strong class="agent-row-name">${escapeHtml(agent.prenom)} ${escapeHtml(agent.nom)}</strong></td>
<td>${escapeHtml(agent.matricule)}</td>
<td class="agent-row-post">${escapeHtml(agent.poste_libelle || 'Non renseigné')}</td>
<td class="agent-row-qualification">${escapeHtml(qualificationLabel(agent))}</td>
<td class="agent-row-phone">${escapeHtml(agent.telephone || 'Non renseigné')}</td>
<td class="agent-row-contract-start">${escapeHtml(formatContractDate(agent.date_debut_contrat))}</td>
<td class="agent-row-structure">${escapeHtml(structureLabel)}</td>
<td><button type="button" class="button button-secondary edit-agent-profile">Modifier</button></td>
`;
}
function setAgentRowData(row, agent) {
row.dataset.agentId = String(Number(agent.id_agent));
row.dataset.email = agent.email || '';
row.dataset.telephone = agent.telephone || '';
row.dataset.adresse = agent.adresse || '';
row.dataset.estDiplome = Number(agent.est_diplome) ? '1' : '0';
row.dataset.diplomeLibelle = agent.diplome_libelle || '';
row.dataset.dateDebutContrat = agent.date_debut_contrat || '';
row.dataset.dateFinContrat = agent.date_fin_contrat || '';
row.dataset.idPoste = agent.id_poste ? String(Number(agent.id_poste)) : '';
row.dataset.typeContrat = agent.type_contrat || 'PERMANENT';
row.dataset.formationRepartitionAnnuelle = Number(agent.formation_repartition_annuelle) ? '1' : '0';
row.dataset.commentairePta = agent.commentaire_pta || '';
row.dataset.structureId = agent.id_structure ? String(Number(agent.id_structure)) : '';
}
function addAgentEverywhere(agent) {
const overviewAgent = document.querySelector('#overview-agent');
addOptionIfMissing(
overviewAgent,
Number(agent.id_agent),
`${agent.prenom} ${agent.nom} (${agent.matricule})`
);
const body = document.querySelector('#agent-assignment-body');
if (body && !body.querySelector(`tr[data-agent-id="${Number(agent.id_agent)}"]`)) {
const row = document.createElement('tr');
setAgentRowData(row, agent);
row.innerHTML = agentRowHtml(agent);
body.appendChild(row);
}
}
function toggleDiplomaField(select, input) {
if (!select || !input) return;
const qualified = select.value === '1';
input.disabled = !qualified;
if (!qualified) input.value = '';
}
function openAgentProfileModal(row) {
const modal = document.querySelector('#agent-profile-modal');
if (!modal || !row) return;
document.querySelector('#edit-agent-profile-id').value = row.dataset.agentId || '';
document.querySelector('#agent-profile-modal-subtitle').textContent = row.querySelector('.agent-row-name')?.textContent?.trim() || '';
document.querySelector('#edit-agent-profile-email').value = row.dataset.email || '';
document.querySelector('#edit-agent-profile-phone').value = row.dataset.telephone || '';
document.querySelector('#edit-agent-profile-address').value = row.dataset.adresse || '';
document.querySelector('#edit-agent-profile-qualified').value = row.dataset.estDiplome === '1' ? '1' : '0';
document.querySelector('#edit-agent-profile-diploma').value = row.dataset.diplomeLibelle || '';
document.querySelector('#edit-agent-profile-contract-start').value = row.dataset.dateDebutContrat || '';
document.querySelector('#edit-agent-profile-contract-end').value = row.dataset.dateFinContrat || '';
document.querySelector('#edit-agent-profile-post').value = row.dataset.idPoste || '';
document.querySelector('#edit-agent-profile-contract-type').value = row.dataset.typeContrat || 'PERMANENT';
document.querySelector('#edit-agent-profile-training-planned').checked = row.dataset.formationRepartitionAnnuelle === '1';
document.querySelector('#edit-agent-profile-pta-comment').value = row.dataset.commentairePta || '';
document.querySelector('#edit-agent-profile-structure').value = row.dataset.structureId || '';
toggleDiplomaField(document.querySelector('#edit-agent-profile-qualified'), document.querySelector('#edit-agent-profile-diploma'));
const message = document.querySelector('#agent-profile-message');
if (message) {
message.className = 'message';
message.textContent = '';
}
modal.hidden = false;
}
function closeAgentProfileModal() {
const modal = document.querySelector('#agent-profile-modal');
if (modal) modal.hidden = true;
}
async function saveAgentProfile(event) {
event.preventDefault();
const form = event.currentTarget;
const message = document.querySelector('#agent-profile-message');
const submit = form.querySelector('button[type="submit"]');
const agentId = Number(document.querySelector('#edit-agent-profile-id').value);
if (!agentId || !message || !submit) return;
submit.disabled = true;
message.className = 'message';
message.textContent = '';
try {
const structureValue = document.querySelector('#edit-agent-profile-structure').value;
const response = await fetch('api/update_agent.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
agent_id: agentId,
email: document.querySelector('#edit-agent-profile-email').value,
telephone: document.querySelector('#edit-agent-profile-phone').value,
adresse: document.querySelector('#edit-agent-profile-address').value,
est_diplome: document.querySelector('#edit-agent-profile-qualified').value === '1',
diplome_libelle: document.querySelector('#edit-agent-profile-diploma').value,
date_debut_contrat: document.querySelector('#edit-agent-profile-contract-start').value,
date_fin_contrat: document.querySelector('#edit-agent-profile-contract-end').value,
id_poste: Number(document.querySelector('#edit-agent-profile-post').value),
type_contrat: document.querySelector('#edit-agent-profile-contract-type').value,
formation_repartition_annuelle: document.querySelector('#edit-agent-profile-training-planned').checked,
commentaire_pta: document.querySelector('#edit-agent-profile-pta-comment').value,
structure_id: structureValue ? Number(structureValue) : null,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de modifier les informations de lagent.'));
const row = document.querySelector(`#agent-assignment-body tr[data-agent-id="${agentId}"]`);
if (row) {
setAgentRowData(row, data.agent);
row.innerHTML = agentRowHtml({
...data.agent,
structure_nom: data.agent.structure_principale_nom,
});
}
message.className = 'message message-success';
message.textContent = data.message;
if (structureSelect?.value) await PTA.modules.planning?.loadAgents?.();
if (document.querySelector('#overview-structure')?.value) await PTA.modules.structureOverview?.load?.();
if (document.querySelector('#overview-agent')?.value === String(agentId)) await PTA.modules.agentOverview?.load?.();
setTimeout(closeAgentProfileModal, 500);
} catch (error) {
message.className = 'message message-error';
message.textContent = error.message;
} finally {
submit.disabled = false;
}
}
async function createStructure(event) {
event.preventDefault();
const form = event.currentTarget;
const message = document.querySelector('#create-structure-message');
const submit = form.querySelector('button[type="submit"]');
if (!message || !submit) return;
message.className = 'message';
message.textContent = '';
submit.disabled = true;
try {
const response = await fetch('api/create_structure.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
code: document.querySelector('#new-structure-code').value,
nom: document.querySelector('#new-structure-name').value,
adresse: document.querySelector('#new-structure-address').value,
type_affectation: document.querySelector('#new-structure-type').value,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de créer le lieu daffectation.'));
addStructureEverywhere(data.structure);
addStructureSettingsRow(data.structure);
form.reset();
document.querySelector('#new-structure-type').value = 'PERISCOLAIRE';
message.className = 'message message-success';
message.textContent = data.message;
PTA.modules.coverage?.refresh?.({ silent: true });
} catch (error) {
message.className = 'message message-error';
message.textContent = error.message;
} finally {
submit.disabled = false;
}
}
async function createAgent(event) {
event.preventDefault();
const form = event.currentTarget;
const message = document.querySelector('#create-agent-message');
const submit = form.querySelector('button[type="submit"]');
if (!message || !submit) return;
message.className = 'message';
message.textContent = '';
submit.disabled = true;
try {
const response = await fetch('api/create_agent.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
matricule: document.querySelector('#new-agent-matricule').value,
nom: document.querySelector('#new-agent-name').value,
prenom: document.querySelector('#new-agent-firstname').value,
email: document.querySelector('#new-agent-email').value,
telephone: document.querySelector('#new-agent-phone').value,
adresse: document.querySelector('#new-agent-address').value,
est_diplome: document.querySelector('#new-agent-qualified').value === '1',
diplome_libelle: document.querySelector('#new-agent-diploma').value,
structure_id: Number(document.querySelector('#new-agent-structure').value),
quotite_travail: Number(document.querySelector('#new-agent-rate').value),
date_debut_contrat: document.querySelector('#new-agent-contract-start').value,
date_fin_contrat: document.querySelector('#new-agent-contract-end').value,
id_poste: Number(document.querySelector('#new-agent-post').value),
type_contrat: document.querySelector('#new-agent-contract-type').value,
formation_repartition_annuelle: document.querySelector('#new-agent-training-planned').checked,
commentaire_pta: document.querySelector('#new-agent-pta-comment').value,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de créer lagent.'));
addAgentEverywhere(data.agent);
const createdStructureId = String(data.agent.id_structure);
form.reset();
document.querySelector('#new-agent-rate').value = '100';
document.querySelector('#new-agent-contract-type').value = 'PERMANENT';
document.querySelector('#new-agent-training-planned').checked = false;
document.querySelector('#new-agent-qualified').value = '0';
toggleDiplomaField(document.querySelector('#new-agent-qualified'), document.querySelector('#new-agent-diploma'));
message.className = 'message message-success';
message.textContent = `${data.message} Quota annuel : ${formatDuration(Number(data.agent.quota_cible_minutes))}, suivi du 1er janvier au 31 décembre.`;
if (structureSelect?.value === createdStructureId) {
await PTA.modules.planning?.loadAgents?.();
}
if (document.querySelector('#overview-structure')?.value === createdStructureId) {
await PTA.modules.structureOverview?.load?.();
}
} catch (error) {
message.className = 'message message-error';
message.textContent = error.message;
} finally {
submit.disabled = false;
}
}
function init() {
document.querySelector('#agent-assignment-body')?.addEventListener('click', (event) => {
const button = event.target.closest('.edit-agent-profile');
if (button) openAgentProfileModal(button.closest('tr[data-agent-id]'));
});
document.querySelector('#structure-settings-body')?.addEventListener('click', (event) => {
const button = event.target.closest('.save-structure-type');
if (button) saveStructureType(button);
});
document.querySelector('#new-agent-qualified')?.addEventListener('change', (event) => {
toggleDiplomaField(event.currentTarget, document.querySelector('#new-agent-diploma'));
});
document.querySelector('#edit-agent-profile-qualified')?.addEventListener('change', (event) => {
toggleDiplomaField(event.currentTarget, document.querySelector('#edit-agent-profile-diploma'));
});
document.querySelector('#close-agent-profile-modal')?.addEventListener('click', closeAgentProfileModal);
document.querySelector('#cancel-agent-profile')?.addEventListener('click', closeAgentProfileModal);
document.querySelector('#agent-profile-modal')?.addEventListener('click', (event) => {
if (event.target.id === 'agent-profile-modal') closeAgentProfileModal();
});
document.querySelector('#agent-profile-form')?.addEventListener('submit', saveAgentProfile);
document.querySelector('#create-structure-form')?.addEventListener('submit', createStructure);
document.querySelector('#create-agent-form')?.addEventListener('submit', createAgent);
toggleDiplomaField(document.querySelector('#new-agent-qualified'), document.querySelector('#new-agent-diploma'));
}
PTA.modules.administration = {
init,
saveAgentStructure,
saveStructureType,
addStructureEverywhere,
addAgentEverywhere,
openAgentProfileModal,
};
})();

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 };
})();

View File

@@ -0,0 +1,306 @@
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) return;
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils;
const el = (id) => document.getElementById(id);
const escapeAttr = (value) => String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('\"', '&quot;')
.replaceAll("'", '&#039;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
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 na é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 dabord la vue annuelle de lagent à 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 lanné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 nest 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 };
})();

175
assets/js/core.js Normal file
View File

@@ -0,0 +1,175 @@
(() => {
'use strict';
const elements = {
structureSelect: document.querySelector('#structure'),
agentSelect: document.querySelector('#agent'),
weekInput: document.querySelector('#week'),
weeklyEntryBody: document.querySelector('#weekly-entry-body'),
addButton: document.querySelector('#add-entry'),
saveButton: document.querySelector('#save-draft'),
validateButton: document.querySelector('#validate-planning'),
modifyButton: document.querySelector('#modify-planning'),
planningBoard: document.querySelector('#planning-board'),
emptyState: document.querySelector('#empty-state'),
weekLabel: document.querySelector('#week-label'),
workTotal: document.querySelector('#work-total'),
statusBadge: document.querySelector('#planning-status'),
messageBox: document.querySelector('#message'),
openAgentPdfButton: document.querySelector('#open-agent-pdf'),
openStructurePdfButton: document.querySelector('#open-structure-pdf'),
quotaPanel: document.querySelector('#editor-quota'),
quotaRate: document.querySelector('#quota-rate'),
quotaTarget: document.querySelector('#quota-target'),
quotaPeriod: document.querySelector('#quota-period'),
quotaUsed: document.querySelector('#quota-used'),
quotaUsedDetail: document.querySelector('#quota-used-detail'),
quotaRemaining: document.querySelector('#quota-remaining'),
};
const state = {
entries: [],
otherStructureEntries: [],
currentStructure: null,
planningId: null,
planningStatus: null,
weekDays: [],
quota: null,
savedCurrentCountedMinutes: 0,
};
const shared = {
lastAgentOverviewData: null,
};
const dayNames = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi'];
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = String(value ?? '');
return div.innerHTML;
}
function showMessage(text, type = 'info') {
if (!elements.messageBox) return;
elements.messageBox.className = `message message-${type}`;
elements.messageBox.textContent = text;
}
function clearMessage() {
if (!elements.messageBox) return;
elements.messageBox.className = 'message';
elements.messageBox.textContent = '';
}
async function readJsonResponse(response) {
const raw = await response.text();
if (!raw.trim()) {
throw new Error(`Le serveur a renvoyé une réponse vide (HTTP ${response.status}).`);
}
try {
return JSON.parse(raw);
} catch (error) {
const readable = raw
.replace(/<br\s*\/?\s*>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&quot;/gi, '"')
.replace(/&#039;/gi, "'")
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&amp;/gi, '&')
.replace(/\s+/g, ' ')
.trim();
throw new Error(
`Le serveur PHP n'a pas renvoyé du JSON valide. ${readable.slice(0, 500) || 'Consultez les logs PHP.'}`
);
}
}
function apiErrorMessage(data, fallback) {
const base = data?.error || fallback;
return data?.details ? `${base} Détail : ${data.details}` : base;
}
function toIsoDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function parseIsoWeek(value) {
const match = /^(\d{4})-W(\d{2})$/.exec(value);
if (!match) return [];
const year = Number(match[1]);
const week = Number(match[2]);
const jan4 = new Date(year, 0, 4);
const jan4Day = jan4.getDay() || 7;
const monday = new Date(jan4);
monday.setDate(jan4.getDate() - jan4Day + 1 + (week - 1) * 7);
return Array.from({ length: 5 }, (_, index) => {
const date = new Date(monday);
date.setDate(monday.getDate() + index);
return {
date: toIsoDate(date),
label: dayNames[index],
display: date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }),
};
});
}
function timeToMinutes(time) {
const [hours, minutes] = String(time).split(':').map(Number);
return hours * 60 + minutes;
}
function formatDuration(minutes) {
const sign = minutes < 0 ? '-' : '';
const absolute = Math.abs(Math.round(minutes));
const hours = Math.floor(absolute / 60);
const mins = absolute % 60;
return `${sign}${hours} h ${String(mins).padStart(2, '0')}`;
}
function formatDate(value) {
if (!value) return '';
const [year, month, day] = String(value).split('-');
return `${day}/${month}/${year}`;
}
function motifInfo(id) {
const motif = (window.PTA_CONFIG?.motifs || []).find((item) => Number(item.id) === Number(id));
return motif ? {
id: Number(motif.id),
code: motif.code,
label: motif.label,
quota: Boolean(motif.quota),
} : null;
}
window.PTA = {
config: window.PTA_CONFIG || {},
elements,
state,
shared,
modules: {},
utils: {
escapeHtml,
showMessage,
clearMessage,
readJsonResponse,
apiErrorMessage,
parseIsoWeek,
timeToMinutes,
formatDuration,
formatDate,
motifInfo,
},
};
})();

284
assets/js/coverage.js Normal file
View File

@@ -0,0 +1,284 @@
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant coverage.js');
const { elements, utils } = PTA;
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = utils;
const badge = document.querySelector('#coverage-tab-badge');
const weekInput = document.querySelector('#coverage-week');
const structureFilter = document.querySelector('#coverage-structure-filter');
const refreshButton = document.querySelector('#refresh-coverage-alerts');
const loadingElement = document.querySelector('#coverage-loading');
const emptyElement = document.querySelector('#coverage-empty');
const contentElement = document.querySelector('#coverage-content');
const bodyElement = document.querySelector('#coverage-alerts-body');
const messageElement = document.querySelector('#coverage-message');
const unconfiguredElement = document.querySelector('#coverage-unconfigured');
const ruleStructureSelect = document.querySelector('#coverage-rules-structure');
const ruleEditor = document.querySelector('#coverage-rules-editor');
const ruleMessage = document.querySelector('#coverage-rules-message');
const loadRulesButton = document.querySelector('#load-coverage-rules');
const saveRulesButton = document.querySelector('#save-coverage-rules');
const dayNames = {
1: 'Lundi',
2: 'Mardi',
3: 'Mercredi',
4: 'Jeudi',
5: 'Vendredi',
};
let alerts = [];
let loading = false;
function setBadge(count) {
if (!badge) return;
badge.textContent = String(count);
badge.hidden = count === 0;
badge.setAttribute('aria-label', `${count} plage${count > 1 ? 's' : ''} avec couverture insuffisante`);
}
function setMessage(text = '', type = 'info') {
if (!messageElement) return;
messageElement.className = text ? `message message-${type}` : 'message';
messageElement.textContent = text;
}
function renderAlerts(data) {
alerts = Array.isArray(data.alerts) ? data.alerts : [];
const summary = data.summary || {};
const unconfigured = Array.isArray(data.unconfigured_structures) ? data.unconfigured_structures : [];
setBadge(alerts.length);
document.querySelector('#coverage-gap-count').textContent = String(Number(summary.gap_count || 0));
document.querySelector('#coverage-structure-count').textContent = String(Number(summary.affected_structure_count || 0));
document.querySelector('#coverage-gap-hours').textContent = formatDuration(Number(summary.gap_minutes || 0));
document.querySelector('#coverage-unconfigured-count').textContent = String(Number(summary.unconfigured_structure_count || 0));
if (loadingElement) loadingElement.hidden = true;
if (emptyElement) emptyElement.hidden = alerts.length !== 0;
if (contentElement) contentElement.hidden = alerts.length === 0;
if (bodyElement) {
bodyElement.innerHTML = alerts.map((alert, index) => {
const missing = Number(alert.agents_manquants || 0);
return `
<tr class="coverage-alert-row" data-alert-index="${index}">
<td>
<strong>${escapeHtml(alert.structure_nom)}</strong>
<small>${escapeHtml(alert.structure_code || '')}</small>
</td>
<td>
<strong>${escapeHtml(alert.jour_libelle)}</strong>
<small>${formatDate(alert.date)}</small>
</td>
<td><strong>${escapeHtml(alert.heure_debut)}${escapeHtml(alert.heure_fin)}</strong></td>
<td>${Number(alert.minimum_agents)}</td>
<td>${Number(alert.agents_planifies)}</td>
<td><span class="coverage-missing-chip">${missing} agent${missing > 1 ? 's' : ''}</span></td>
<td class="coverage-action-cell"><button type="button" class="button button-primary open-coverage-gap" data-alert-index="${index}">Planifier un agent</button></td>
</tr>`;
}).join('');
}
if (unconfiguredElement) {
unconfiguredElement.hidden = unconfigured.length === 0;
unconfiguredElement.innerHTML = unconfigured.length ? `
<strong>Couverture non configurée pour ${unconfigured.length} lieu${unconfigured.length > 1 ? 'x' : ''}</strong>
<p>${unconfigured.map((item) => escapeHtml(item.nom)).join(', ')}. Configurez les plages attendues dans la page Administration pour pouvoir détecter les créneaux vides.</p>
` : '';
}
}
async function refresh(options = {}) {
if (loading) return;
const week = weekInput?.value;
if (!week) return;
loading = true;
if (!options.silent) setMessage('');
if (loadingElement) loadingElement.hidden = false;
if (refreshButton) refreshButton.disabled = true;
try {
const params = new URLSearchParams({ week });
if (structureFilter?.value) params.set('structure_id', structureFilter.value);
const response = await fetch(`api/coverage_alerts.php?${params.toString()}`, { headers: { Accept: 'application/json' } });
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible danalyser la couverture.'));
renderAlerts(data);
} catch (error) {
if (loadingElement) loadingElement.hidden = true;
setMessage(error.message, 'error');
} finally {
loading = false;
if (refreshButton) refreshButton.disabled = false;
}
}
function openGap(alert) {
if (!alert) return;
const params = new URLSearchParams({
structure_id: String(alert.id_structure),
week: weekInput?.value || '',
notice: `Couverture insuffisante le ${alert.jour_libelle.toLowerCase()} ${formatDate(alert.date)} de ${alert.heure_debut} à ${alert.heure_fin} sur ${alert.structure_nom}. Choisissez un agent à affecter.`,
});
window.location.href = `planning.php?${params.toString()}`;
}
function coverageRuleRow(rule = {}) {
return `
<div class="coverage-rule-row">
<label class="field compact-field">
<span>Début</span>
<input class="coverage-rule-start" type="time" step="900" value="${escapeHtml(rule.heure_debut || '')}" required>
</label>
<label class="field compact-field">
<span>Fin</span>
<input class="coverage-rule-end" type="time" step="900" value="${escapeHtml(rule.heure_fin || '')}" required>
</label>
<label class="field compact-field coverage-rule-minimum-field">
<span>Agents min.</span>
<input class="coverage-rule-minimum" type="number" min="1" max="50" step="1" value="${Number(rule.minimum_agents || 1)}" required>
</label>
<button type="button" class="button button-danger remove-coverage-rule" aria-label="Supprimer cette plage">Supprimer</button>
</div>`;
}
function renderRuleEditor(rules = []) {
if (!ruleEditor) return;
const grouped = new Map();
for (let day = 1; day <= 5; day += 1) grouped.set(day, []);
rules.forEach((rule) => grouped.get(Number(rule.jour_semaine))?.push(rule));
ruleEditor.innerHTML = Array.from(grouped.entries()).map(([day, dayRules]) => `
<section class="coverage-rule-day" data-day="${day}">
<div class="coverage-rule-day-header">
<strong>${dayNames[day]}</strong>
<button type="button" class="button button-secondary add-coverage-rule">+ Ajouter une plage</button>
</div>
<div class="coverage-rule-day-ranges">
${dayRules.length ? dayRules.map(coverageRuleRow).join('') : '<p class="coverage-rule-empty">Aucune couverture obligatoire configurée.</p>'}
</div>
</section>`).join('');
if (saveRulesButton) saveRulesButton.disabled = false;
}
async function loadRules() {
const structureId = ruleStructureSelect?.value;
if (!structureId || !ruleEditor) {
if (ruleEditor) ruleEditor.innerHTML = '<div class="empty-state">Choisissez un lieu daffectation.</div>';
if (saveRulesButton) saveRulesButton.disabled = true;
return;
}
if (ruleMessage) {
ruleMessage.className = 'message';
ruleMessage.textContent = '';
}
ruleEditor.innerHTML = '<div class="empty-state">Chargement des règles de couverture…</div>';
try {
const response = await fetch(`api/structure_coverage_rules.php?structure_id=${encodeURIComponent(structureId)}`);
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les règles de couverture.'));
renderRuleEditor(data.rules || []);
} catch (error) {
ruleEditor.innerHTML = '';
if (ruleMessage) {
ruleMessage.className = 'message message-error';
ruleMessage.textContent = error.message;
}
}
}
function collectRules() {
const rules = [];
ruleEditor?.querySelectorAll('.coverage-rule-day').forEach((daySection) => {
const day = Number(daySection.dataset.day);
daySection.querySelectorAll('.coverage-rule-row').forEach((row) => {
const start = row.querySelector('.coverage-rule-start')?.value || '';
const end = row.querySelector('.coverage-rule-end')?.value || '';
const minimum = Number(row.querySelector('.coverage-rule-minimum')?.value || 1);
if (!start && !end) return;
rules.push({ jour_semaine: day, heure_debut: start, heure_fin: end, minimum_agents: minimum });
});
});
return rules;
}
async function saveRules() {
const structureId = ruleStructureSelect?.value;
if (!structureId || !saveRulesButton || !ruleMessage) return;
saveRulesButton.disabled = true;
ruleMessage.className = 'message';
ruleMessage.textContent = '';
try {
const response = await fetch('api/save_structure_coverage_rules.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
structure_id: Number(structureId),
rules: collectRules(),
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible denregistrer les règles de couverture.'));
renderRuleEditor(data.rules || []);
ruleMessage.className = 'message message-success';
ruleMessage.textContent = data.message;
await refresh({ silent: true });
if (document.querySelector('#overview-structure')?.value === String(structureId)) {
await PTA.modules.structureOverview?.load?.();
}
} catch (error) {
ruleMessage.className = 'message message-error';
ruleMessage.textContent = error.message;
} finally {
saveRulesButton.disabled = false;
}
}
function init() {
refreshButton?.addEventListener('click', () => refresh());
weekInput?.addEventListener('change', () => refresh());
structureFilter?.addEventListener('change', () => refresh());
bodyElement?.addEventListener('click', (event) => {
const button = event.target.closest('.open-coverage-gap');
if (!button) return;
openGap(alerts[Number(button.dataset.alertIndex)]);
});
ruleStructureSelect?.addEventListener('change', loadRules);
loadRulesButton?.addEventListener('click', loadRules);
saveRulesButton?.addEventListener('click', saveRules);
ruleEditor?.addEventListener('click', (event) => {
const addButton = event.target.closest('.add-coverage-rule');
if (addButton) {
const day = addButton.closest('.coverage-rule-day');
const ranges = day?.querySelector('.coverage-rule-day-ranges');
if (!ranges) return;
ranges.querySelector('.coverage-rule-empty')?.remove();
ranges.insertAdjacentHTML('beforeend', coverageRuleRow());
return;
}
const removeButton = event.target.closest('.remove-coverage-rule');
if (removeButton) {
const ranges = removeButton.closest('.coverage-rule-day-ranges');
removeButton.closest('.coverage-rule-row')?.remove();
if (ranges && !ranges.querySelector('.coverage-rule-row')) {
ranges.innerHTML = '<p class="coverage-rule-empty">Aucune couverture obligatoire configurée.</p>';
}
}
});
refresh({ silent: true });
}
PTA.modules.coverage = { init, refresh, loadRules };
})();

View File

@@ -0,0 +1,80 @@
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) return;
const page = window.PTA_PAGE_CONFIG || {};
const modules = Array.isArray(page.modules) ? page.modules : [];
const params = page.params || {};
modules.forEach((name) => PTA.modules[name]?.init?.());
async function initialisePlanning() {
const planning = PTA.modules.planning;
if (!planning) return;
const { structureSelect, agentSelect, weekInput, validateButton } = PTA.elements;
if (params.structureId && structureSelect) {
structureSelect.value = String(params.structureId);
await planning.loadAgents();
if (params.structureLocked) structureSelect.disabled = true;
}
if (params.agentId && agentSelect) {
agentSelect.value = String(params.agentId);
if (weekInput) weekInput.disabled = false;
}
if (params.week && weekInput) {
weekInput.disabled = !agentSelect?.value;
weekInput.value = params.week;
if (agentSelect?.value) await planning.handleWeekSelection();
}
if (params.notice) PTA.utils.showMessage(params.notice, 'info');
if (params.focus === 'validation') {
window.setTimeout(() => {
document.querySelector('#editor-view .actions-bar')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
validateButton?.focus({ preventScroll: true });
}, 150);
}
}
async function initialiseStructureOverview() {
if (!params.structureId) return;
const select = document.querySelector('#overview-structure');
if (select) {
select.value = String(params.structureId);
if (params.structureLocked) select.disabled = true;
}
await PTA.modules.structureOverview?.load?.();
}
async function initialiseAgentOverview() {
if (params.agentId) {
const select = document.querySelector('#overview-agent');
if (select) {
select.value = String(params.agentId);
if (params.agentLocked) select.disabled = true;
}
}
if (params.month) {
const input = document.querySelector('#agent-overview-month');
if (input) input.value = params.month;
}
if (params.agentId) await PTA.modules.agentOverview?.load?.();
}
function initialiseCoverage() {
if (!params.structureId) return;
const select = document.querySelector('#coverage-structure-filter');
if (select) {
select.value = String(params.structureId);
if (params.structureLocked) select.disabled = true;
PTA.modules.coverage?.refresh?.();
}
}
initialisePlanning();
initialiseStructureOverview();
initialiseAgentOverview();
initialiseCoverage();
})();

View File

@@ -0,0 +1,165 @@
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant pending-validation.js');
const { utils } = PTA;
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = utils;
const badge = document.querySelector('#pending-validation-tab-badge');
const refreshButton = document.querySelector('#refresh-pending-validation');
const countElement = document.querySelector('#pending-validation-count');
const hoursElement = document.querySelector('#pending-validation-hours');
const countedHoursElement = document.querySelector('#pending-validation-counted-hours');
const loadingElement = document.querySelector('#pending-validation-loading');
const emptyElement = document.querySelector('#pending-validation-empty');
const contentElement = document.querySelector('#pending-validation-content');
const bodyElement = document.querySelector('#pending-validation-body');
const messageElement = document.querySelector('#pending-validation-message');
let items = [];
let loading = false;
function weekValue(item) {
return `${item.annee}-W${String(item.numero_semaine).padStart(2, '0')}`;
}
function formatDateTime(value) {
if (!value) return '—';
const normalized = String(value).replace(' ', 'T');
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return escapeHtml(value);
return date.toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function structureTypeLabel(value) {
return value === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire';
}
function setBadge(count) {
if (!badge) return;
badge.textContent = String(count);
badge.hidden = count === 0;
badge.setAttribute('aria-label', `${count} planning${count > 1 ? 's' : ''} en attente de validation`);
}
function setMessage(text = '', type = 'info') {
if (!messageElement) return;
messageElement.className = text ? `message message-${type}` : 'message';
messageElement.textContent = text;
}
function render(data) {
items = Array.isArray(data.plannings) ? data.plannings : [];
const summary = data.summary || {};
const count = Number(summary.count ?? items.length);
setBadge(count);
if (countElement) countElement.textContent = String(count);
if (hoursElement) hoursElement.textContent = formatDuration(Number(summary.total_minutes || 0));
if (countedHoursElement) countedHoursElement.textContent = formatDuration(Number(summary.counted_minutes || 0));
if (loadingElement) loadingElement.hidden = true;
if (emptyElement) emptyElement.hidden = items.length !== 0;
if (contentElement) contentElement.hidden = items.length === 0;
if (!bodyElement) return;
bodyElement.innerHTML = items.map((item) => {
const week = weekValue(item);
const rowId = Number(item.id_planning);
return `
<tr class="pending-validation-row" data-planning-id="${rowId}" tabindex="0" role="button" aria-label="Ouvrir le planning de ${escapeHtml(item.agent_prenom)} ${escapeHtml(item.agent_nom)} pour la semaine ${escapeHtml(week)}">
<td>
<strong>${escapeHtml(item.agent_prenom)} ${escapeHtml(item.agent_nom)}</strong>
<small>${escapeHtml(item.matricule || '')}</small>
</td>
<td>
<strong>${escapeHtml(item.structure_nom)}</strong>
<small>${escapeHtml(structureTypeLabel(item.structure_type_affectation))}</small>
</td>
<td>
<strong>Semaine ${Number(item.numero_semaine)}</strong>
<small>${formatDate(item.date_debut)}${formatDate(item.date_fin_ouvrable || item.date_fin)}</small>
</td>
<td><span class="pending-count-chip">${Number(item.nombre_creneaux)}</span></td>
<td>
<strong>${formatDuration(Number(item.total_minutes || 0))}</strong>
<small>${formatDuration(Number(item.minutes_decomptees || 0))} décomptées</small>
</td>
<td>${formatDateTime(item.date_modification)}</td>
<td class="pending-validation-action-cell">
<button class="button button-success open-pending-planning" type="button" data-planning-id="${rowId}">Consulter et valider</button>
</td>
</tr>`;
}).join('');
}
async function refresh(options = {}) {
if (loading) return;
loading = true;
if (!options.silent) setMessage('');
if (loadingElement) {
loadingElement.hidden = false;
if (emptyElement) emptyElement.hidden = true;
if (contentElement) contentElement.hidden = true;
}
if (refreshButton) refreshButton.disabled = true;
try {
const response = await fetch('api/pending_plannings.php', { headers: { Accept: 'application/json' } });
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les plannings en attente de validation.'));
render(data);
} catch (error) {
if (loadingElement) loadingElement.hidden = true;
setMessage(error.message, 'error');
} finally {
loading = false;
if (refreshButton) refreshButton.disabled = false;
}
}
function openPlanning(item) {
if (!item) return;
const params = new URLSearchParams({
structure_id: String(item.id_structure),
agent_id: String(item.id_agent),
week: weekValue(item),
focus: 'validation',
notice: `Planning de ${item.agent_prenom} ${item.agent_nom} chargé depuis la file de validation.`,
});
window.location.href = `planning.php?${params.toString()}`;
}
function findItemFromEventTarget(target) {
const trigger = target.closest('[data-planning-id]');
if (!trigger) return null;
const id = Number(trigger.dataset.planningId);
return items.find((item) => Number(item.id_planning) === id) || null;
}
function init() {
refreshButton?.addEventListener('click', () => refresh());
bodyElement?.addEventListener('click', (event) => {
const item = findItemFromEventTarget(event.target);
if (item) openPlanning(item);
});
bodyElement?.addEventListener('keydown', (event) => {
if (!['Enter', ' '].includes(event.key)) return;
if (event.target.closest('button')) return;
const row = event.target.closest('.pending-validation-row');
if (!row) return;
event.preventDefault();
const item = findItemFromEventTarget(row);
if (item) openPlanning(item);
});
refresh({ silent: true });
}
PTA.modules.pendingValidation = { init, refresh, openPlanning };
})();

1130
assets/js/planning-editor.js Normal file

File diff suppressed because it is too large Load Diff

184
assets/js/pta.js Normal file
View File

@@ -0,0 +1,184 @@
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) return;
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = PTA.utils;
let current = null;
const el = (id) => document.getElementById(id);
const selectedAgentId = () => Number(el('pta-agent')?.value || 0);
function show(text, type = 'info') {
const box = el('pta-message');
if (!box) return;
box.className = `message message-${type}`;
box.textContent = text;
}
function card(label, value, detail = '') {
return `<article class="summary-card"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong>${detail ? `<small>${escapeHtml(detail)}</small>` : ''}</article>`;
}
function renderSummary(data) {
current = data;
const agent = data.agent || {};
const quota = data.quota || {};
const counters = data.counters || {};
el('pta-content').hidden = false;
el('pta-agent-name').textContent = `${agent.prenom || ''} ${agent.nom || ''}`.trim();
el('pta-agent-meta').textContent = [
agent.matricule,
agent.poste_libelle || 'Poste non renseigné',
agent.structure_principale_nom || 'Sans lieu principal',
agent.type_contrat === 'PERMANENT' ? 'Contrat permanent' : 'Contrat non permanent',
].filter(Boolean).join(' — ');
el('pta-summary-cards').innerHTML = [
card('Année civile PTA', `${formatDate(quota.date_debut_periode)}${formatDate(quota.date_fin_periode)}`),
card('Quotité', `${Number(quota.quotite_travail || 0).toLocaleString('fr-FR')} %`, `Référence : ${formatDuration(Number(quota.quota_reference_minutes || 96420))}`),
card('Quota à réaliser', quota.quota_cible || formatDuration(Number(quota.quota_cible_minutes || 0))),
card('Déjà affecté', quota.affectees || formatDuration(Number(quota.minutes_affectees || 0))),
card('Reste à affecter', quota.restantes || formatDuration(Number(quota.minutes_restantes || 0))),
card('Formation', `${counters.formation_duree || '0 h 00'} / ${counters.formation_cible_duree || '0 h 00'}`),
card('Congés annuels', `${Number(counters.conges_annuels_jours || 0)} / ${Number(counters.conges_annuels_cible || 25)} jours`),
card('Fractionnement', `${Number(counters.jours_fractionnement || 0)} / ${Number(counters.jours_fractionnement_cible || 2)} jours`),
].join('');
el('pta-category-body').innerHTML = (data.categories || []).map((category) => `
<tr><td><strong>${escapeHtml(category.libelle)}</strong></td><td>${escapeHtml(category.duree)}</td><td>${Number(category.jours || 0)}</td></tr>
`).join('') || '<tr><td colspan="3">Aucune affectation sur la période.</td></tr>';
el('pta-controls').innerHTML = (data.controls || []).map((control) => {
const level = String(control.niveau || 'INFO').toLowerCase();
return `<article class="control-item control-${escapeHtml(level)}"><strong>${escapeHtml(control.niveau || 'INFO')}</strong><span>${escapeHtml(control.message || '')}</span></article>`;
}).join('') || '<div class="empty-state">Aucun contrôle disponible.</div>';
renderConstraints(data.constraints || []);
renderWishes(data.wishes || []);
const refDate = el('pta-reference-date').value;
el('pta-open-agent-view').href = `agents.php?agent_id=${Number(agent.id_agent)}&month=${encodeURIComponent(refDate.slice(0, 7))}`;
el('pta-open-planning').href = `planning.php?agent_id=${Number(agent.id_agent)}`;
el('pta-constraint-start').value ||= quota.date_debut_periode || '';
el('pta-constraint-end').value ||= quota.date_fin_periode || '';
}
function renderConstraints(items) {
el('pta-constraint-list').innerHTML = items.map((item) => `
<article class="stack-item">
<div><strong>${item.type_contrainte === 'TEMPS_PARTIEL_THERAPEUTIQUE' ? 'Temps partiel thérapeutique' : 'Préconisation médicale'}</strong>
<p>${escapeHtml(formatDate(item.date_debut))}${item.date_fin ? escapeHtml(formatDate(item.date_fin)) : 'sans fin'}${escapeHtml(item.commentaire)}</p></div>
<button class="button button-danger pta-delete-constraint" type="button" data-id="${Number(item.id_contrainte)}">Supprimer</button>
</article>
`).join('') || '<div class="empty-state">Aucune contrainte active sur cette période.</div>';
}
function renderWishes(items) {
el('pta-wish-list').innerHTML = items.map((item) => `
<article class="stack-item">
<div><strong>${item.type_souhait === 'INTERDICTION' ? 'Interdiction' : 'Préférence'}${escapeHtml(item.structure_nom)}</strong>
<p>Priorité ${Number(item.priorite)}${item.distance_km !== null ? `${Number(item.distance_km).toLocaleString('fr-FR')} km` : ''}${item.commentaire ? `${escapeHtml(item.commentaire)}` : ''}</p></div>
<button class="button button-danger pta-delete-wish" type="button" data-id="${Number(item.id_souhait)}">Supprimer</button>
</article>
`).join('') || '<div class="empty-state">Aucune préférence ou interdiction enregistrée.</div>';
}
async function load() {
const agentId = selectedAgentId();
const date = el('pta-reference-date')?.value;
if (!agentId || !date) {
show('Choisissez un agent et une date de référence.', 'error');
return;
}
show('Calcul du PTA en cours…');
try {
const response = await fetch(`api/pta_summary.php?agent_id=${agentId}&date=${encodeURIComponent(date)}`);
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger le PTA.'));
renderSummary(data);
show('PTA actualisé.', 'success');
} catch (error) {
show(error.message, 'error');
}
}
async function post(url, payload) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, ...payload }),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Opération impossible.'));
return data;
}
async function saveConstraint(event) {
event.preventDefault();
if (!selectedAgentId()) return;
try {
await post('api/pta_constraint.php', {
agent_id: selectedAgentId(),
type_contrainte: el('pta-constraint-type').value,
date_debut: el('pta-constraint-start').value,
date_fin: el('pta-constraint-end').value,
quotite_temporaire: el('pta-constraint-rate').value,
maximum_heures_jour: el('pta-constraint-day-max').value,
maximum_heures_semaine: el('pta-constraint-week-max').value,
interdit_matin: el('pta-forbid-morning').checked,
interdit_midi: el('pta-forbid-midday').checked,
interdit_soir: el('pta-forbid-evening').checked,
commentaire: el('pta-constraint-comment').value,
});
event.currentTarget.reset();
await load();
} catch (error) { show(error.message, 'error'); }
}
async function saveWish(event) {
event.preventDefault();
if (!selectedAgentId()) return;
try {
await post('api/pta_wish.php', {
agent_id: selectedAgentId(),
structure_id: Number(el('pta-wish-structure').value),
type_souhait: el('pta-wish-type').value,
priorite: Number(el('pta-wish-priority').value),
distance_km: el('pta-wish-distance').value,
commentaire: el('pta-wish-comment').value,
});
event.currentTarget.reset();
el('pta-wish-priority').value = '1';
await load();
} catch (error) { show(error.message, 'error'); }
}
async function handleDelete(event) {
const constraint = event.target.closest('.pta-delete-constraint');
const wish = event.target.closest('.pta-delete-wish');
if (!constraint && !wish) return;
if (!window.confirm('Confirmer la suppression ?')) return;
try {
if (constraint) {
await post('api/pta_constraint.php', { action: 'DELETE', agent_id: selectedAgentId(), constraint_id: Number(constraint.dataset.id) });
} else {
await post('api/pta_wish.php', { action: 'DELETE', agent_id: selectedAgentId(), wish_id: Number(wish.dataset.id) });
}
await load();
} catch (error) { show(error.message, 'error'); }
}
function init() {
el('load-pta')?.addEventListener('click', load);
el('pta-constraint-form')?.addEventListener('submit', saveConstraint);
el('pta-wish-form')?.addEventListener('submit', saveWish);
el('pta-content')?.addEventListener('click', handleDelete);
const params = window.PTA_PAGE_CONFIG?.params || {};
if (params.agentId) el('pta-agent').value = String(params.agentId);
if (params.date) el('pta-reference-date').value = params.date;
if (params.agentId) load();
}
PTA.modules.pta = { init, load };
})();

View File

@@ -0,0 +1,177 @@
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant structure-overview.js');
const { openStructurePdfButton } = PTA.elements;
const { escapeHtml, readJsonResponse, apiErrorMessage, parseIsoWeek, formatDate } = PTA.utils;
const typeLabel = (type) => type === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire';
function entryBadge(entry) {
return `
<div class="overview-entry motif-${escapeHtml(String(entry.motif_code || 'autre').toLowerCase())}">
<strong>${escapeHtml(entry.heure_debut)}${escapeHtml(entry.heure_fin)}</strong>
<span>${escapeHtml(entry.motif_libelle)}</span>
${entry.statut === 'BROUILLON' ? '<em>Brouillon</em>' : ''}
</div>
`;
}
function coveragePanel(coverage) {
if (!coverage?.configured) {
return `
<div class="coverage-inline-notice coverage-inline-info">
<strong>Couverture non configurée</strong>
<span>Définissez les horaires de présence minimale dans Administration pour détecter automatiquement les créneaux vides.</span>
</div>`;
}
const gaps = Array.isArray(coverage.gaps) ? coverage.gaps : [];
if (!gaps.length) {
return `
<div class="coverage-inline-notice coverage-inline-success">
<strong>Couverture complète</strong>
<span>Aucun manque dagent détecté sur les plages de couverture configurées.</span>
</div>`;
}
const items = gaps.slice(0, 8).map((gap) => `
<li><strong>${escapeHtml(gap.jour_libelle)} ${escapeHtml(gap.heure_debut)}${escapeHtml(gap.heure_fin)}</strong> : ${Number(gap.agents_manquants)} agent${Number(gap.agents_manquants) > 1 ? 's' : ''} manquant${Number(gap.agents_manquants) > 1 ? 's' : ''}</li>
`).join('');
const more = gaps.length > 8 ? `<li>… et ${gaps.length - 8} autre${gaps.length - 8 > 1 ? 's' : ''} plage${gaps.length - 8 > 1 ? 's' : ''}.</li>` : '';
return `
<div class="coverage-inline-notice coverage-inline-danger">
<strong>⚠ ${gaps.length} plage${gaps.length > 1 ? 's' : ''} insuffisamment couverte${gaps.length > 1 ? 's' : ''}</strong>
<ul>${items}${more}</ul>
</div>`;
}
function render(data) {
const container = document.querySelector('#structure-overview-content');
const empty = document.querySelector('#structure-overview-empty');
const days = parseIsoWeek(data.week.value);
if (!container || !empty) return;
empty.hidden = true;
container.hidden = false;
if (!data.agents.length) {
container.innerHTML = `
<div class="overview-title-row">
<div>
<h3>${escapeHtml(data.structure.nom)}</h3>
<p>${typeLabel(data.structure.type_affectation)} · semaine du ${formatDate(days[0]?.date)} au ${formatDate(days[days.length - 1]?.date)}</p>
</div>
<span class="overview-count">0 agent</span>
</div>
${coveragePanel(data.coverage)}
<div class="empty-state">Aucun agent nest rattaché ou planifié sur ce lieu pour cette semaine.</div>`;
return;
}
const headerDays = days.map((day) => `<th><strong>${day.label}</strong><span>${day.display}</span></th>`).join('');
const rows = data.agents.map((agent) => {
const cells = days.map((day) => {
const entries = agent.entries.filter((entry) => entry.date_jour === day.date);
return `<td>${entries.length ? entries.map((entry) => entryBadge(entry)).join('') : '<span class="cell-empty">—</span>'}</td>`;
}).join('');
return `
<tr>
<th class="agent-cell">
<strong>${escapeHtml(agent.prenom)} ${escapeHtml(agent.nom)}</strong>
<span>${escapeHtml(agent.matricule)}</span>
<small>${agent.lieu_principal ? 'Lieu principal' : 'Affectation ponctuelle'}</small>
</th>
${cells}
<td class="quota-cell">
<strong>${escapeHtml(agent.quota.restantes.libelle)}</strong>
<span>sur ${escapeHtml(agent.quota.quota_cible.libelle)}</span>
<small>${Number(agent.quota.quotite_travail).toLocaleString('fr-FR')} %</small>
</td>
</tr>
`;
}).join('');
container.innerHTML = `
<div class="overview-title-row">
<div>
<h3>${escapeHtml(data.structure.nom)}</h3>
<p>${typeLabel(data.structure.type_affectation)} · semaine du ${formatDate(days[0]?.date)} au ${formatDate(days[days.length - 1]?.date)}</p>
</div>
<span class="overview-count">${data.agents.length} agent${data.agents.length > 1 ? 's' : ''}</span>
</div>
${coveragePanel(data.coverage)}
<div class="table-scroll">
<table class="planning-table">
<thead>
<tr>
<th>Agent</th>
${headerDays}
<th>Quota restant</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
function updatePdfButton() {
if (!openStructurePdfButton) return;
const structureId = document.querySelector('#overview-structure')?.value;
const week = document.querySelector('#overview-week')?.value;
openStructurePdfButton.disabled = !(structureId && week);
}
function openPdf() {
const structureId = document.querySelector('#overview-structure')?.value;
const week = document.querySelector('#overview-week')?.value;
if (!structureId || !week) return;
const params = new URLSearchParams({ structure_id: structureId, week });
window.open(`api/structure_planning_pdf.php?${params.toString()}`, '_blank', 'noopener');
}
async function load() {
const structureId = document.querySelector('#overview-structure')?.value;
const week = document.querySelector('#overview-week')?.value;
const container = document.querySelector('#structure-overview-content');
const empty = document.querySelector('#structure-overview-empty');
updatePdfButton();
if (!container || !empty) return;
if (!structureId || !week) {
empty.hidden = false;
empty.textContent = 'Choisissez un lieu daffectation et une semaine.';
container.hidden = true;
return;
}
empty.hidden = false;
empty.textContent = 'Chargement du planning du lieu…';
container.hidden = true;
try {
const params = new URLSearchParams({ structure_id: structureId, week });
const response = await fetch(`api/structure_overview.php?${params.toString()}`);
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger la vue du lieu.'));
render(data);
} catch (error) {
empty.hidden = false;
empty.textContent = error.message;
}
}
function init() {
document.querySelector('#load-structure-overview')?.addEventListener('click', load);
document.querySelector('#overview-structure')?.addEventListener('change', load);
document.querySelector('#overview-week')?.addEventListener('change', load);
openStructurePdfButton?.addEventListener('click', openPdf);
updatePdfButton();
}
PTA.modules.structureOverview = { init, load, render, updatePdfButton };
})();

158
assets/js/teams.js Normal file
View File

@@ -0,0 +1,158 @@
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) return;
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils;
const el = (id) => document.getElementById(id);
function message(text, type = 'info') {
const box = el('team-list-message');
if (!box) return;
box.className = `message message-${type}`;
box.textContent = text;
}
async function api(url, options = {}) {
const response = await fetch(url, options);
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Opération impossible.'));
return data;
}
function controlHtml(control) {
const level = String(control.niveau || 'INFO').toLowerCase();
return `<div class="control-item control-${escapeHtml(level)}"><strong>${escapeHtml(control.niveau)}</strong><span>${escapeHtml(control.message)}</span></div>`;
}
function teamHtml(team) {
const options = el('team-agent-options')?.innerHTML || '';
const members = (team.members || []).map((member) => `
<tr>
<td><strong>${escapeHtml(member.prenom)} ${escapeHtml(member.nom)}</strong><br><small>${escapeHtml(member.poste_libelle || 'Poste non renseigné')}</small></td>
<td>${member.est_diplome ? `Diplômé${member.diplome_libelle ? `${escapeHtml(member.diplome_libelle)}` : ''}` : 'Non diplômé'}</td>
<td>${escapeHtml(member.fonction_equipe)}</td>
<td>${escapeHtml(member.structure_principale_nom || 'Sans lieu principal')}</td>
<td><button type="button" class="button button-danger remove-team-member" data-agent-id="${Number(member.id_agent)}">Retirer</button></td>
</tr>
`).join('') || '<tr><td colspan="5">Aucun agent affecté.</td></tr>';
return `
<article class="card team-card" data-team-id="${Number(team.id_equipe)}">
<div class="section-heading">
<div><h3>${escapeHtml(team.libelle)}</h3><p class="section-subtitle">${escapeHtml(team.structure_nom)}${formatDate(team.date_debut)} au ${formatDate(team.date_fin)}${escapeHtml(team.type_equipe)}</p></div>
<select class="team-status" aria-label="Statut"><option value="BROUILLON" ${team.statut === 'BROUILLON' ? 'selected' : ''}>Brouillon</option><option value="A_CONTROLER" ${team.statut === 'A_CONTROLER' ? 'selected' : ''}>À contrôler</option><option value="VALIDEE" ${team.statut === 'VALIDEE' ? 'selected' : ''}>Validée</option></select>
</div>
<div class="summary-grid compact-summary">
<article class="summary-card"><span>Agents requis</span><strong>${Number(team.agents_requis)}</strong></article>
<article class="summary-card"><span>Agents affectés</span><strong>${Number(team.agents_affectes)}</strong></article>
<article class="summary-card"><span>Diplômés</span><strong>${Number(team.agents_diplomes || 0)}</strong></article>
<article class="summary-card"><span>Enfants</span><strong>${Number(team.nombre_enfants_moins_6) + Number(team.nombre_enfants_6_plus)}</strong></article>
</div>
<div class="control-list">${(team.controls || []).map(controlHtml).join('')}</div>
<form class="team-member-form inline-form">
<label class="field"><span>Agent</span><select class="team-member-agent" required><option value="">Choisir un agent</option>${options}</select></label>
<label class="field"><span>Fonction dans léquipe</span><select class="team-member-function"><option value="RESPONSABLE">Responsable</option><option value="ANIMATION">Animation</option><option value="RESTAURATION_ENTRETIEN">Restauration / entretien</option></select></label>
<label class="field"><span>Commentaire</span><input class="team-member-comment" type="text" maxlength="500"></label>
<button class="button button-primary" type="submit">Ajouter</button>
</form>
<div class="table-scroll"><table class="planning-table"><thead><tr><th>Agent</th><th>Qualification</th><th>Fonction</th><th>Lieu principal</th><th>Action</th></tr></thead><tbody>${members}</tbody></table></div>
</article>`;
}
async function load() {
const start = el('team-filter-start')?.value || '';
const end = el('team-filter-end')?.value || '';
const query = new URLSearchParams();
if (start) query.set('date_debut', start);
if (end) query.set('date_fin', end);
message('Chargement des équipes…');
try {
const data = await api(`api/teams.php?${query.toString()}`);
el('team-list').innerHTML = (data.teams || []).map(teamHtml).join('') || '<div class="empty-state">Aucune équipe sur cette période.</div>';
message(`${(data.teams || []).length} équipe(s) affichée(s).`, 'success');
} catch (error) { message(error.message, 'error'); }
}
async function create(event) {
event.preventDefault();
const box = el('team-create-message');
try {
const data = await api('api/teams.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
structure_id: Number(el('team-structure').value),
type_equipe: el('team-type').value,
libelle: el('team-label').value,
date_debut: el('team-start').value,
date_fin: el('team-end').value,
nombre_enfants_moins_6: Number(el('team-under-six').value),
nombre_enfants_6_plus: Number(el('team-over-six').value),
ratio_moins_6: Number(el('team-ratio-under-six').value),
ratio_6_plus: Number(el('team-ratio-over-six').value),
minimum_agents: Number(el('team-min-agents').value),
pourcentage_diplomes_min: Number(el('team-qualified-rate').value),
commentaire: el('team-comment').value,
}),
});
box.className = 'message message-success'; box.textContent = data.message;
event.currentTarget.reset();
el('team-ratio-under-six').value = '8'; el('team-ratio-over-six').value = '12'; el('team-min-agents').value = '1'; el('team-qualified-rate').value = '50';
await load();
} catch (error) { box.className = 'message message-error'; box.textContent = error.message; }
}
async function handle(event) {
const card = event.target.closest('.team-card');
if (!card) return;
const teamId = Number(card.dataset.teamId);
if (event.type === 'submit' && event.target.matches('.team-member-form')) {
event.preventDefault();
try {
await api('api/team_members.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken, team_id: teamId,
agent_id: Number(card.querySelector('.team-member-agent').value),
fonction_equipe: card.querySelector('.team-member-function').value,
commentaire: card.querySelector('.team-member-comment').value,
}) });
await load();
} catch (error) { message(error.message, 'error'); }
return;
}
const remove = event.target.closest('.remove-team-member');
if (remove) {
if (!window.confirm('Retirer cet agent de léquipe ?')) return;
try {
await api('api/team_members.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken, action: 'REMOVE', team_id: teamId, agent_id: Number(remove.dataset.agentId),
}) });
await load();
} catch (error) { message(error.message, 'error'); }
}
}
async function status(event) {
const select = event.target.closest('.team-status');
if (!select) return;
const card = select.closest('.team-card');
try {
await api('api/team_status.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken, team_id: Number(card.dataset.teamId), statut: select.value,
}) });
await load();
} catch (error) { message(error.message, 'error'); }
}
function init() {
const params = window.PTA_PAGE_CONFIG?.params || {};
if (params.dateDebut) el('team-filter-start').value = params.dateDebut;
if (params.dateFin) el('team-filter-end').value = params.dateFin;
el('team-create-form')?.addEventListener('submit', create);
el('load-teams')?.addEventListener('click', load);
el('team-list')?.addEventListener('submit', handle);
el('team-list')?.addEventListener('click', handle);
el('team-list')?.addEventListener('change', status);
load();
}
PTA.modules.teams = { init, load };
})();

View File

@@ -0,0 +1,263 @@
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant vacation-calendar.js');
const { escapeHtml, readJsonResponse, apiErrorMessage, formatDate } = PTA.utils;
let previewPeriods = [];
const elements = () => ({
academy: document.querySelector('#vacation-academy'),
calendarYear: document.querySelector('#vacation-calendar-year'),
previewButton: document.querySelector('#preview-vacations'),
previewMessage: document.querySelector('#vacation-preview-message'),
previewPanel: document.querySelector('#vacation-preview-panel'),
previewBody: document.querySelector('#vacation-preview-body'),
selectAll: document.querySelector('#vacation-select-all'),
saveSelected: document.querySelector('#save-selected-vacations'),
savedBody: document.querySelector('#vacation-saved-body'),
savedMessage: document.querySelector('#vacation-saved-message'),
refresh: document.querySelector('#refresh-vacations'),
manualForm: document.querySelector('#manual-vacation-form'),
manualMessage: document.querySelector('#manual-vacation-message'),
});
function setMessage(element, text = '', type = 'info') {
if (!element) return;
element.className = text ? `message message-${type}` : 'message';
element.textContent = text;
}
function sourceLabel(source) {
return source === 'DATA_GOUV' ? 'API officielle' : 'Saisie manuelle';
}
function renderPreview() {
const el = elements();
if (!el.previewBody || !el.previewPanel) return;
if (!previewPeriods.length) {
el.previewBody.innerHTML = '<tr><td colspan="5"><div class="empty-state">Aucune période proposée.</div></td></tr>';
el.previewPanel.hidden = false;
return;
}
el.previewBody.innerHTML = previewPeriods.map((period, index) => `
<tr class="vacation-preview-row" data-index="${index}">
<td><input class="vacation-import-check" type="checkbox" aria-label="Importer ${escapeHtml(period.libelle)}"></td>
<td>
<input class="vacation-preview-label" type="text" maxlength="150" value="${escapeHtml(period.libelle)}">
<small>${escapeHtml(period.annee_scolaire || '')}</small>
</td>
<td><input class="vacation-preview-start" type="date" value="${escapeHtml(period.date_debut)}"></td>
<td>
<input class="vacation-preview-end" type="date" value="${escapeHtml(period.date_fin)}">
${period.date_reprise_api ? `<small>Reprise API : ${escapeHtml(formatDate(period.date_reprise_api))}</small>` : ''}
</td>
<td>${escapeHtml([period.zone, period.academie].filter(Boolean).join(' · ') || 'Non précisé')}</td>
</tr>
`).join('');
el.previewPanel.hidden = false;
if (el.selectAll) el.selectAll.checked = false;
}
async function preview() {
const el = elements();
const academy = el.academy?.value.trim() || '';
const calendarYear = String(el.calendarYear?.value || '').trim();
if (!academy || !/^\d{4}$/.test(calendarYear)) {
setMessage(el.previewMessage, 'Renseignez une académie et une année civile au format 2026.', 'warning');
return;
}
el.previewButton.disabled = true;
setMessage(el.previewMessage, 'Consultation de lAPI officielle en cours…', 'info');
if (el.previewPanel) el.previewPanel.hidden = true;
try {
const params = new URLSearchParams({ academy, year: calendarYear });
const response = await fetch(`api/preview_school_holidays.php?${params.toString()}`);
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de consulter le calendrier scolaire.'));
previewPeriods = Array.isArray(data.periods) ? data.periods : [];
renderPreview();
const warnings = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : [];
const baseMessage = data.message || `${previewPeriods.length} période(s) proposée(s). Aucune donnée na encore été enregistrée.`;
const fullMessage = warnings.length ? `${baseMessage} Attention : ${warnings.join(' ')}` : baseMessage;
setMessage(el.previewMessage, fullMessage, warnings.length ? 'warning' : (previewPeriods.length ? 'success' : 'warning'));
} catch (error) {
previewPeriods = [];
setMessage(el.previewMessage, error.message, 'error');
} finally {
el.previewButton.disabled = false;
}
}
function selectedPreviewPeriods() {
const el = elements();
return Array.from(el.previewBody?.querySelectorAll('.vacation-preview-row') || [])
.filter((row) => row.querySelector('.vacation-import-check')?.checked)
.map((row) => {
const source = previewPeriods[Number(row.dataset.index)] || {};
return {
libelle: row.querySelector('.vacation-preview-label')?.value.trim() || '',
date_debut: row.querySelector('.vacation-preview-start')?.value || '',
date_fin: row.querySelector('.vacation-preview-end')?.value || '',
annee_scolaire: source.annee_scolaire || '',
academie: source.academie || elements().academy?.value.trim() || '',
zone: source.zone || '',
source: 'DATA_GOUV',
};
});
}
async function savePeriods(periods, messageElement) {
const response = await fetch('api/save_vacation_periods.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
periods,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible denregistrer les périodes.'));
setMessage(messageElement, data.message, 'success');
await loadSaved();
return data;
}
async function saveSelected() {
const el = elements();
const periods = selectedPreviewPeriods();
if (!periods.length) {
setMessage(el.previewMessage, 'Cochez au moins une période avant de lenregistrer.', 'warning');
return;
}
el.saveSelected.disabled = true;
try {
await savePeriods(periods, el.previewMessage);
el.previewBody?.querySelectorAll('.vacation-import-check').forEach((checkbox) => { checkbox.checked = false; });
if (el.selectAll) el.selectAll.checked = false;
} catch (error) {
setMessage(el.previewMessage, error.message, 'error');
} finally {
el.saveSelected.disabled = false;
}
}
async function saveManual(event) {
event.preventDefault();
const el = elements();
const submit = el.manualForm?.querySelector('button[type="submit"]');
const period = {
libelle: document.querySelector('#manual-vacation-label')?.value.trim() || '',
date_debut: document.querySelector('#manual-vacation-start')?.value || '',
date_fin: document.querySelector('#manual-vacation-end')?.value || '',
annee_scolaire: document.querySelector('#manual-vacation-school-year')?.value.trim() || '',
academie: document.querySelector('#manual-vacation-academy')?.value.trim() || '',
zone: document.querySelector('#manual-vacation-zone')?.value.trim() || '',
source: 'MANUEL',
};
if (period.date_debut && period.date_fin && period.date_fin < period.date_debut) {
setMessage(el.manualMessage, 'La date de fin doit être postérieure ou égale à la date de début.', 'warning');
return;
}
if (submit) submit.disabled = true;
try {
await savePeriods([period], el.manualMessage);
document.querySelector('#manual-vacation-label').value = '';
document.querySelector('#manual-vacation-start').value = '';
document.querySelector('#manual-vacation-end').value = '';
document.querySelector('#manual-vacation-zone').value = '';
} catch (error) {
setMessage(el.manualMessage, error.message, 'error');
} finally {
if (submit) submit.disabled = false;
}
}
function renderSaved(periods) {
const el = elements();
if (!el.savedBody) return;
if (!periods.length) {
el.savedBody.innerHTML = '<tr><td colspan="6"><div class="empty-state">Aucune période de vacances nest encore enregistrée.</div></td></tr>';
return;
}
el.savedBody.innerHTML = periods.map((period) => `
<tr data-period-id="${Number(period.id_periode)}">
<td><strong>${escapeHtml(period.libelle)}</strong></td>
<td>${escapeHtml(formatDate(period.date_debut))}${escapeHtml(formatDate(period.date_fin))}</td>
<td>${escapeHtml(period.annee_scolaire || '')}</td>
<td>${escapeHtml([period.academie, period.zone].filter(Boolean).join(' · ') || 'Non précisé')}</td>
<td><span class="vacation-source-badge">${escapeHtml(sourceLabel(period.source))}</span></td>
<td><button type="button" class="button button-danger delete-vacation-period">Supprimer</button></td>
</tr>
`).join('');
}
async function loadSaved() {
const el = elements();
try {
const response = await fetch('api/vacation_periods.php');
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les périodes enregistrées.'));
renderSaved(Array.isArray(data.periods) ? data.periods : []);
setMessage(el.savedMessage, '', 'info');
} catch (error) {
setMessage(el.savedMessage, error.message, 'error');
}
}
async function deletePeriod(button) {
const row = button.closest('tr[data-period-id]');
const id = Number(row?.dataset.periodId || 0);
if (!id || !confirm('Supprimer cette période de vacances ? Les journées concernées seront à nouveau considérées comme périscolaires.')) return;
button.disabled = true;
try {
const response = await fetch('api/delete_vacation_period.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
period_id: id,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Suppression impossible.'));
setMessage(elements().savedMessage, data.message, 'success');
await loadSaved();
} catch (error) {
setMessage(elements().savedMessage, error.message, 'error');
button.disabled = false;
}
}
function init() {
const el = elements();
el.previewButton?.addEventListener('click', preview);
el.saveSelected?.addEventListener('click', saveSelected);
el.refresh?.addEventListener('click', loadSaved);
el.manualForm?.addEventListener('submit', saveManual);
el.selectAll?.addEventListener('change', () => {
el.previewBody?.querySelectorAll('.vacation-import-check').forEach((checkbox) => {
checkbox.checked = el.selectAll.checked;
});
});
el.savedBody?.addEventListener('click', (event) => {
const button = event.target.closest('.delete-vacation-period');
if (button) deletePeriod(button);
});
loadSaved();
}
PTA.modules.vacationCalendar = { init, loadSaved, preview };
})();

691
assets/js/week-templates.js Normal file
View File

@@ -0,0 +1,691 @@
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant week-templates.js');
const { state } = PTA;
const { agentSelect, weekInput } = PTA.elements;
const { showMessage, readJsonResponse, apiErrorMessage, escapeHtml } = PTA.utils;
const saveButton = document.querySelector('#save-week-template');
const applyButton = document.querySelector('#apply-week-template');
const saveModal = document.querySelector('#save-week-template-modal');
const saveForm = document.querySelector('#save-week-template-form');
const saveNameInput = document.querySelector('#week-template-name');
const saveContext = document.querySelector('#save-week-template-context');
const saveMessage = document.querySelector('#save-week-template-message');
const applyModal = document.querySelector('#apply-week-template-modal');
const applyForm = document.querySelector('#apply-week-template-form');
const templateSelect = document.querySelector('#week-template-select');
const templateDetails = document.querySelector('#week-template-details');
const replaceWarning = document.querySelector('#week-template-replace-warning');
const deleteButton = document.querySelector('#delete-week-template');
const applyMessage = document.querySelector('#apply-week-template-message');
const periodButton = document.querySelector('#apply-week-template-period');
const periodModal = document.querySelector('#apply-week-template-period-modal');
const periodForm = document.querySelector('#apply-week-template-period-form');
const periodTemplateSelect = document.querySelector('#week-template-period-select');
const periodStartInput = document.querySelector('#week-template-period-start');
const periodEndInput = document.querySelector('#week-template-period-end');
const periodReplaceWarning = document.querySelector('#week-template-period-replace-warning');
const periodPreviewButton = document.querySelector('#preview-week-template-period');
const periodPreviewPanel = document.querySelector('#week-template-period-preview');
const periodConfirmButton = document.querySelector('#confirm-apply-week-template-period');
const periodMessage = document.querySelector('#apply-week-template-period-message');
let templates = [];
let lastPeriodPreview = null;
let lastPeriodPreviewSignature = '';
function contextReady() {
return Boolean(agentSelect?.value && weekInput?.value);
}
function globalEntryCount() {
return (state.entries?.length || 0) + (state.otherStructureEntries?.length || 0);
}
function refreshButtons() {
const ready = contextReady();
if (saveButton) saveButton.disabled = !ready;
if (applyButton) applyButton.disabled = !ready;
if (periodButton) periodButton.disabled = !ready;
}
function setInlineMessage(element, text = '', type = 'info') {
if (!element) return;
element.className = text ? `message message-${type}` : 'message';
element.textContent = text;
}
function updateBodyModalState() {
const anyOpen = Array.from(document.querySelectorAll('.modal-backdrop'))
.some((modal) => !modal.hidden);
document.body.classList.toggle('modal-open', anyOpen);
}
function closeModal(modal) {
if (!modal) return;
modal.hidden = true;
updateBodyModalState();
}
function openModal(modal) {
if (!modal) return;
modal.hidden = false;
document.body.classList.add('modal-open');
}
function agentLabel() {
return agentSelect?.options[agentSelect.selectedIndex]?.textContent?.trim() || 'Agent sélectionné';
}
async function persistCurrentEditorIfNeeded() {
const planning = PTA.modules.planning;
if (!planning || state.planningStatus === 'VALIDE') return true;
if (!planning.isPlanningContextReady?.()) return true;
const planningId = await planning.saveDraft?.({ silent: true });
return planningId !== null && planningId !== undefined;
}
async function openSaveModal() {
if (!contextReady()) {
showMessage('Sélectionnez un agent et une semaine avant denregistrer une semaine type.', 'warning');
return;
}
if (state.planningStatus !== 'VALIDE') {
const synced = PTA.modules.planning?.syncEntriesFromWeeklyForm?.({ silent: true });
if (synced === false) return;
}
if (globalEntryCount() === 0) {
showMessage('La semaine ne contient aucune affectation à enregistrer comme modèle.', 'warning');
return;
}
if (saveNameInput) saveNameInput.value = '';
if (saveContext) {
saveContext.innerHTML = `
<strong>${escapeHtml(agentLabel())}</strong>
<span>Semaine ${escapeHtml(weekInput.value)} · ${globalEntryCount()} créneau${globalEntryCount() > 1 ? 'x' : ''} actuellement affiché${globalEntryCount() > 1 ? 's' : ''}</span>
`;
}
setInlineMessage(saveMessage);
openModal(saveModal);
setTimeout(() => saveNameInput?.focus(), 0);
}
async function saveTemplate(replace = false) {
const name = saveNameInput?.value?.trim() || '';
if (!name) {
setInlineMessage(saveMessage, 'Saisissez un nom pour la semaine type.', 'warning');
saveNameInput?.focus();
return;
}
const submitButton = saveForm?.querySelector('button[type="submit"]');
if (submitButton) submitButton.disabled = true;
setInlineMessage(saveMessage, 'Enregistrement du modèle...', 'info');
try {
const persisted = await persistCurrentEditorIfNeeded();
if (!persisted) {
throw new Error('Le planning courant na pas pu être enregistré avant la création du modèle.');
}
const response = await fetch('api/save_week_template.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
agent_id: Number(agentSelect.value),
week: weekInput.value,
name,
replace,
}),
});
const data = await readJsonResponse(response);
if (response.status === 409 && data.code === 'TEMPLATE_EXISTS' && !replace) {
const confirmed = window.confirm(`Une semaine type nommée « ${name} » existe déjà pour cet agent. Voulez-vous la remplacer par la semaine actuelle ?`);
if (confirmed) {
await saveTemplate(true);
} else {
setInlineMessage(saveMessage, 'Enregistrement annulé.', 'info');
}
return;
}
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible denregistrer la semaine type.'));
setInlineMessage(saveMessage, data.message, 'success');
showMessage(data.message, 'success');
setTimeout(() => closeModal(saveModal), 450);
} catch (error) {
setInlineMessage(saveMessage, error.message, 'error');
} finally {
if (submitButton) submitButton.disabled = false;
}
}
async function loadTemplates() {
if (!agentSelect?.value) return [];
const response = await fetch(`api/week_templates.php?agent_id=${encodeURIComponent(agentSelect.value)}`);
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les semaines types.'));
templates = Array.isArray(data.templates) ? data.templates : [];
return templates;
}
function renderTemplateSelect() {
if (!templateSelect) return;
if (!templates.length) {
templateSelect.innerHTML = '<option value="">Aucune semaine type enregistrée</option>';
templateSelect.disabled = true;
if (deleteButton) deleteButton.disabled = true;
if (templateDetails) templateDetails.innerHTML = '<p class="hint">Créez dabord une semaine type depuis une semaine déjà planifiée.</p>';
return;
}
templateSelect.disabled = false;
templateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
`<option value="${template.id}">${escapeHtml(template.name)}</option>`
)).join('');
renderSelectedTemplateDetails();
}
function renderSelectedTemplateDetails() {
const selectedId = Number(templateSelect?.value || 0);
const template = templates.find((item) => Number(item.id) === selectedId);
if (deleteButton) deleteButton.disabled = !template;
if (!templateDetails) return;
if (!template) {
templateDetails.innerHTML = '<p class="hint">Sélectionnez un modèle pour lappliquer à la semaine affichée.</p>';
return;
}
templateDetails.innerHTML = `
<div class="week-template-detail-card">
<strong>${escapeHtml(template.name)}</strong>
<span>${Number(template.entry_count)} créneau${Number(template.entry_count) > 1 ? 'x' : ''} · ${Number(template.location_count)} lieu${Number(template.location_count) > 1 ? 'x' : ''}</span>
</div>
`;
}
async function openApplyModal() {
if (!contextReady()) {
showMessage('Sélectionnez un agent et la semaine cible avant dimporter une semaine type.', 'warning');
return;
}
setInlineMessage(applyMessage, 'Chargement des modèles...', 'info');
if (templateSelect) {
templateSelect.disabled = true;
templateSelect.innerHTML = '<option value="">Chargement...</option>';
}
openModal(applyModal);
try {
await loadTemplates();
renderTemplateSelect();
setInlineMessage(applyMessage);
} catch (error) {
setInlineMessage(applyMessage, error.message, 'error');
}
}
async function applyTemplate() {
const templateId = Number(templateSelect?.value || 0);
const mode = applyForm?.querySelector('input[name="week-template-mode"]:checked')?.value || 'merge';
const template = templates.find((item) => Number(item.id) === templateId);
if (!templateId || !template) {
setInlineMessage(applyMessage, 'Sélectionnez une semaine type.', 'warning');
return;
}
if (mode === 'replace') {
const confirmed = window.confirm(
`Le mode « Remplacer » va supprimer toutes les affectations actuelles de ${agentLabel()} pour ${weekInput.value}, tous lieux confondus, puis appliquer « ${template.name} ».\n\nContinuer ?`
);
if (!confirmed) return;
}
const submitButton = applyForm?.querySelector('button[type="submit"]');
if (submitButton) submitButton.disabled = true;
if (deleteButton) deleteButton.disabled = true;
setInlineMessage(applyMessage, 'Import de la semaine type...', 'info');
try {
// En mode compléter, persister d'abord les éventuelles modifications locales non encore enregistrées.
if (mode === 'merge') {
const persisted = await persistCurrentEditorIfNeeded();
if (!persisted) {
throw new Error('Le planning courant na pas pu être enregistré avant limport.');
}
}
const response = await fetch('api/apply_week_template.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
template_id: templateId,
agent_id: Number(agentSelect.value),
week: weekInput.value,
mode,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible dimporter la semaine type.'));
if (data.quota) state.quota = data.quota;
await PTA.modules.planning?.loadPlanning?.();
setInlineMessage(applyMessage, data.message, 'success');
showMessage(data.message, 'success');
setTimeout(() => closeModal(applyModal), 550);
} catch (error) {
setInlineMessage(applyMessage, error.message, 'error');
} finally {
if (submitButton) submitButton.disabled = false;
renderSelectedTemplateDetails();
}
}
function periodMode() {
return periodForm?.querySelector('input[name="week-template-period-mode"]:checked')?.value || 'merge';
}
function periodFilter() {
return periodForm?.querySelector('input[name="week-template-period-filter"]:checked')?.value || 'all';
}
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(Date.UTC(year, 0, 4));
const jan4Day = jan4.getUTCDay() || 7;
const monday = new Date(jan4);
monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + (week - 1) * 7);
return monday;
}
function dateInputValue(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '';
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatDateFr(value) {
if (!value) return '';
const date = new Date(`${value}T12:00:00`);
return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
}
function invalidatePeriodPreview() {
lastPeriodPreview = null;
lastPeriodPreviewSignature = '';
if (periodConfirmButton) periodConfirmButton.disabled = true;
if (periodPreviewPanel) {
periodPreviewPanel.hidden = true;
periodPreviewPanel.innerHTML = '';
}
setInlineMessage(periodMessage);
updatePeriodReplaceWarning();
}
function currentPeriodSignature() {
return JSON.stringify({
template_id: Number(periodTemplateSelect?.value || 0),
start_date: periodStartInput?.value || '',
end_date: periodEndInput?.value || '',
mode: periodMode(),
period_filter: periodFilter(),
});
}
function renderPeriodTemplateSelect() {
if (!periodTemplateSelect) return;
if (!templates.length) {
periodTemplateSelect.innerHTML = '<option value="">Aucune semaine type enregistrée</option>';
periodTemplateSelect.disabled = true;
return;
}
periodTemplateSelect.disabled = false;
periodTemplateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
`<option value="${template.id}">${escapeHtml(template.name)}</option>`
)).join('');
}
function setDefaultPeriodDates() {
const monday = weekMonday(weekInput?.value);
if (!monday) return;
if (periodStartInput && !periodStartInput.value) periodStartInput.value = dateInputValue(monday);
if (periodEndInput && !periodEndInput.value) {
const friday = new Date(monday);
friday.setUTCDate(monday.getUTCDate() + 4);
periodEndInput.value = dateInputValue(friday);
}
}
async function openPeriodModal() {
if (!contextReady()) {
showMessage('Sélectionnez un agent et une semaine avant dappliquer une semaine type sur une période.', 'warning');
return;
}
invalidatePeriodPreview();
setDefaultPeriodDates();
if (periodTemplateSelect) {
periodTemplateSelect.disabled = true;
periodTemplateSelect.innerHTML = '<option value="">Chargement...</option>';
}
openModal(periodModal);
setInlineMessage(periodMessage, 'Chargement des modèles...', 'info');
try {
await loadTemplates();
renderPeriodTemplateSelect();
setInlineMessage(periodMessage);
} catch (error) {
setInlineMessage(periodMessage, error.message, 'error');
}
}
function updatePeriodReplaceWarning() {
if (periodReplaceWarning) periodReplaceWarning.hidden = periodMode() !== 'replace';
}
function renderPeriodPreview(preview) {
if (!periodPreviewPanel) return;
const summary = preview.summary || {};
const conflicts = Array.isArray(preview.conflicts) ? preview.conflicts : [];
const days = Array.isArray(preview.days) ? preview.days : [];
const vacations = Array.isArray(preview.vacation_periods) ? preview.vacation_periods : [];
const mode = preview.mode || periodMode();
const filter = preview.period_filter || periodFilter();
const filterLabel = filter === 'school'
? 'Périodes scolaires uniquement'
: filter === 'extra'
? 'Périodes extrascolaires uniquement'
: 'Toutes les semaines';
const vacationHtml = vacations.length
? `<div class="week-template-period-vacations"><strong>Vacances prises en compte</strong><ul>${vacations.map((item) => `<li>${escapeHtml(item.label)} : ${formatDateFr(item.start_date)}${formatDateFr(item.end_date)}</li>`).join('')}</ul></div>`
: filter === 'extra'
? '<div class="week-template-period-alert is-warning"><strong>Aucune période de vacances enregistrée sur cette plage.</strong> Aucun jour ne pourra être considéré comme extrascolaire tant que le calendrier nest pas renseigné.</div>'
: '<div class="week-template-period-alert is-warning"><strong>Aucune période de vacances enregistrée sur cette plage.</strong> Les dates seront considérées comme scolaires.</div>';
const relevantDays = days.filter((day) => Number(day.eligible_count) > 0 || Number(day.skipped_count) > 0 || Number(day.conflict_count) > 0);
const dayRows = relevantDays.slice(0, 80).map((day) => {
const periodLabel = day.period_type === 'EXTRASCOLAIRE'
? `Extrascolaire${day.vacation_label ? ` · ${escapeHtml(day.vacation_label)}` : ''}`
: 'Périscolaire';
return `<tr>
<td>${formatDateFr(day.date)}</td>
<td><span class="weekly-period-badge ${day.period_type === 'EXTRASCOLAIRE' ? 'is-extra' : 'is-peri'}">${periodLabel}</span></td>
<td>${Number(day.eligible_count)}</td>
<td>${Number(day.skipped_count)}</td>
<td>${Number(day.conflict_count) || '—'}</td>
</tr>`;
}).join('');
const conflictHtml = conflicts.length
? `<div class="week-template-period-alert is-error"><strong>${conflicts.length} conflit${conflicts.length > 1 ? 's' : ''} détecté${conflicts.length > 1 ? 's' : ''}.</strong> ${mode === 'merge' ? 'Le mode « Compléter » ne peut pas être appliqué tant que ces conflits existent. Vous pouvez choisir « Remplacer les jours concernés » après vérification.' : 'Ces créneaux existants seront remplacés sur les jours concernés.'}</div>`
: '<div class="week-template-period-alert is-success"><strong>Aucun chevauchement détecté.</strong></div>';
periodPreviewPanel.innerHTML = `
<div class="week-template-period-summary">
<article><span>Périmètre</span><strong>${escapeHtml(filterLabel)}</strong></article>
<article><span>Créneaux à créer</span><strong>${Number(summary.entry_count || 0)}</strong></article>
<article><span>Jours concernés</span><strong>${Number(summary.target_day_count || 0)}</strong></article>
<article><span>Semaines touchées</span><strong>${Number(summary.week_count || 0)}</strong></article>
<article><span>Ignorés (vacances)</span><strong>${Number(summary.skipped_vacation_count || 0)}</strong></article>
<article><span>Ignorés (hors vacances)</span><strong>${Number(summary.skipped_school_count || 0)}</strong></article>
<article><span>${mode === 'replace' ? 'Créneaux existants remplacés' : 'Conflits'}</span><strong>${mode === 'replace' ? Number(summary.existing_entry_count_on_target_dates || 0) : Number(summary.conflict_count || 0)}</strong></article>
</div>
${vacationHtml}
${conflictHtml}
<div class="table-scroll week-template-period-table-wrap">
<table class="data-table week-template-period-table">
<thead><tr><th>Date</th><th>Contexte</th><th>Créés</th><th>Ignorés</th><th>Conflits</th></tr></thead>
<tbody>${dayRows || '<tr><td colspan="5" class="empty-cell">Aucune journée du modèle ne correspond à cette période.</td></tr>'}</tbody>
</table>
</div>
${relevantDays.length > 80 ? `<p class="hint">Aperçu limité aux 80 premières journées concernées sur ${relevantDays.length}.</p>` : ''}
`;
periodPreviewPanel.hidden = false;
const canApply = Number(summary.entry_count || 0) > 0
&& (mode !== 'merge' || Number(summary.conflict_count || 0) === 0);
if (periodConfirmButton) periodConfirmButton.disabled = !canApply;
}
async function previewPeriodApplication() {
const templateId = Number(periodTemplateSelect?.value || 0);
const startDate = periodStartInput?.value || '';
const endDate = periodEndInput?.value || '';
if (!templateId || !startDate || !endDate) {
setInlineMessage(periodMessage, 'Sélectionnez une semaine type et renseignez les deux dates.', 'warning');
return;
}
if (endDate < startDate) {
setInlineMessage(periodMessage, 'La date de fin doit être postérieure ou égale à la date de début.', 'warning');
return;
}
if (periodPreviewButton) periodPreviewButton.disabled = true;
if (periodConfirmButton) periodConfirmButton.disabled = true;
setInlineMessage(periodMessage, 'Analyse de la période et des vacances...', 'info');
try {
const response = await fetch('api/preview_week_template_period.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
template_id: templateId,
agent_id: Number(agentSelect.value),
start_date: startDate,
end_date: endDate,
mode: periodMode(),
period_filter: periodFilter(),
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de prévisualiser lapplication sur la période.'));
lastPeriodPreview = data.preview;
lastPeriodPreviewSignature = currentPeriodSignature();
renderPeriodPreview(lastPeriodPreview);
setInlineMessage(periodMessage, 'Aperçu calculé. Vérifiez les dates avant de confirmer.', 'success');
} catch (error) {
lastPeriodPreview = null;
lastPeriodPreviewSignature = '';
if (periodPreviewPanel) periodPreviewPanel.hidden = true;
setInlineMessage(periodMessage, error.message, 'error');
} finally {
if (periodPreviewButton) periodPreviewButton.disabled = false;
}
}
async function applyTemplatePeriod() {
if (!lastPeriodPreview || lastPeriodPreviewSignature !== currentPeriodSignature()) {
setInlineMessage(periodMessage, 'La période a changé. Relancez la prévisualisation avant dappliquer le modèle.', 'warning');
if (periodConfirmButton) periodConfirmButton.disabled = true;
return;
}
const templateId = Number(periodTemplateSelect?.value || 0);
const template = templates.find((item) => Number(item.id) === templateId);
const mode = periodMode();
const summary = lastPeriodPreview.summary || {};
if (mode === 'replace' && Number(summary.existing_entry_count_on_target_dates || 0) > 0) {
const confirmed = window.confirm(
`${Number(summary.existing_entry_count_on_target_dates)} créneau(x) existant(s) seront supprimé(s) sur les jours réellement concernés avant dappliquer « ${template?.name || 'la semaine type'} ».\n\nLes dates exclues par le filtre de calendrier ne seront pas modifiées. Continuer ?`
);
if (!confirmed) return;
}
if (periodConfirmButton) periodConfirmButton.disabled = true;
if (periodPreviewButton) periodPreviewButton.disabled = true;
setInlineMessage(periodMessage, 'Application de la semaine type sur la période...', 'info');
try {
const response = await fetch('api/apply_week_template_period.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
template_id: templateId,
agent_id: Number(agentSelect.value),
start_date: periodStartInput.value,
end_date: periodEndInput.value,
mode,
period_filter: periodFilter(),
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible dappliquer la semaine type sur la période.'));
await PTA.modules.planning?.loadPlanning?.();
PTA.modules.pendingValidation?.refresh?.({ silent: true });
PTA.modules.coverage?.refresh?.({ silent: true });
showMessage(data.message, 'success');
setInlineMessage(periodMessage, data.message, 'success');
lastPeriodPreview = null;
lastPeriodPreviewSignature = '';
setTimeout(() => closeModal(periodModal), 700);
} catch (error) {
setInlineMessage(periodMessage, error.message, 'error');
// Le planning peut avoir changé depuis l'aperçu : imposer une nouvelle prévisualisation.
lastPeriodPreview = null;
lastPeriodPreviewSignature = '';
if (periodConfirmButton) periodConfirmButton.disabled = true;
} finally {
if (periodPreviewButton) periodPreviewButton.disabled = false;
}
}
async function deleteSelectedTemplate() {
const templateId = Number(templateSelect?.value || 0);
const template = templates.find((item) => Number(item.id) === templateId);
if (!template) return;
const confirmed = window.confirm(`Supprimer définitivement la semaine type « ${template.name} » ?`);
if (!confirmed) return;
if (deleteButton) deleteButton.disabled = true;
try {
const response = await fetch('api/delete_week_template.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
template_id: templateId,
agent_id: Number(agentSelect.value),
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de supprimer la semaine type.'));
setInlineMessage(applyMessage, data.message, 'success');
await loadTemplates();
renderTemplateSelect();
} catch (error) {
setInlineMessage(applyMessage, error.message, 'error');
renderSelectedTemplateDetails();
}
}
function updateReplaceWarning() {
const mode = applyForm?.querySelector('input[name="week-template-mode"]:checked')?.value || 'merge';
if (replaceWarning) replaceWarning.hidden = mode !== 'replace';
}
function init() {
saveButton?.addEventListener('click', openSaveModal);
applyButton?.addEventListener('click', openApplyModal);
periodButton?.addEventListener('click', openPeriodModal);
saveForm?.addEventListener('submit', (event) => {
event.preventDefault();
saveTemplate(false);
});
applyForm?.addEventListener('submit', (event) => {
event.preventDefault();
applyTemplate();
});
periodForm?.addEventListener('submit', (event) => {
event.preventDefault();
applyTemplatePeriod();
});
periodPreviewButton?.addEventListener('click', previewPeriodApplication);
document.querySelector('#close-save-week-template-modal')?.addEventListener('click', () => closeModal(saveModal));
document.querySelector('#cancel-save-week-template')?.addEventListener('click', () => closeModal(saveModal));
document.querySelector('#close-apply-week-template-modal')?.addEventListener('click', () => closeModal(applyModal));
document.querySelector('#cancel-apply-week-template')?.addEventListener('click', () => closeModal(applyModal));
document.querySelector('#close-apply-week-template-period-modal')?.addEventListener('click', () => closeModal(periodModal));
document.querySelector('#cancel-apply-week-template-period')?.addEventListener('click', () => closeModal(periodModal));
saveModal?.addEventListener('click', (event) => {
if (event.target === saveModal) closeModal(saveModal);
});
applyModal?.addEventListener('click', (event) => {
if (event.target === applyModal) closeModal(applyModal);
});
periodModal?.addEventListener('click', (event) => {
if (event.target === periodModal) closeModal(periodModal);
});
templateSelect?.addEventListener('change', renderSelectedTemplateDetails);
applyForm?.querySelectorAll('input[name="week-template-mode"]').forEach((input) => {
input.addEventListener('change', updateReplaceWarning);
});
periodForm?.querySelectorAll('input[name="week-template-period-mode"]').forEach((input) => {
input.addEventListener('change', invalidatePeriodPreview);
});
periodForm?.querySelectorAll('input[name="week-template-period-filter"]').forEach((input) => {
input.addEventListener('change', invalidatePeriodPreview);
});
[periodTemplateSelect, periodStartInput, periodEndInput].forEach((input) => {
input?.addEventListener('change', invalidatePeriodPreview);
input?.addEventListener('input', invalidatePeriodPreview);
});
deleteButton?.addEventListener('click', deleteSelectedTemplate);
agentSelect?.addEventListener('change', refreshButtons);
weekInput?.addEventListener('input', refreshButtons);
weekInput?.addEventListener('change', refreshButtons);
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
if (saveModal && !saveModal.hidden) closeModal(saveModal);
if (applyModal && !applyModal.hidden) closeModal(applyModal);
if (periodModal && !periodModal.hidden) closeModal(periodModal);
});
updateReplaceWarning();
updatePeriodReplaceWarning();
refreshButtons();
}
PTA.modules.weekTemplates = {
init,
refreshButtons,
loadTemplates,
};
})();

2555
assets/style.css Normal file

File diff suppressed because it is too large Load Diff