1131 lines
47 KiB
JavaScript
1131 lines
47 KiB
JavaScript
(() => {
|
||
'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 `<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 d’abord un lieu d’affectation, 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 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) => (
|
||
`<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 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 = `
|
||
<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 d’abord un lieu d’affectation</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 n’est 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 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,
|
||
};
|
||
})();
|