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

1131 lines
47 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant planning-editor.js');
const {
structureSelect,
agentSelect,
weekInput,
weeklyEntryBody,
addButton,
saveButton,
validateButton,
modifyButton,
planningBoard,
emptyState,
weekLabel,
workTotal,
statusBadge,
openAgentPdfButton,
quotaPanel,
quotaRate,
quotaTarget,
quotaPeriod,
quotaUsed,
quotaUsedDetail,
quotaRemaining,
} = PTA.elements;
const permissions = window.PTA_CONFIG?.permissions || {};
const accessProfile = window.PTA_CONFIG?.access || {};
const canEditDraft = Boolean(permissions.can_edit_draft);
const canValidate = Boolean(permissions.can_validate);
const canEditCrossStructure = Boolean(permissions.can_edit_cross_structure);
const state = PTA.state;
const {
escapeHtml,
showMessage,
clearMessage,
readJsonResponse,
apiErrorMessage,
parseIsoWeek,
timeToMinutes,
formatDuration,
motifInfo,
} = PTA.utils;
function isPlanningContextReady() {
return Boolean(structureSelect?.value && agentSelect?.value && weekInput?.value);
}
function updateAgentPdfButton() {
if (openAgentPdfButton) {
openAgentPdfButton.disabled = !(agentSelect?.value && weekInput?.value);
}
}
function openAgentPlanningPdf() {
if (!agentSelect?.value || !weekInput?.value) {
showMessage('Sélectionnez un agent et une semaine avant douvrir le PDF.', 'warning');
return;
}
const params = new URLSearchParams({
agent_id: agentSelect.value,
week: weekInput.value,
weeks: '7',
});
window.open(`api/agent_planning_pdf.php?${params.toString()}`, '_blank', 'noopener');
}
function setStatus(status) {
state.planningStatus = status;
if (!statusBadge) return;
statusBadge.className = 'status-badge';
if (status === 'VALIDE') {
statusBadge.classList.add('status-valid');
statusBadge.textContent = 'Planning validé';
} else if (status === 'BROUILLON') {
statusBadge.classList.add('status-draft');
statusBadge.textContent = 'Brouillon';
} else {
statusBadge.classList.add('status-neutral');
statusBadge.textContent = 'Nouveau planning';
}
const locked = status === 'VALIDE';
if (modifyButton) {
modifyButton.hidden = !canEditDraft || !locked;
modifyButton.disabled = !canEditDraft || !locked || !state.planningId;
}
if (saveButton) {
saveButton.hidden = !canEditDraft || locked;
saveButton.disabled = !canEditDraft || locked || !isPlanningContextReady();
}
if (validateButton) {
validateButton.hidden = !canValidate || locked;
validateButton.disabled = !canValidate || locked || !isPlanningContextReady() || state.entries.length === 0;
}
if (addButton) {
addButton.hidden = !canEditDraft;
addButton.disabled = !canEditDraft || locked || !isPlanningContextReady() || state.weekDays.length === 0;
}
updateEntryControlsState();
updateAgentPdfButton();
}
function motifOptionsHtml(selectedMotifId = null) {
return (window.PTA_CONFIG?.motifs || [])
.map((motif) => {
const selected = Number(motif.id) === Number(selectedMotifId) ? ' selected' : '';
const quotaLabel = motif.quota ? ' · décompte quota' : ' · hors quota';
return `<option value="${Number(motif.id)}"${selected}>${escapeHtml(motif.label)}${quotaLabel}</option>`;
})
.join('');
}
function timeRangeHtml(dayLabel, index = 0, selectedMotifId = null) {
const suffix = `${dayLabel} plage ${index + 1}`;
return `
<div class="weekly-time-range">
<label class="weekly-range-motif-field">
<span>Motif</span>
<select class="weekly-range-motif" aria-label="Motif ${escapeHtml(suffix)}">
<option value="">Choisir un motif</option>
${motifOptionsHtml(selectedMotifId)}
</select>
</label>
<label>
<span>Début</span>
<input class="weekly-time weekly-range-start" type="time" step="900" aria-label="Début ${escapeHtml(suffix)}">
</label>
<span class="weekly-time-separator">→</span>
<label>
<span>Fin</span>
<input class="weekly-time weekly-range-end" type="time" step="900" aria-label="Fin ${escapeHtml(suffix)}">
</label>
<button type="button" class="button button-ghost remove-time-range" title="Supprimer cette plage" aria-label="Supprimer ${escapeHtml(suffix)}">×</button>
</div>`;
}
function renderWeeklyEntryRows() {
if (!weeklyEntryBody) return;
weeklyEntryBody.innerHTML = '';
if (!state.weekDays.length) {
weeklyEntryBody.innerHTML = '<tr><td colspan="2" class="weekly-entry-placeholder">Sélectionnez dabord un lieu daffectation, un agent et une semaine.</td></tr>';
return;
}
state.weekDays.forEach((day) => {
const row = document.createElement('tr');
row.className = 'weekly-entry-row';
row.dataset.date = day.date;
row.innerHTML = `
<th scope="row" class="weekly-day-cell">
<strong>${escapeHtml(day.label)}</strong>
<span>${escapeHtml(day.display)}</span>
${day.type_affectation ? `<span class="weekly-period-badge ${day.type_affectation === 'EXTRASCOLAIRE' ? 'is-extra' : 'is-peri'}">${day.type_affectation === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire'}</span>` : ''}
${day.vacances?.libelle ? `<small class="weekly-period-detail">${escapeHtml(day.vacances.libelle)}</small>` : ''}
</th>
<td class="weekly-ranges-cell">
<div class="weekly-time-ranges">
${timeRangeHtml(day.label, 0)}
</div>
<button type="button" class="button button-secondary add-time-range">+ Ajouter une plage</button>
</td>
`;
weeklyEntryBody.appendChild(row);
});
updateEntryControlsState();
}
function fillWeeklyEntryRowsFromEntries() {
if (!state.weekDays.length || !weeklyEntryBody) return;
renderWeeklyEntryRows();
state.weekDays.forEach((day) => {
const row = weeklyEntryBody.querySelector(`.weekly-entry-row[data-date="${day.date}"]`);
if (!row) return;
const entries = state.entries
.filter((entry) => entry.date === day.date)
.sort((a, b) => a.start.localeCompare(b.start));
if (!entries.length) return;
const rangesContainer = row.querySelector('.weekly-time-ranges');
if (!rangesContainer) return;
rangesContainer.innerHTML = '';
entries.forEach((entry, index) => {
rangesContainer.insertAdjacentHTML('beforeend', timeRangeHtml(day.label, index, entry.motif_id));
const range = rangesContainer.lastElementChild;
range.querySelector('.weekly-range-start').value = entry.start;
range.querySelector('.weekly-range-end').value = entry.end;
});
});
updateEntryControlsState();
}
function updateEntryControlsState() {
if (!weeklyEntryBody) return;
const locked = state.planningStatus === 'VALIDE';
const enabled = canEditDraft && !locked && isPlanningContextReady() && state.weekDays.length > 0;
weeklyEntryBody.querySelectorAll('.weekly-time, .weekly-range-motif, .add-time-range, .remove-time-range').forEach((control) => {
control.disabled = !enabled;
});
}
function populateDays() {
state.weekDays = parseIsoWeek(weekInput?.value || '');
renderWeeklyEntryRows();
if (state.weekDays.length) {
if (weekLabel) {
weekLabel.textContent = `Du ${state.weekDays[0].display} au ${state.weekDays[state.weekDays.length - 1].display} · tous lieux daffectation`;
}
return true;
}
if (weekLabel) weekLabel.textContent = 'Sélectionnez une semaine valide.';
return false;
}
function countedMinutes(entries = state.entries) {
return entries.reduce((total, entry) => {
const motif = motifInfo(entry.motif_id) ?? { quota: Boolean(entry.compte_dans_quota) };
if (!motif.quota) return total;
return total + (timeToMinutes(entry.end) - timeToMinutes(entry.start));
}, 0);
}
function updateQuotaPanel() {
if (!quotaPanel) return;
if (!state.quota) {
quotaPanel.hidden = true;
return;
}
const currentMinutes = countedMinutes();
const baseAllocated = Number(state.quota.minutes_affectees) - Number(state.savedCurrentCountedMinutes || 0);
const projectedAllocated = baseAllocated + currentMinutes;
const projectedRemaining = Number(state.quota.quota_cible_minutes) - projectedAllocated;
quotaPanel.hidden = false;
if (quotaRate) quotaRate.textContent = `${Number(state.quota.quotite_travail).toLocaleString('fr-FR')} %`;
if (quotaTarget) quotaTarget.textContent = formatDuration(Number(state.quota.quota_cible_minutes));
if (quotaPeriod) {
quotaPeriod.textContent = state.quota.periode_libelle || 'Année civile';
}
if (quotaUsed) quotaUsed.textContent = formatDuration(projectedAllocated);
if (quotaUsedDetail) {
quotaUsedDetail.textContent = `${formatDuration(Number(state.quota.minutes_valides))} validées · ${formatDuration(Math.max(0, projectedAllocated - Number(state.quota.minutes_valides)))} en brouillon`;
}
if (quotaRemaining) {
quotaRemaining.textContent = formatDuration(projectedRemaining);
quotaRemaining.classList.toggle('quota-negative', projectedRemaining < 0);
}
}
let draggedEntry = null;
let activeEntryAction = null;
let suppressEntryClickUntil = 0;
function sameEntry(a, b) {
if (!a || !b) return false;
if (a.id_creneau && b.id_creneau) return Number(a.id_creneau) === Number(b.id_creneau);
return String(a.local_id || '') === String(b.local_id || '');
}
function allGlobalEntries() {
const currentStructureName = state.currentStructure?.nom
|| structureSelect?.options[structureSelect.selectedIndex]?.textContent?.trim()
|| 'Lieu daffectation sélectionné';
return [
...state.entries.map((entry) => ({
...entry,
is_external: false,
structure_name: currentStructureName,
structure_id: Number(structureSelect?.value || 0),
})),
...state.otherStructureEntries.map((entry) => ({ ...entry, is_external: true })),
];
}
function findEntryConflict(entry, targetDate, duplicate) {
const startMinutes = timeToMinutes(entry.start);
const endMinutes = timeToMinutes(entry.end);
return allGlobalEntries().find((candidate) => {
if (!duplicate && sameEntry(candidate, entry)) return false;
return candidate.date === targetDate
&& startMinutes < timeToMinutes(candidate.end)
&& endMinutes > timeToMinutes(candidate.start);
}) || null;
}
function entryActionSummary(entry) {
const motif = motifInfo(entry.motif_id) ?? { label: entry.motif_label || 'Affectation' };
const location = entry.structure_name
|| state.currentStructure?.nom
|| structureSelect?.options[structureSelect.selectedIndex]?.textContent?.trim()
|| 'Lieu non précisé';
return `${motif.label} · ${entry.start}${entry.end} · ${location}`;
}
function closeEntryActionModal() {
const modal = document.querySelector('#planning-entry-action-modal');
const message = document.querySelector('#planning-entry-action-message');
if (modal) modal.hidden = true;
if (message) {
message.className = 'message';
message.textContent = '';
}
activeEntryAction = null;
if (!document.querySelector('.modal-backdrop:not([hidden])')) {
document.body.classList.remove('modal-open');
}
}
function openEntryActionModal(entry) {
const modal = document.querySelector('#planning-entry-action-modal');
const summary = document.querySelector('#planning-entry-action-summary');
const targetSelect = document.querySelector('#planning-entry-target-date');
const moveRadio = document.querySelector('input[name="planning-entry-action"][value="move"]');
if (!modal || !targetSelect) return;
activeEntryAction = entry;
if (summary) summary.textContent = entryActionSummary(entry);
targetSelect.innerHTML = state.weekDays.map((day) => (
`<option value="${escapeHtml(day.date)}"${day.date === entry.date ? ' selected' : ''}>${escapeHtml(day.label)} ${escapeHtml(day.display)}</option>`
)).join('');
if (moveRadio) moveRadio.checked = true;
const message = document.querySelector('#planning-entry-action-message');
if (message) {
message.className = 'message';
message.textContent = '';
}
modal.hidden = false;
document.body.classList.add('modal-open');
targetSelect.focus();
}
function applyLocalEntryDayAction(entry, targetDate, duplicate) {
const conflict = findEntryConflict(entry, targetDate, duplicate);
if (conflict) {
const location = conflict.structure_name || 'un autre lieu';
showMessage(`Action impossible : un créneau ${conflict.start}${conflict.end} existe déjà à ${location} ce jour-là.`, 'warning');
return false;
}
if (duplicate) {
const clone = {
...entry,
local_id: crypto.randomUUID(),
id_creneau: null,
date: targetDate,
};
delete clone.is_external;
delete clone.structure_name;
delete clone.structure_id;
delete clone.structure_type_affectation;
delete clone.status;
state.entries.push(clone);
} else {
const source = state.entries.find((candidate) => sameEntry(candidate, entry));
if (!source) {
showMessage('Le créneau à déplacer nest plus présent dans le formulaire. Rechargez le planning.', 'warning');
return false;
}
source.date = targetDate;
}
fillWeeklyEntryRowsFromEntries();
render();
setStatus(state.planningStatus ?? 'BROUILLON');
showMessage(
duplicate
? 'Le créneau a été dupliqué dans le formulaire. Enregistrez le brouillon pour confirmer la duplication.'
: 'Le créneau a été déplacé dans le formulaire. Enregistrez le brouillon pour confirmer le changement de jour.',
'success'
);
return true;
}
async function persistEntryDayAction(entry, targetDate, duplicate) {
if (!entry.id_creneau) {
return applyLocalEntryDayAction(entry, targetDate, duplicate);
}
if (entry.is_external && isPlanningContextReady() && state.planningStatus !== 'VALIDE') {
if (!syncEntriesFromWeeklyForm({ silent: true })) return false;
if (state.entries.length > 0 || state.planningId) {
const saved = await saveDraft({ silent: true });
if (!saved) return false;
}
}
try {
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(entry.id_creneau),
target_date: targetDate,
action: duplicate ? 'duplicate' : 'move',
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de modifier ce créneau.'));
await loadPlanning();
showMessage(data.message || (duplicate ? 'Créneau dupliqué.' : 'Créneau déplacé.'), 'success');
return true;
} catch (error) {
showMessage(error.message, 'error');
return false;
}
}
async function applyEntryDayAction(entry, targetDate, duplicate = false) {
if (!entry || !targetDate) return false;
if (!duplicate && entry.date === targetDate) {
showMessage('Le créneau se trouve déjà sur ce jour.', 'info');
return true;
}
const conflict = findEntryConflict(entry, targetDate, duplicate);
if (conflict) {
const location = conflict.structure_name || 'un autre lieu';
showMessage(`Action impossible : un créneau ${conflict.start}${conflict.end} existe déjà à ${location} ce jour-là.`, 'warning');
return false;
}
const directServerAction = Boolean(entry.id_creneau) && (entry.is_external || state.planningStatus === 'VALIDE');
return directServerAction
? persistEntryDayAction(entry, targetDate, duplicate)
: applyLocalEntryDayAction(entry, targetDate, duplicate);
}
async function submitEntryActionModal(event) {
event.preventDefault();
if (!activeEntryAction) return;
const targetDate = document.querySelector('#planning-entry-target-date')?.value || '';
const action = document.querySelector('input[name="planning-entry-action"]:checked')?.value || 'move';
const button = document.querySelector('#confirm-planning-entry-action');
const message = document.querySelector('#planning-entry-action-message');
if (button) button.disabled = true;
try {
const success = await applyEntryDayAction(activeEntryAction, targetDate, action === 'duplicate');
if (success) closeEntryActionModal();
else if (message) {
message.className = 'message message-warning';
message.textContent = 'La modification na pas été appliquée. Vérifiez les messages affichés dans la page.';
}
} finally {
if (button) button.disabled = false;
}
}
function clearDropTargets() {
planningBoard?.querySelectorAll('.day-column').forEach((column) => {
column.classList.remove('is-drop-target', 'is-copy-target');
});
}
function makeEntryInteractive(item, entry) {
if (!canEditDraft || (entry.is_external && !canEditCrossStructure)) return;
item.classList.add('planning-entry-interactive');
item.draggable = true;
item.tabIndex = 0;
item.setAttribute('role', 'button');
item.setAttribute('aria-label', `${entryActionSummary(entry)}. Cliquez pour déplacer ou dupliquer ce créneau.`);
item.title = 'Cliquez pour déplacer ou dupliquer. Glissez vers un autre jour ; Ctrl/⌘ + glisser pour dupliquer.';
item.addEventListener('dragstart', (event) => {
draggedEntry = entry;
item.classList.add('is-dragging');
event.dataTransfer.effectAllowed = 'copyMove';
event.dataTransfer.setData('text/plain', String(entry.id_creneau || entry.local_id || 'entry'));
});
item.addEventListener('dragend', () => {
item.classList.remove('is-dragging');
draggedEntry = null;
suppressEntryClickUntil = Date.now() + 250;
clearDropTargets();
});
item.addEventListener('click', (event) => {
if (Date.now() < suppressEntryClickUntil || event.target.closest('button')) return;
openEntryActionModal(entry);
});
item.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openEntryActionModal(entry);
}
});
}
function makeDayDropTarget(column, day) {
column.dataset.date = day.date;
if (!canEditDraft) return;
column.addEventListener('dragover', (event) => {
if (!draggedEntry) return;
event.preventDefault();
const copy = event.ctrlKey || event.metaKey;
event.dataTransfer.dropEffect = copy ? 'copy' : 'move';
clearDropTargets();
column.classList.add('is-drop-target');
if (copy) column.classList.add('is-copy-target');
});
column.addEventListener('dragleave', (event) => {
if (!column.contains(event.relatedTarget)) {
column.classList.remove('is-drop-target', 'is-copy-target');
}
});
column.addEventListener('drop', async (event) => {
if (!draggedEntry) return;
event.preventDefault();
const entry = draggedEntry;
const duplicate = event.ctrlKey || event.metaKey;
clearDropTargets();
await applyEntryDayAction(entry, day.date, duplicate);
});
}
function render() {
if (!planningBoard || !emptyState) return;
planningBoard.innerHTML = '';
const visibleDates = new Set(state.weekDays.map((day) => day.date));
const currentStructureName = state.currentStructure?.nom
|| structureSelect?.options[structureSelect.selectedIndex]?.textContent?.trim()
|| 'Lieu daffectation sélectionné';
const localEntries = state.entries.map((entry) => ({
...entry,
is_external: false,
structure_name: currentStructureName,
structure_id: Number(structureSelect?.value || 0),
structure_type_affectation: state.currentStructure?.type_affectation || '',
}));
const externalEntries = state.otherStructureEntries.map((entry) => ({
...entry,
is_external: true,
}));
const globalEntries = [...localEntries, ...externalEntries]
.filter((entry) => visibleDates.has(entry.date));
const hasEntries = globalEntries.length > 0;
emptyState.hidden = hasEntries;
planningBoard.hidden = !hasEntries;
let totalCountedMinutes = 0;
state.weekDays.forEach((day) => {
const entries = globalEntries
.filter((entry) => entry.date === day.date)
.sort((a, b) => a.start.localeCompare(b.start));
const column = document.createElement('section');
column.className = 'day-column';
column.innerHTML = `
<header class="day-header">
<strong>${day.label}</strong>
<span>${day.display}</span>
</header>
<div class="day-entries"></div>
`;
makeDayDropTarget(column, day);
const entriesContainer = column.querySelector('.day-entries');
if (entries.length === 0) {
entriesContainer.innerHTML = '<p class="day-empty">Aucune affectation</p>';
} else {
entries.forEach((entry) => {
const motif = motifInfo(entry.motif_id) ?? {
label: entry.motif_label || 'Motif',
quota: Boolean(entry.compte_dans_quota),
};
const duration = timeToMinutes(entry.end) - timeToMinutes(entry.start);
if (motif.quota) totalCountedMinutes += duration;
const item = document.createElement('article');
item.className = `planning-entry motif-${String(entry.motif_code || motif.code || 'autre').toLowerCase()}${entry.is_external ? ' planning-entry-external' : ''}`;
item.innerHTML = `
<span class="entry-drag-hint" aria-hidden="true">⋮⋮</span>
<div class="entry-content">
<span class="entry-motif">${escapeHtml(motif.label)}</span>
<strong>${escapeHtml(entry.start)} ${escapeHtml(entry.end)}</strong>
<span class="entry-location">📍 ${escapeHtml(entry.structure_name || 'Lieu non précisé')}${entry.structure_type_affectation ? ` · ${escapeHtml(structureTypeLabel(entry.structure_type_affectation))}` : ''}</span>
<small>${formatDuration(duration)}${motif.quota ? ' · quota' : ''}${entry.is_external ? ` · ${escapeHtml(String(entry.status || '').toLowerCase())}` : ''}</small>
</div>
${entry.is_external
? '<span class="entry-locked" title="Créneau saisi sur un autre lieu">Autre lieu</span>'
: state.planningStatus === 'VALIDE'
? '<span class="entry-locked" title="Un déplacement repassera le planning en brouillon">Validé</span>'
: canEditDraft
? '<button type="button" class="remove-entry" aria-label="Supprimer cette affectation">×</button>'
: '<span class="entry-locked">Lecture seule</span>'}
`;
makeEntryInteractive(item, entry);
const removeButton = item.querySelector('.remove-entry');
if (removeButton) {
removeButton.addEventListener('click', (event) => {
event.stopPropagation();
if (state.planningStatus === 'VALIDE') return;
state.entries = state.entries.filter((candidate) => candidate.local_id !== entry.local_id);
fillWeeklyEntryRowsFromEntries();
render();
setStatus(state.planningStatus);
});
}
entriesContainer.appendChild(item);
});
}
planningBoard.appendChild(column);
});
if (workTotal) workTotal.textContent = formatDuration(totalCountedMinutes);
if (validateButton) {
validateButton.disabled = state.planningStatus === 'VALIDE' || !isPlanningContextReady() || state.entries.length === 0;
}
updateQuotaPanel();
PTA.modules.weekTemplates?.refreshButtons?.();
}
function resetPlanningState() {
state.entries = [];
state.otherStructureEntries = [];
state.currentStructure = null;
state.planningId = null;
state.quota = null;
state.savedCurrentCountedMinutes = 0;
state.weekDays = [];
renderWeeklyEntryRows();
setStatus(null);
render();
clearMessage();
}
const structureTypeLabel = (type) => type === 'EXTRASCOLAIRE' ? 'Extrascolaire' : type === 'PERISCOLAIRE' ? 'Périscolaire' : '';
async function loadAgents() {
resetPlanningState();
if (!agentSelect || !weekInput || !structureSelect) return;
agentSelect.disabled = true;
weekInput.disabled = true;
agentSelect.innerHTML = '<option value="">Chargement...</option>';
if (!structureSelect.value) {
agentSelect.innerHTML = '<option value="">Choisir dabord un lieu daffectation</option>';
return;
}
try {
const response = await fetch(`api/agents.php?structure_id=${encodeURIComponent(structureSelect.value)}`);
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Erreur de chargement des agents.'));
agentSelect.innerHTML = '<option value="">Choisir un agent</option>';
data.agents.forEach((agent) => {
const option = document.createElement('option');
option.value = agent.id_agent;
const defaultLabel = agent.structure_nom ? ` · principal : ${agent.structure_nom}` : ' · sans lieu principal';
const principalType = agent.structure_type_affectation === 'EXTRASCOLAIRE' ? 'Extrascolaire' : 'Périscolaire';
const typeSuffix = agent.structure_nom ? ` · ${principalType}` : '';
option.textContent = `${agent.prenom} ${agent.nom} (${agent.matricule})${defaultLabel}${typeSuffix}`;
agentSelect.appendChild(option);
});
agentSelect.disabled = false;
if (data.agents.length === 0) {
showMessage('Aucun agent actif nest disponible.', 'warning');
}
} catch (error) {
showMessage(error.message, 'error');
agentSelect.innerHTML = '<option value="">Erreur de chargement</option>';
}
}
async function loadPlanning() {
if (!isPlanningContextReady()) return;
if (!populateDays()) {
setStatus(null);
return;
}
state.entries = [];
state.otherStructureEntries = [];
state.currentStructure = null;
state.planningId = null;
state.savedCurrentCountedMinutes = 0;
setStatus(null);
render();
clearMessage();
try {
const params = new URLSearchParams({
structure_id: structureSelect.value,
agent_id: agentSelect.value,
week: weekInput.value,
});
const response = await fetch(`api/planning.php?${params.toString()}`);
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger le planning.'));
state.quota = data.quota || null;
state.currentStructure = data.current_structure || null;
const calendarByDate = new Map((data.calendar_days || []).map((day) => [day.date, day]));
state.weekDays = state.weekDays.map((day) => ({ ...day, ...(calendarByDate.get(day.date) || {}) }));
state.otherStructureEntries = (data.other_entries || []).map((entry) => ({
local_id: `external-${entry.id_creneau}`,
id_creneau: Number(entry.id_creneau),
date: entry.date_jour,
motif_id: Number(entry.id_motif),
motif_code: entry.motif_code,
motif_label: entry.motif_libelle,
compte_dans_quota: Number(entry.compte_dans_quota) === 1,
start: entry.heure_debut,
end: entry.heure_fin,
structure_id: Number(entry.id_structure),
structure_name: entry.structure_nom,
structure_type_affectation: entry.structure_type_affectation,
status: entry.statut,
}));
if (data.planning) {
state.planningId = Number(data.planning.id_planning);
state.entries = data.entries.map((entry) => ({
local_id: crypto.randomUUID(),
id_creneau: Number(entry.id_creneau),
date: entry.date_jour,
motif_id: Number(entry.id_motif),
motif_code: entry.motif_code,
motif_label: entry.motif_libelle,
compte_dans_quota: Number(entry.compte_dans_quota) === 1,
start: entry.heure_debut,
end: entry.heure_fin,
}));
state.savedCurrentCountedMinutes = countedMinutes(state.entries);
setStatus(data.planning.statut);
} else {
setStatus(null);
}
fillWeeklyEntryRowsFromEntries();
render();
if (state.otherStructureEntries.length > 0) {
const count = state.otherStructureEntries.length;
showMessage(`${count} affectation${count > 1 ? 's' : ''} déjà saisie${count > 1 ? 's' : ''} sur dautres lieux ${count > 1 ? 'sont' : 'est'} affichée${count > 1 ? 's' : ''} en lecture seule.`, 'info');
}
} catch (error) {
showMessage(error.message, 'error');
}
}
function hasLocalOverlap(date, start, end, additionalEntries = [], includeCurrentEntries = true) {
const startMinutes = timeToMinutes(start);
const endMinutes = timeToMinutes(end);
const candidates = [
...(includeCurrentEntries ? state.entries : []),
...state.otherStructureEntries,
...additionalEntries,
];
return candidates.find((entry) => (
entry.date === date
&& startMinutes < timeToMinutes(entry.end)
&& endMinutes > timeToMinutes(entry.start)
)) || null;
}
function syncEntriesFromWeeklyForm({ silent = false } = {}) {
clearMessage();
const newEntries = [];
const errors = [];
weeklyEntryBody?.querySelectorAll('.weekly-entry-row').forEach((row) => {
const day = state.weekDays.find((item) => item.date === row.dataset.date);
const ranges = Array.from(row.querySelectorAll('.weekly-time-range'));
const filledRanges = ranges.map((range, index) => ({
index,
motifId: Number(range.querySelector('.weekly-range-motif')?.value || 0),
start: range.querySelector('.weekly-range-start')?.value || '',
end: range.querySelector('.weekly-range-end')?.value || '',
})).filter((range) => range.start || range.end);
filledRanges.forEach((range) => {
const label = `plage ${range.index + 1}`;
const { motifId, start, end } = range;
const motif = motifInfo(motifId);
if (!motifId || !motif) {
errors.push(`${day?.label || row.dataset.date} ${label} : sélectionnez un motif.`);
return;
}
if (!start || !end) {
errors.push(`${day?.label || row.dataset.date} ${label} : renseignez lheure de début et lheure de fin.`);
return;
}
if (timeToMinutes(end) <= timeToMinutes(start)) {
errors.push(`${day?.label || row.dataset.date} ${label} : lheure de fin doit être postérieure à lheure de début.`);
return;
}
const conflict = hasLocalOverlap(row.dataset.date, start, end, newEntries, false);
if (conflict) {
const location = conflict.structure_name
|| state.currentStructure?.nom
|| structureSelect?.options[structureSelect.selectedIndex]?.textContent?.trim()
|| 'le lieu sélectionné';
errors.push(`${day?.label || row.dataset.date} ${label} : chevauchement avec ${conflict.start}-${conflict.end} à ${location}.`);
return;
}
newEntries.push({
local_id: crypto.randomUUID(),
date: row.dataset.date,
motif_id: motifId,
motif_code: motif.code,
motif_label: motif.label,
compte_dans_quota: motif.quota,
start,
end,
});
});
});
if (errors.length) {
showMessage(errors.join('\n'), 'warning');
return false;
}
state.entries = newEntries;
render();
setStatus(state.planningStatus ?? 'BROUILLON');
if (!silent) {
if (newEntries.length === 0) {
showMessage('Toutes les plages ont été supprimées du formulaire. Enregistrez le brouillon pour confirmer la suppression.', 'info');
} else {
showMessage(`${newEntries.length} affectation${newEntries.length > 1 ? 's' : ''} mise${newEntries.length > 1 ? 's' : ''} à jour dans le planning. Pensez à enregistrer le brouillon.`, 'success');
}
}
return true;
}
function addEntry() {
syncEntriesFromWeeklyForm();
}
async function saveDraft({ silent = false } = {}) {
if (!canEditDraft) {
showMessage('Votre rôle ne permet pas de modifier ce planning.', 'warning');
return null;
}
if (!isPlanningContextReady()) {
showMessage('Sélectionnez un lieu daffectation, un agent et une semaine.', 'warning');
return null;
}
if (!syncEntriesFromWeeklyForm({ silent: true })) {
return null;
}
if (saveButton) saveButton.disabled = true;
if (validateButton) validateButton.disabled = true;
try {
const response = await fetch('save_planning.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
structure_id: Number(structureSelect.value),
agent_id: Number(agentSelect.value),
week: weekInput.value,
entries: state.entries.map((entry) => ({
date: entry.date,
motif_id: entry.motif_id,
start: entry.start,
end: entry.end,
})),
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Enregistrement impossible.'));
state.planningId = Number(data.planning_id);
if (data.quota) state.quota = data.quota;
state.savedCurrentCountedMinutes = countedMinutes();
setStatus('BROUILLON');
render();
if (!silent) showMessage(data.message, 'success');
PTA.modules.pendingValidation?.refresh?.({ silent: true });
PTA.modules.coverage?.refresh?.({ silent: true });
return state.planningId;
} catch (error) {
showMessage(error.message, 'error');
setStatus(state.planningStatus);
return null;
}
}
function controlsToText(controls) {
return controls
.filter((control) => control.niveau !== 'INFO')
.map((control) => `${control.message}`)
.join('\n');
}
async function validatePlanning(forceQuota = false) {
if (!canValidate) {
showMessage('Seul le Service Enfance peut valider un planning.', 'warning');
return;
}
clearMessage();
const planningId = await saveDraft({ silent: true });
if (!planningId) return;
try {
const response = await fetch('validate_planning.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
planning_id: planningId,
force_quota: forceQuota,
}),
});
const data = await readJsonResponse(response);
if (response.status === 409 && data.status === 'warning') {
const detail = controlsToText(data.controls || []);
const confirmed = window.confirm(`${data.message}\n\n${detail}\n\nVoulez-vous valider malgré cette alerte ?`);
if (confirmed) await validatePlanning(true);
return;
}
if (!response.ok) {
const detail = controlsToText(data.controls || []);
throw new Error(detail || apiErrorMessage(data, 'Validation impossible.'));
}
if (data.quota) state.quota = data.quota;
state.savedCurrentCountedMinutes = countedMinutes();
setStatus('VALIDE');
showMessage(data.message, 'success');
render();
PTA.modules.pendingValidation?.refresh?.({ silent: true });
PTA.modules.coverage?.refresh?.({ silent: true });
} catch (error) {
showMessage(error.message, 'error');
setStatus('BROUILLON');
}
}
async function reopenPlanningForEdit() {
if (!canEditDraft) return;
clearMessage();
if (!state.planningId || state.planningStatus !== 'VALIDE') {
showMessage('Aucun planning validé à modifier.', 'warning');
return;
}
const confirmed = window.confirm(
'Le planning va repasser en brouillon afin de pouvoir être corrigé.\n\n' +
'Il devra être validé à nouveau après les modifications. Continuer ?'
);
if (!confirmed) return;
if (modifyButton) modifyButton.disabled = true;
try {
const response = await fetch('reopen_planning.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
planning_id: state.planningId,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de modifier ce planning.'));
if (data.quota) state.quota = data.quota;
setStatus('BROUILLON');
render();
showMessage(data.message || 'Le planning est à nouveau modifiable.', 'success');
PTA.modules.pendingValidation?.refresh?.({ silent: true });
PTA.modules.coverage?.refresh?.({ silent: true });
} catch (error) {
showMessage(error.message, 'error');
setStatus('VALIDE');
}
}
async function handleWeekSelection() {
clearMessage();
updateAgentPdfButton();
const hasValidWeek = populateDays();
setStatus(state.planningStatus);
render();
if (!hasValidWeek || !isPlanningContextReady()) return;
await loadPlanning();
}
function handleAgentChange() {
if (!weekInput) return;
weekInput.disabled = !agentSelect?.value;
state.entries = [];
state.otherStructureEntries = [];
state.currentStructure = null;
state.planningId = null;
state.weekDays = [];
state.quota = null;
state.savedCurrentCountedMinutes = 0;
renderWeeklyEntryRows();
setStatus(null);
render();
clearMessage();
updateAgentPdfButton();
if (agentSelect?.value && weekInput.value) {
handleWeekSelection();
}
}
function handleWeeklyEntryClick(event) {
const addRangeButton = event.target.closest('.add-time-range');
if (addRangeButton) {
const row = addRangeButton.closest('.weekly-entry-row');
const container = row?.querySelector('.weekly-time-ranges');
const day = state.weekDays.find((item) => item.date === row?.dataset.date);
if (!row || !container || !day || state.planningStatus === 'VALIDE') return;
const index = container.querySelectorAll('.weekly-time-range').length;
container.insertAdjacentHTML('beforeend', timeRangeHtml(day.label, index));
updateEntryControlsState();
return;
}
const removeRangeButton = event.target.closest('.remove-time-range');
if (removeRangeButton) {
const row = removeRangeButton.closest('.weekly-entry-row');
const ranges = row?.querySelectorAll('.weekly-time-range');
if (!row || !ranges || state.planningStatus === 'VALIDE') return;
if (ranges.length === 1) {
ranges[0].querySelectorAll('input, select').forEach((control) => { control.value = ''; });
} else {
removeRangeButton.closest('.weekly-time-range')?.remove();
}
}
}
function init() {
structureSelect?.addEventListener('change', loadAgents);
agentSelect?.addEventListener('change', handleAgentChange);
weekInput?.addEventListener('input', handleWeekSelection);
weekInput?.addEventListener('change', handleWeekSelection);
if (canEditDraft) {
weeklyEntryBody?.addEventListener('click', handleWeeklyEntryClick);
addButton?.addEventListener('click', addEntry);
saveButton?.addEventListener('click', () => saveDraft());
modifyButton?.addEventListener('click', reopenPlanningForEdit);
}
if (canValidate) validateButton?.addEventListener('click', () => validatePlanning(false));
openAgentPdfButton?.addEventListener('click', openAgentPlanningPdf);
document.querySelector('#planning-entry-action-form')?.addEventListener('submit', submitEntryActionModal);
document.querySelector('#close-planning-entry-action-modal')?.addEventListener('click', closeEntryActionModal);
document.querySelector('#cancel-planning-entry-action')?.addEventListener('click', closeEntryActionModal);
document.querySelector('#planning-entry-action-modal')?.addEventListener('click', (event) => {
if (event.target.id === 'planning-entry-action-modal') closeEntryActionModal();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !document.querySelector('#planning-entry-action-modal')?.hidden) {
closeEntryActionModal();
}
});
updateAgentPdfButton();
renderWeeklyEntryRows();
setStatus(null);
render();
}
PTA.modules.planning = {
init,
loadAgents,
loadPlanning,
render,
setStatus,
renderWeeklyEntryRows,
handleWeekSelection,
updateAgentPdfButton,
applyEntryDayAction,
openEntryActionModal,
saveDraft,
syncEntriesFromWeeklyForm,
isPlanningContextReady,
countedMinutes,
};
})();