pour prod
This commit is contained in:
482
assets/js/administration.js
Normal file
482
assets/js/administration.js
Normal 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 l’agent.'));
|
||||
|
||||
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 d’affectation 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 l’agent.'));
|
||||
|
||||
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 d’affectation.'));
|
||||
|
||||
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 l’agent.'));
|
||||
|
||||
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
535
assets/js/agent-overview.js
Normal 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 l’année civile du 1er janvier au 31 décembre.</small></article>
|
||||
</div>
|
||||
<p class="agent-month-help">${canEditDraft
|
||||
? (canCopyFullWeek
|
||||
? 'Glissez un créneau modifiable vers un autre jour pour le déplacer. Maintenez <strong>Ctrl</strong> (ou <strong>⌘</strong> sur Mac) pour le dupliquer. La duplication complète d’une semaine est réservée au Service Enfance.'
|
||||
: 'Vous pouvez modifier les créneaux de votre lieu. Les créneaux saisis sur un autre lieu restent en lecture seule et toute modification est enregistrée en brouillon.')
|
||||
: 'Cette vue est en lecture seule. Vous pouvez consulter votre planning et télécharger les documents disponibles.'}</p>
|
||||
<div class="agent-month-planner">${weekBlocks}</div>
|
||||
`;
|
||||
|
||||
bindMonthInteractions();
|
||||
}
|
||||
|
||||
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 l’agent…';
|
||||
container.hidden = true;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ agent_id: agentId, month });
|
||||
const response = await fetch(`api/agent_month_overview.php?${params.toString()}`);
|
||||
const data = await readJsonResponse(response);
|
||||
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger la vue mensuelle de l’agent.'));
|
||||
render(data);
|
||||
} catch (error) {
|
||||
empty.hidden = false;
|
||||
empty.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
306
assets/js/annual-overview.js
Normal file
306
assets/js/annual-overview.js
Normal 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('&', '&')
|
||||
.replaceAll('\"', '"')
|
||||
.replaceAll("'", ''')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
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) : ' ';
|
||||
const title = cell.title ? ` title="${escapeAttr(cell.title)}"` : '';
|
||||
const draftClass = day.has_draft && cell.minutes > 0 ? ' annual-cell-draft' : '';
|
||||
return `<td class="annual-data-cell ${escapeHtml(cell.class || '')}${draftClass}" data-date="${escapeHtml(day.date)}"${title}>${content}</td>`;
|
||||
}
|
||||
|
||||
function monthHtml(month) {
|
||||
const rows = month.days.map((day) => {
|
||||
const classes = [
|
||||
day.is_weekend ? 'annual-weekend' : '',
|
||||
day.is_vacation ? 'annual-vacation-row' : '',
|
||||
day.has_entries ? 'annual-has-entry' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const vacationTitle = day.vacation_label ? ` title="${escapeAttr(day.vacation_label)}"` : '';
|
||||
const weekText = day.show_week ? String(day.week).padStart(2, '0') : '';
|
||||
return `
|
||||
<tr class="${classes}" data-url="${escapeAttr(buildPlanningUrl(day))}"${vacationTitle}>
|
||||
<th class="annual-day-cell"><span>${escapeHtml(day.weekday_short)}</span>${day.day}</th>
|
||||
<td class="annual-week-cell">${weekText}</td>
|
||||
${cellHtml(day.cells.AP, day)}
|
||||
${cellHtml(day.cells.PP, day)}
|
||||
${cellHtml(day.cells.AE, day)}
|
||||
${cellHtml(day.cells.PE, day)}
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<article class="annual-month" data-month="${month.number}">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th colspan="6" class="annual-month-title">${escapeHtml(month.name)}</th></tr>
|
||||
<tr class="annual-column-headings"><th>Jour</th><th>S</th><th>AP</th><th>PP</th><th>AE</th><th>PE</th></tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
<tfoot>
|
||||
<tr><th colspan="2">Total</th><td>${escapeHtml(month.totals.AP.compact || '0h')}</td><td>${escapeHtml(month.totals.PP.compact || '0h')}</td><td>${escapeHtml(month.totals.AE.compact || '0h')}</td><td>${escapeHtml(month.totals.PE.compact || '0h')}</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function quotaPeriodsHtml(periods) {
|
||||
return periods.map((period) => `
|
||||
<article class="annual-quota-chip">
|
||||
<span>${escapeHtml(formatDate(period.date_debut_periode))} → ${escapeHtml(formatDate(period.date_fin_periode))}</span>
|
||||
<strong>${escapeHtml(period.affectees?.libelle || '0 h 00')} affectées / ${escapeHtml(period.quota_cible?.libelle || '0 h 00')}</strong>
|
||||
<small>Reste : ${escapeHtml(period.restantes?.libelle || '0 h 00')}</small>
|
||||
</article>`).join('');
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
current = data;
|
||||
const agent = data.agent || {};
|
||||
const periods = data.quota_periods || [];
|
||||
const referencePeriod = periods[0] || {};
|
||||
|
||||
el('annual-content').hidden = false;
|
||||
el('annual-title').textContent = `ANNUALISATION ${data.year}`;
|
||||
el('annual-updated').textContent = `Mise à jour : ${formatGenerated(data.generated_at)}`;
|
||||
el('annual-agent-name').textContent = `${agent.prenom || ''} ${agent.nom || ''}`.trim() || '—';
|
||||
el('annual-agent-number').textContent = agent.matricule || '—';
|
||||
el('annual-rate').textContent = referencePeriod.quotite_travail !== undefined ? `${Number(referencePeriod.quotite_travail).toLocaleString('fr-FR')} %` : '—';
|
||||
el('annual-target').textContent = referencePeriod.quota_cible?.libelle || '—';
|
||||
el('annual-contract').textContent = formatContract(agent.type_contrat);
|
||||
el('annual-location').textContent = agent.structure_principale_nom || 'Sans lieu principal';
|
||||
el('annual-post').textContent = agent.poste_libelle || 'Poste non renseigné';
|
||||
el('annual-remaining').textContent = referencePeriod.restantes?.libelle || '—';
|
||||
el('annual-quota-periods').innerHTML = quotaPeriodsHtml(periods);
|
||||
el('annual-calendar-total').textContent = data.calendar_totals?.duration || '0 h 00';
|
||||
el('annual-validated-total').textContent = data.calendar_totals?.validated || '0 h 00';
|
||||
el('annual-draft-total').textContent = data.calendar_totals?.draft || '0 h 00';
|
||||
el('annual-year-label').textContent = `01/01/${data.year} → 31/12/${data.year}`;
|
||||
el('annual-months').innerHTML = (data.months || []).map(monthHtml).join('');
|
||||
el('annual-legend').innerHTML = (data.legend || []).map((item) => `
|
||||
<span class="annual-legend-item"><i class="${escapeHtml(item.class)}"></i><b>${escapeHtml(item.code)}</b> ${escapeHtml(item.label)}</span>
|
||||
`).join('');
|
||||
|
||||
el('annual-open-pta').href = `pta.php?agent_id=${Number(agent.id_agent)}&date=${data.year}-07-01`;
|
||||
el('annual-open-month').href = `agents.php?agent_id=${Number(agent.id_agent)}&month=${data.year}-01`;
|
||||
el('annual-print').disabled = false;
|
||||
el('annual-fullscreen').disabled = false;
|
||||
if (el('annual-validate-year')) el('annual-validate-year').disabled = false;
|
||||
}
|
||||
|
||||
async function load(options = {}) {
|
||||
const agentId = Number(el('annual-agent')?.value || 0);
|
||||
const year = Number(el('annual-year')?.value || 0);
|
||||
if (!agentId || year < 2000 || year > 2100) {
|
||||
show('Choisissez un agent et une année valide.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.clearValidation !== false && el('annual-validation-result')) {
|
||||
el('annual-validation-result').innerHTML = '';
|
||||
}
|
||||
show('Construction de la vue annuelle…');
|
||||
el('annual-content').hidden = true;
|
||||
try {
|
||||
const response = await fetch(`api/annual_overview.php?agent_id=${agentId}&year=${year}`);
|
||||
const data = await readJsonResponse(response);
|
||||
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger la vue annuelle.'));
|
||||
render(data);
|
||||
show('Vue annuelle actualisée.', 'success');
|
||||
} catch (error) {
|
||||
show(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function validationPlanningUrl(planning) {
|
||||
const agentId = Number(current?.agent?.id_agent || el('annual-agent')?.value || 0);
|
||||
const week = `${Number(planning.annee)}-W${String(planning.numero_semaine).padStart(2, '0')}`;
|
||||
const params = new URLSearchParams({
|
||||
agent_id: String(agentId),
|
||||
structure_id: String(Number(planning.id_structure)),
|
||||
week,
|
||||
focus: 'annual-validation',
|
||||
notice: `Correction demandée depuis la vue annuelle — semaine ${String(planning.numero_semaine).padStart(2, '0')}.`,
|
||||
});
|
||||
return `planning.php?${params.toString()}`;
|
||||
}
|
||||
|
||||
function renderValidationIssues(data) {
|
||||
const container = el('annual-validation-result');
|
||||
if (!container) return;
|
||||
const invalid = (data.plannings || []).filter((planning) => planning.has_errors);
|
||||
container.innerHTML = `
|
||||
<div class="annual-validation-summary is-error">
|
||||
<strong>Aucune semaine n’a été validée.</strong><br>
|
||||
${escapeHtml(data.error || 'Des erreurs doivent être corrigées avant la validation annuelle.')}
|
||||
<div>${invalid.length} planning(s) à corriger.</div>
|
||||
</div>
|
||||
<div class="annual-validation-issues">
|
||||
${invalid.map((planning) => {
|
||||
const errors = (planning.controls || []).filter((control) => control.niveau === 'ERREUR');
|
||||
return `
|
||||
<article class="annual-validation-issue">
|
||||
<header>
|
||||
<h4>Semaine ${String(planning.numero_semaine).padStart(2, '0')} — ${escapeHtml(planning.structure_nom || 'Lieu non renseigné')}</h4>
|
||||
<a class="button button-secondary annual-validation-open-link"
|
||||
href="${escapeAttr(validationPlanningUrl(planning))}"
|
||||
target="_blank" rel="noopener">Corriger cette semaine</a>
|
||||
</header>
|
||||
<div>${escapeHtml(formatDate(planning.date_debut))} → ${escapeHtml(formatDate(planning.date_fin))}</div>
|
||||
<ul>${errors.map((control) => `<li>${escapeHtml(control.message)}</li>`).join('')}</ul>
|
||||
</article>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderValidationWarning(data) {
|
||||
const container = el('annual-validation-result');
|
||||
if (!container) return;
|
||||
container.innerHTML = `
|
||||
<div class="annual-validation-summary is-warning">
|
||||
<strong>Avertissement avant validation.</strong><br>
|
||||
${escapeHtml(data.message || 'Une confirmation est nécessaire.')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderValidationSuccess(data) {
|
||||
const container = el('annual-validation-result');
|
||||
if (!container) return;
|
||||
container.innerHTML = `
|
||||
<div class="annual-validation-summary is-success">
|
||||
<strong>Validation terminée.</strong><br>
|
||||
${escapeHtml(data.message || 'Les plannings ont été validés.')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function validateWholeYear(forceQuota = false) {
|
||||
const agentId = Number(el('annual-agent')?.value || 0);
|
||||
const year = Number(el('annual-year')?.value || 0);
|
||||
if (!agentId || !year || !current || Number(current.agent?.id_agent) !== agentId || Number(current.year) !== year) {
|
||||
show('Affichez d’abord la vue annuelle de l’agent à contrôler.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const button = el('annual-validate-year');
|
||||
if (button) button.disabled = true;
|
||||
const container = el('annual-validation-result');
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="annual-validation-summary">Contrôle de toutes les semaines en brouillon…</div>';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('api/annual_validate.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
csrf_token: PTA.config.csrfToken,
|
||||
agent_id: agentId,
|
||||
year,
|
||||
force_quota: Boolean(forceQuota),
|
||||
}),
|
||||
});
|
||||
const data = await readJsonResponse(response);
|
||||
|
||||
if (response.status === 422 && data.status === 'errors') {
|
||||
renderValidationIssues(data);
|
||||
show('Des semaines doivent être corrigées avant la validation annuelle.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 409 && data.status === 'warning') {
|
||||
renderValidationWarning(data);
|
||||
const confirmed = window.confirm(`${data.message || 'Le quota annuel présente un avertissement.'}\n\nValider malgré cet avertissement ?`);
|
||||
if (confirmed) await validateWholeYear(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(apiErrorMessage(data, 'Impossible de valider l’année.'));
|
||||
}
|
||||
|
||||
renderValidationSuccess(data);
|
||||
show(data.message || 'Validation annuelle terminée.', 'success');
|
||||
await load({ clearValidation: false });
|
||||
} catch (error) {
|
||||
if (container) {
|
||||
container.innerHTML = `<div class="annual-validation-summary is-error">${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
show(error.message, 'error');
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCalendarClick(event) {
|
||||
const row = event.target.closest('tr[data-url]');
|
||||
if (!row) return;
|
||||
window.location.href = row.dataset.url;
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
const target = el('annual-content');
|
||||
if (!target) return;
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await target.requestFullscreen?.();
|
||||
} else {
|
||||
await document.exitFullscreen?.();
|
||||
}
|
||||
} catch (error) {
|
||||
show('Le mode plein écran n’est pas disponible dans ce navigateur.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
el('load-annual')?.addEventListener('click', load);
|
||||
el('annual-print')?.addEventListener('click', () => window.print());
|
||||
el('annual-fullscreen')?.addEventListener('click', toggleFullscreen);
|
||||
el('annual-validate-year')?.addEventListener('click', () => validateWholeYear(false));
|
||||
el('annual-months')?.addEventListener('click', handleCalendarClick);
|
||||
|
||||
const params = window.PTA_PAGE_CONFIG?.params || {};
|
||||
if (params.agentId) el('annual-agent').value = String(params.agentId);
|
||||
if (params.year) el('annual-year').value = String(params.year);
|
||||
if (params.agentId) load();
|
||||
}
|
||||
|
||||
PTA.modules.annualOverview = { init, load };
|
||||
})();
|
||||
175
assets/js/core.js
Normal file
175
assets/js/core.js
Normal 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(/ /gi, ' ')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/&/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
284
assets/js/coverage.js
Normal 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 d’analyser 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 d’affectation.</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 d’enregistrer 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 };
|
||||
})();
|
||||
80
assets/js/page-bootstrap.js
Normal file
80
assets/js/page-bootstrap.js
Normal 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();
|
||||
})();
|
||||
165
assets/js/pending-validation.js
Normal file
165
assets/js/pending-validation.js
Normal 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
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
184
assets/js/pta.js
Normal 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 };
|
||||
})();
|
||||
177
assets/js/structure-overview.js
Normal file
177
assets/js/structure-overview.js
Normal 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 d’agent 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 n’est 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 d’affectation 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
158
assets/js/teams.js
Normal 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 };
|
||||
})();
|
||||
263
assets/js/vacation-calendar.js
Normal file
263
assets/js/vacation-calendar.js
Normal 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 l’API 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 n’a 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 d’enregistrer 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 l’enregistrer.', '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 n’est 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
691
assets/js/week-templates.js
Normal 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 d’enregistrer 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 n’a 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 d’enregistrer 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 d’abord 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 l’appliquer à 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 d’importer 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 n’a pas pu être enregistré avant l’import.');
|
||||
}
|
||||
}
|
||||
|
||||
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 d’importer 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 d’appliquer 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 n’est 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 l’application 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 d’appliquer 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 d’appliquer « ${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 d’appliquer 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
2555
assets/style.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user