Files
pta/assets/js/planning-editor.js
2026-08-20 13:22:49 +02:00

1400 lines
59 KiB
JavaScript
Raw 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.
/**
* Éditeur de planning hebdomadaire — module le plus important de l'interface.
*
* Il expose `PTA.modules.planning` et gère toute la saisie d'une semaine :
* formulaire jour par jour, tableau de visualisation, glisser-déposer,
* projection du quota, enregistrement, validation et réouverture.
*
* DEUX REPRÉSENTATIONS DES MÊMES DONNÉES, à ne pas confondre :
* - le FORMULAIRE (`#weekly-entry-body`), une ligne par jour et des « plages »
* dans chaque ligne : c'est ce que l'utilisateur remplit ;
* - `state.entries`, la liste à plat des créneaux : c'est ce qui est envoyé au
* serveur et ce qu'affiche le tableau.
* Les deux sens de conversion sont assurés par `syncEntriesFromWeeklyForm()`
* (formulaire → état, avec validation) et `fillWeeklyEntryRowsFromEntries()`
* (état → formulaire). Modifier l'un sans l'autre désynchronise l'écran.
*
* DEUX CHEMINS D'ÉCRITURE, également distincts :
* - LOCAL : la modification reste dans `state.entries` et n'est envoyée qu'à
* l'enregistrement du brouillon. C'est le cas nominal ;
* - SERVEUR IMMÉDIAT : un créneau appartenant à un AUTRE lieu, ou à un planning
* déjà VALIDÉ, est modifié par un appel direct à l'API. Il ne fait pas partie
* du brouillon courant, l'enregistrement local ne le toucherait pas.
* L'arbitrage est fait par `applyEntryDayAction()`.
*
* Les permissions (`can_edit_draft`, `can_validate`,
* `can_edit_cross_structure`) ne font que masquer et désactiver : le serveur
* refait tous les contrôles.
*/
(() => {
'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;
/** Vrai quand lieu, agent et semaine sont tous les trois choisis. */
function isPlanningContextReady() {
return Boolean(structureSelect?.value && agentSelect?.value && weekInput?.value);
}
/** Le PDF agent ne dépend pas du lieu : agent et semaine y suffisent. */
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();
}
/**
* Motif proposé par défaut sur une nouvelle plage : le temps de travail, très
* majoritaire à la saisie. On se rabat sur le premier motif décomptant le quota
* si le référentiel ne contient pas le code TRAVAIL.
*/
function defaultMotifId() {
const motifs = window.PTA_CONFIG?.motifs || [];
const motif = motifs.find((item) => String(item.code).toUpperCase() === 'TRAVAIL')
|| motifs.find((item) => item.quota)
|| motifs[0];
return motif ? Number(motif.id) : null;
}
/**
* Un agent sans droit à l'enveloppe de formation ne peut pas recevoir le motif
* « Formation ». Le droit vaut tant que l'agent n'est pas chargé : on ne retire
* jamais une possibilité sur une donnée absente.
*/
function agentHasTrainingRight() {
return Number(state.currentAgent?.droit_enveloppe_formation ?? 1) !== 0;
}
/** Seul « Formation » est conditionné à un droit ; tout le reste est ouvert. */
function isMotifAllowedForAgent(motif) {
return agentHasTrainingRight() || String(motif.code).toUpperCase() !== 'FORMATION';
}
/**
* Options du sélecteur de motif d'une plage.
* Les libellés passent par escapeHtml() : ils viennent de la base et sont
* insérés en HTML brut.
*/
function motifOptionsHtml(selectedMotifId = null) {
return (window.PTA_CONFIG?.motifs || [])
// Un motif interdit reste proposé s'il est déjà celui de la plage :
// le retirer changerait silencieusement le motif d'une saisie existante.
.filter((motif) => isMotifAllowedForAgent(motif) || Number(motif.id) === Number(selectedMotifId))
.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('');
}
/**
* Balisage d'une plage horaire du formulaire : motif, début, fin, suppression.
*
* `step="900"` contraint les champs horaires au quart d'heure, granularité de
* toute l'application. Chaque contrôle porte un `aria-label` nommant le jour
* et le rang de la plage, sans quoi un lecteur d'écran annoncerait des
* dizaines de champs « Début » indiscernables.
*/
function timeRangeHtml(dayLabel, index = 0, selectedMotifId = null) {
const suffix = `${dayLabel} plage ${index + 1}`;
const fallbackMotifId = defaultMotifId();
const motifId = selectedMotifId ?? fallbackMotifId;
const placeholder = fallbackMotifId === null ? '<option value="">Choisir un motif</option>' : '';
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)}">
${placeholder}
${motifOptionsHtml(motifId)}
</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>`;
}
/**
* Reconstruit le formulaire : une ligne par jour ouvré, avec une plage vide.
*
* Chaque ligne affiche aussi son contexte périscolaire ou extrascolaire,
* déduit du calendrier des vacances renvoyé par l'API : c'est ce qui oriente
* le choix du motif.
*/
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();
}
/**
* ÉTAT → FORMULAIRE. Reconstruit le formulaire puis y réinjecte les créneaux
* de `state.entries`, triés par heure de début.
*
* Reconstruction complète plutôt que mise à jour ciblée : le nombre de
* plages par jour varie, et une réconciliation fine serait plus fragile que
* ce redessin.
*/
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();
}
/**
* Active ou désactive les contrôles de saisie.
* Un planning VALIDÉ est verrouillé : il faut le rouvrir explicitement.
*/
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;
});
}
/**
* Recalcule les jours de la semaine choisie et redessine le formulaire.
* @returns {boolean} false si la semaine saisie est invalide.
*/
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;
}
/**
* Total des minutes qui S'IMPUTENT SUR LE QUOTA, motifs hors quota exclus.
* Le drapeau du référentiel prime ; `compte_dans_quota` du créneau sert de
* repli pour un motif absent de la configuration publiée.
*/
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);
}
/**
* Met à jour le panneau de quota avec une PROJECTION, et non l'état en base.
*
* Le calcul retire du total serveur les minutes déjà enregistrées pour cette
* semaine (`savedCurrentCountedMinutes`) puis rajoute celles actuellement
* saisies. L'utilisateur voit ainsi l'effet de sa saisie EN COURS, avant
* même d'enregistrer — sans cette soustraction, la semaine courante serait
* comptée deux fois.
*/
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;
/**
* Compare deux créneaux.
* Un créneau enregistré porte un `id_creneau` ; un créneau saisi mais pas
* encore envoyé n'a qu'un `local_id` généré par crypto.randomUUID(). Les
* deux cas doivent être gérés, l'éditeur mêlant les uns et les autres.
*/
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 || '');
}
/**
* Réunit les créneaux du lieu courant et ceux des autres lieux, en marquant
* ces derniers par `is_external`.
*
* C'est la vue à utiliser pour toute recherche de conflit : l'agent ne peut
* pas être à deux endroits à la fois, quel que soit le lieu saisi.
*/
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 })),
];
}
/**
* Cherche un créneau qui empêcherait de poser celui-ci sur $targetDate.
*
* En DÉPLACEMENT, le créneau lui-même est exclu — il libère sa place ; en
* DUPLICATION il est retenu, la copie entrerait en collision avec lui.
*
* Ce contrôle local ne remplace pas celui du serveur : il évite un
* aller-retour réseau pour un geste manifestement impossible.
*/
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;
}
/** Résumé « Motif · 09:00–12:00 · Lieu », pour la modale et les aria-label. */
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}`;
}
/**
* Ferme la modale de déplacement/duplication.
* `modal-open` n'est retiré du body que s'il ne reste aucune autre modale
* ouverte, sinon le défilement de la page serait rétabli trop tôt.
*/
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();
}
/**
* Chemin LOCAL : déplace ou duplique un créneau dans `state.entries`, sans
* appel réseau.
*
* Le clone d'une duplication est débarrassé de son `id_creneau` et de ses
* métadonnées de lieu : c'est un créneau NEUF du planning courant, pas une
* copie d'un créneau externe.
*
* Le message rappelle explicitement qu'il reste à enregistrer : rien n'est
* persisté à ce stade.
*
* @returns {boolean} false en cas de conflit ou de créneau introuvable.
*/
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;
}
/**
* Chemin SERVEUR : envoie le déplacement ou la duplication à l'API.
*
* Subtilité importante — avant de toucher un créneau EXTERNE, le brouillon
* courant est d'abord enregistré. L'appel serveur provoque en effet un
* `loadPlanning()` qui écrase l'état local ; sans cette sauvegarde
* préalable, la saisie en cours serait perdue.
*
* @returns {Promise<boolean>} Succès de l'opération.
*/
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;
}
}
/**
* Aiguille entre le chemin local et le chemin serveur.
*
* Le serveur est saisi dans deux cas seulement : créneau d'un AUTRE lieu, ou
* planning déjà VALIDÉ. Dans les deux cas, le créneau échappe au brouillon
* courant et l'écriture locale n'aurait aucun effet.
*
* Déposer sur le jour d'origine est un non-événement : on renvoie un succès
* sans rien faire.
*/
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);
}
/**
* Valide la modale : le bouton est désactivé pendant l'opération pour
* empêcher un double envoi, et rétabli dans un `finally` afin de ne jamais
* rester bloqué en cas d'erreur.
*/
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;
}
}
/** Retire les marques visuelles de dépôt sur toutes les colonnes. */
function clearDropTargets() {
planningBoard?.querySelectorAll('.day-column').forEach((column) => {
column.classList.remove('is-drop-target', 'is-copy-target');
});
}
/**
* Rend un créneau déplaçable, au clavier comme à la souris.
*
* Un créneau externe n'est interactif que si l'utilisateur a le droit
* `can_edit_cross_structure`. Le `role="button"` et le `tabIndex` assurent
* l'accès clavier : le glisser-déposer seul exclurait ces utilisateurs.
*/
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.currentAgent = 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.currentAgent = 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;
state.currentAgent = data.agent || 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');
}
}
/**
* Cherche un chevauchement avec les créneaux connus du navigateur.
*
* @param {Array} additionalEntries Créneaux en cours de construction, pas
* encore dans l'état — c'est ce qui permet de détecter deux plages
* fautives dans une même saisie.
* @param {boolean} includeCurrentEntries false pendant une resynchronisation
* complète, où `state.entries` est sur le point d'être remplacé et ne
* doit pas se comparer à lui-même.
* @returns {object|null} Le créneau en conflit, ou null.
*/
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;
}
/**
* FORMULAIRE → ÉTAT. Lit toutes les plages saisies, les valide, et remplace
* `state.entries`.
*
* TOUTES les erreurs sont collectées avant d'être affichées d'un bloc :
* corriger une plage à la fois serait pénible sur une semaine complète. Une
* plage entièrement vide est ignorée sans erreur — c'est une ligne non
* utilisée, pas une saisie incomplète.
*
* L'état n'est remplacé QUE si tout est valide : une saisie partiellement
* fautive laisse l'état intact.
*
* @param {{silent?: boolean}} options `silent` supprime le message de
* succès, pour les appels enchaînés avant un enregistrement.
* @returns {boolean} false si au moins une erreur a été relevée.
*/
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 (!isMotifAllowedForAgent(motif)) {
errors.push(`${day?.label || row.dataset.date} ${label} : le motif « ${motif.label} » n’est pas ouvert à cet agent, qui n’a pas droit à l’enveloppe de formation.`);
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;
}
/**
* Bouton « Mettre à jour le planning » : resynchronise depuis le formulaire.
* Le nom est un héritage de la version où l'on ajoutait les créneaux un par un.
*/
function addEntry() {
syncEntriesFromWeeklyForm();
}
/**
* Enregistre le brouillon de la semaine.
*
* Resynchronise d'abord depuis le formulaire : l'utilisateur peut avoir
* modifié une plage sans cliquer sur « Mettre à jour », et ce qui est à
* l'écran doit être ce qui est enregistré.
*
* Rafraîchit ensuite les modules voisins (file de validation, couverture) en
* mode silencieux, leurs compteurs dépendant de cette semaine.
*
* @returns {Promise<number|null>} Identifiant du planning, ou null en échec.
*/
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;
}
}
/** Met à plat la liste de contrôles {niveau, code, message} en texte affichable. */
function controlsToText(controls) {
return controls
.filter((control) => control.niveau !== 'INFO')
.map((control) => `• ${control.message}`)
.join('\n');
}
/**
* Demande la validation du planning au serveur.
*
* Trois réponses possibles, alignées sur PlanningController::validate() :
* - 422 : erreurs bloquantes, rien n'est validé ;
* - 409 : quota dépassé — l'utilisateur est invité à confirmer, ce qui
* rappelle cette fonction avec `forceQuota = true` ;
* - 200 : validé.
*
* @param {boolean} forceQuota true uniquement après confirmation explicite
* de l'utilisateur, jamais par défaut.
*/
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');
}
}
/** Repasse un planning validé en brouillon pour le corriger. */
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');
}
}
/** Changement de semaine : recalcule les jours puis recharge le planning. */
async function handleWeekSelection() {
clearMessage();
updateAgentPdfButton();
const hasValidWeek = populateDays();
setStatus(state.planningStatus);
render();
if (!hasValidWeek || !isPlanningContextReady()) return;
await loadPlanning();
}
/**
* Changement d'agent : réinitialise l'état de saisie.
* Les plages de l'agent précédent ne doivent en aucun cas subsister, elles
* seraient enregistrées sur le nouveau.
*/
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();
}
}
/**
* Clics du formulaire, traités par DÉLÉGATION sur le conteneur.
*
* Les lignes sont redessinées en permanence : attacher des écouteurs à
* chaque bouton obligerait à les reposer après chaque rendu. Un seul
* écouteur sur le parent survit à tous les redessins.
*/
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();
}
}
}
/**
* Point d'entrée appelé par page-bootstrap.js : pose les écouteurs et
* l'état initial de l'écran.
*/
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,
};
})();