/**
* É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 ``;
})
.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 ? '' : '';
return `
→
`;
}
/**
* 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 = '
Sélectionnez d’abord un lieu d’affectation, un agent et une semaine.
`;
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) => (
``
)).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} 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 = `
${day.label}${day.display}
`;
makeDayDropTarget(column, day);
const entriesContainer = column.querySelector('.day-entries');
if (entries.length === 0) {
entriesContainer.innerHTML = '