(() => {
'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 d’ouvrir 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 ``;
})
.join('');
}
function timeRangeHtml(dayLabel, index = 0, selectedMotifId = null) {
const suffix = `${dayLabel} plage ${index + 1}`;
return `
→
`;
}
function renderWeeklyEntryRows() {
if (!weeklyEntryBody) return;
weeklyEntryBody.innerHTML = '';
if (!state.weekDays.length) {
weeklyEntryBody.innerHTML = '| Sélectionnez d’abord un lieu d’affectation, un agent et une semaine. |
';
return;
}
state.weekDays.forEach((day) => {
const row = document.createElement('tr');
row.className = 'weekly-entry-row';
row.dataset.date = day.date;
row.innerHTML = `
${escapeHtml(day.label)}
${escapeHtml(day.display)}
${day.type_affectation ? `` : ''}
${day.vacances?.libelle ? `${escapeHtml(day.vacances.libelle)}` : ''}
|
${timeRangeHtml(day.label, 0)}
|
`;
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 d’affectation`;
}
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 d’affectation 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) => (
``
)).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 n’est 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 n’a 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 d’affectation 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 = `
`;
makeDayDropTarget(column, day);
const entriesContainer = column.querySelector('.day-entries');
if (entries.length === 0) {
entriesContainer.innerHTML = 'Aucune affectation
';
} 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 = `
⋮⋮
${escapeHtml(motif.label)}
${escapeHtml(entry.start)} – ${escapeHtml(entry.end)}
📍 ${escapeHtml(entry.structure_name || 'Lieu non précisé')}${entry.structure_type_affectation ? ` · ${escapeHtml(structureTypeLabel(entry.structure_type_affectation))}` : ''}
${formatDuration(duration)}${motif.quota ? ' · quota' : ''}${entry.is_external ? ` · ${escapeHtml(String(entry.status || '').toLowerCase())}` : ''}
${entry.is_external
? 'Autre lieu'
: state.planningStatus === 'VALIDE'
? 'Validé'
: canEditDraft
? ''
: 'Lecture seule'}
`;
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 = '';
if (!structureSelect.value) {
agentSelect.innerHTML = '';
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 = '';
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 n’est disponible.', 'warning');
}
} catch (error) {
showMessage(error.message, 'error');
agentSelect.innerHTML = '';
}
}
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 d’autres 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 l’heure de début et l’heure de fin.`);
return;
}
if (timeToMinutes(end) <= timeToMinutes(start)) {
errors.push(`${day?.label || row.dataset.date} ${label} : l’heure de fin doit être postérieure à l’heure 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 d’affectation, 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,
};
})();