/** * Éditeur de semaine type (`PTA.modules.weekTemplateEditor`, V7.10). * * Permet de COMPOSER un modèle lundi→vendredi directement, sans partir d'une * semaine réellement planifiée. À distinguer de `week-templates.js`, qui * enregistre et applique les modèles. * * MODES EXCLUSIFS DE planning.php — ce module pilote la bascule entre l'éditeur * de semaine type (ouvert par défaut) et la saisie semaine par semaine, via * `setMode()`. `page-bootstrap.js` force le second dès que l'URL porte `week` ou * `focus` : un utilisateur venant corriger un planning identifié doit atterrir * sur la saisie, pas sur l'éditeur de modèles. * * ÉTAT LOCAL — `draft` est une Map indexée par jour (1 à 5), contenant les * plages en cours d'édition. Elle est indépendante de `PTA.state`, qui décrit la * semaine réellement planifiée : un modèle n'est pas un planning. * * Le DOM fait autorité entre deux rendus : `syncDraftFromDom()` est appelé avant * toute opération sur `draft`, car l'utilisateur peut avoir tapé dans les champs * sans qu'aucun événement n'ait été traité. */ (() => { 'use strict'; const PTA = window.PTA; if (!PTA) throw new Error('PTA core doit être chargé avant week-template-editor.js'); const { state } = PTA; const { structureSelect, agentSelect, weekInput } = PTA.elements; const { escapeHtml, readJsonResponse, apiErrorMessage, formatDuration, timeToMinutes, motifInfo } = PTA.utils; const panel = document.querySelector('#template-mode-panel'); const weekPanel = document.querySelector('#week-mode-panel'); const modeTemplateButton = document.querySelector('#editor-mode-template'); const modeWeekButton = document.querySelector('#editor-mode-week'); const templateSelect = document.querySelector('#template-editor-select'); const nameInput = document.querySelector('#template-editor-name'); const gridElement = document.querySelector('#template-editor-grid'); const emptyElement = document.querySelector('#template-editor-empty'); const messageElement = document.querySelector('#template-editor-message'); const totalElement = document.querySelector('#template-editor-total'); const totalDetailElement = document.querySelector('#template-editor-total-detail'); const saveButton = document.querySelector('#template-editor-save'); const deleteButton = document.querySelector('#template-editor-delete'); const applyPeriodButton = document.querySelector('#template-editor-apply-period'); const applyWeekButton = document.querySelector('#template-editor-apply-week'); const DAYS = [ { weekday: 1, label: 'Lundi' }, { weekday: 2, label: 'Mardi' }, { weekday: 3, label: 'Mercredi' }, { weekday: 4, label: 'Jeudi' }, { weekday: 5, label: 'Vendredi' }, ]; /** Plages de la semaine type en cours d'édition, indexées par jour. */ let draft = new Map(); let templates = []; let currentTemplateId = null; let loading = false; /** Message propre à l'éditeur, distinct du bandeau global de la page. */ function setMessage(text = '', type = 'info') { if (!messageElement) return; messageElement.className = text ? `message message-${type}` : 'message'; messageElement.textContent = text; } /** Agent auquel le modèle appartient ; un modèle est toujours nominatif. */ function agentId() { return Number(agentSelect?.value || 0) || null; } /** * Lieu proposé par défaut sur une nouvelle plage : celui sélectionné dans * l'écran, sinon le premier disponible. Une plage sans lieu est refusée. */ function defaultStructureId() { const selected = Number(structureSelect?.value || 0); if (selected) return selected; const first = structureSelect?.querySelector('option[value]:not([value=""])'); return first ? Number(first.value) : null; } /** * Un agent sans droit à l'enveloppe de formation ne peut pas recevoir le * motif « Formation ». Même règle qu'à la saisie semaine par semaine : 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; } /** Motifs proposables à CET agent, « Formation » exclue s'il n'y a pas droit. */ function motifs() { return window.PTA_CONFIG?.motifs || []; } function defaultMotifId() { const list = motifs(); const motif = list.find((item) => String(item.code).toUpperCase() === 'TRAVAIL') || list.find((item) => item.quota) || list[0]; return motif ? Number(motif.id) : null; } /** Options du sélecteur de motif d'une plage. */ function motifOptionsHtml(selectedMotifId = null) { return motifs() // Un motif interdit reste proposé s'il est déjà celui de la plage : // le retirer changerait silencieusement une saisie existante. .filter((motif) => agentHasTrainingRight() || String(motif.code).toUpperCase() !== 'FORMATION' || 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(''); } /** * Options du sélecteur de lieu. * Un modèle peut porter des plages sur PLUSIEURS lieux : c'est ce qui le * réserve au Service Enfance. */ function structureOptionsHtml(selectedStructureId = null) { return [...(structureSelect?.querySelectorAll('option[value]:not([value=""])') || [])] .map((option) => { const selected = Number(option.value) === Number(selectedStructureId) ? ' selected' : ''; return ``; }) .join(''); } /** * Cibles de recopie d'un jour : chacun des autres jours, ou tous d'un coup. * Un agent a souvent le même rythme du lundi au vendredi ; recopier évite de * ressaisir cinq fois la même chose. */ function copyTargetOptionsHtml(weekday) { const others = DAYS.filter((day) => day.weekday !== weekday); return [ '', ...others.map((day) => ``), '', ].join(''); } /** Plages d'un jour du brouillon (tableau vide si le jour est libre). */ function rangesFor(weekday) { return draft.get(weekday) || []; } /** Brouillon vierge : les cinq jours présents, tous sans plage. */ function emptyDraft() { const next = new Map(); DAYS.forEach((day) => next.set(day.weekday, [])); return next; } /** Plage neuve, horaires vides mais motif et lieu déjà proposés. */ function newRange() { return { heure_debut: '', heure_fin: '', id_motif: defaultMotifId(), id_structure: defaultStructureId(), }; } /** Durée d'une plage ; 0 tant qu'elle est incomplète, jamais négatif. */ function rangeMinutes(range) { if (!range.heure_debut || !range.heure_fin) return 0; return Math.max(0, timeToMinutes(range.heure_fin) - timeToMinutes(range.heure_debut)); } /** Total des minutes d'un jour. */ function dayMinutes(weekday) { return rangesFor(weekday).reduce((total, range) => total + rangeMinutes(range), 0); } /** * Totaux du modèle : toutes les minutes, et celles qui s'imputeraient sur le * quota. La distinction compte, un modèle pouvant contenir des congés. */ function totals() { let all = 0; let counted = 0; DAYS.forEach((day) => { rangesFor(day.weekday).forEach((range) => { const minutes = rangeMinutes(range); all += minutes; if (motifInfo(range.id_motif)?.quota) counted += minutes; }); }); return { all, counted }; } /** Balisage d'une plage : motif, lieu, horaires, suppression. */ function rangeHtml(weekday, index, range) { const label = `${DAYS.find((day) => day.weekday === weekday)?.label || 'Jour'} plage ${index + 1}`; return `
`; } /** * Redessine toute la grille depuis `draft`, puis met à jour les totaux. * Rendu complet plutôt que ciblé : le nombre de plages par jour varie sans * cesse, et une mise à jour fine serait plus fragile. */ function render() { if (!gridElement) return; const ready = Boolean(agentId()); if (emptyElement) emptyElement.hidden = ready; gridElement.hidden = !ready; if (ready) { gridElement.innerHTML = DAYS.map((day) => { const ranges = rangesFor(day.weekday); const minutes = dayMinutes(day.weekday); return `
${escapeHtml(day.label)} ${minutes > 0 ? formatDuration(minutes) : '—'}
${ranges.length === 0 ? '

Aucune plage — jour non travaillé.

' : ranges.map((range, index) => rangeHtml(day.weekday, index, range)).join('')}
`; }).join(''); } const { all, counted } = totals(); if (totalElement) totalElement.textContent = formatDuration(all); if (totalDetailElement) totalDetailElement.textContent = `dont ${formatDuration(counted)} décomptées`; refreshButtons(); } /** Vrai si le brouillon contient au moins une plage. */ function hasAnyRange() { return DAYS.some((day) => rangesFor(day.weekday).length > 0); } /** * Active ou désactive les actions selon l'état : agent choisi, nom saisi, * modèle non vide, absence d'appel réseau en cours. */ function refreshButtons() { const ready = Boolean(agentId()); const named = Boolean(nameInput?.value.trim()); if (templateSelect) templateSelect.disabled = !ready; if (nameInput) nameInput.disabled = !ready; if (saveButton) saveButton.disabled = !ready || !named || !hasAnyRange() || loading; if (deleteButton) deleteButton.disabled = !ready || currentTemplateId === null || loading; // Dupliquer suppose un modèle déjà enregistré : on ne duplique jamais un // brouillon d'écran, seulement ce qui est réellement en base. if (applyPeriodButton) applyPeriodButton.disabled = !ready || currentTemplateId === null || loading; if (applyWeekButton) applyWeekButton.disabled = !ready || currentTemplateId === null || loading; } /** Remplit le sélecteur des modèles existants de l'agent. */ function renderTemplateSelect() { if (!templateSelect) return; const options = ['']; templates.forEach((template) => { const selected = Number(template.id) === Number(currentTemplateId) ? ' selected' : ''; const detail = `${template.entry_count} plage${template.entry_count > 1 ? 's' : ''}`; options.push(``); }); templateSelect.innerHTML = options.join(''); } /** Recharge la liste des modèles de l'agent courant. */ async function loadTemplates() { const agent = agentId(); if (!agent) { templates = []; renderTemplateSelect(); return; } try { const response = await fetch(`api/week_templates.php?agent_id=${agent}`, { headers: { Accept: 'application/json' }, }); const data = await readJsonResponse(response); if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les semaines types.')); templates = Array.isArray(data.templates) ? data.templates : []; renderTemplateSelect(); } catch (error) { setMessage(error.message, 'error'); } } /** * Charge un modèle existant dans le brouillon pour le modifier. * Les plages sont réparties par jour dans la Map ; les horaires arrivent en * HH:MM, format attendu par les champs `type="time"`. */ async function openTemplate(templateId) { const agent = agentId(); if (!agent || !templateId) { currentTemplateId = null; draft = emptyDraft(); if (nameInput) nameInput.value = ''; render(); return; } loading = true; refreshButtons(); try { const response = await fetch(`api/week_template.php?agent_id=${agent}&template_id=${Number(templateId)}`, { headers: { Accept: 'application/json' }, }); const data = await readJsonResponse(response); if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’ouvrir cette semaine type.')); currentTemplateId = Number(data.template.id); if (nameInput) nameInput.value = data.template.name; draft = emptyDraft(); (data.entries || []).forEach((entry) => { const weekday = Number(entry.jour_semaine); if (!draft.has(weekday)) return; draft.get(weekday).push({ heure_debut: entry.heure_debut, heure_fin: entry.heure_fin, id_motif: Number(entry.id_motif), id_structure: Number(entry.id_structure), }); }); setMessage(''); } catch (error) { setMessage(error.message, 'error'); } finally { loading = false; render(); } } /** Lit les valeurs saisies à l'écran dans le brouillon en mémoire. */ function syncDraftFromDom() { if (!gridElement || gridElement.hidden) return; const next = emptyDraft(); gridElement.querySelectorAll('.template-range').forEach((element) => { const weekday = Number(element.dataset.weekday); if (!next.has(weekday)) return; next.get(weekday).push({ heure_debut: element.querySelector('.template-range-start')?.value || '', heure_fin: element.querySelector('.template-range-end')?.value || '', id_motif: Number(element.querySelector('.template-range-motif')?.value || 0) || null, id_structure: Number(element.querySelector('.template-range-structure')?.value || 0) || null, }); }); draft = next; } /** * Contrôles faits à l'écran pour éviter un aller-retour serveur inutile. * Le serveur revalide tout : ceci n'est qu'une commodité. */ function localValidationError() { for (const day of DAYS) { const ranges = rangesFor(day.weekday); for (const range of ranges) { if (!range.heure_debut || !range.heure_fin) { return `${day.label} : une plage n’a pas d’horaire de début ou de fin.`; } if (timeToMinutes(range.heure_fin) <= timeToMinutes(range.heure_debut)) { return `${day.label} : la fin doit être postérieure au début (${range.heure_debut} → ${range.heure_fin}).`; } if (!range.id_motif || !range.id_structure) { return `${day.label} : chaque plage doit avoir un motif et un lieu d’affectation.`; } } const sorted = [...ranges].sort((a, b) => a.heure_debut.localeCompare(b.heure_debut)); for (let index = 1; index < sorted.length; index++) { if (sorted[index].heure_debut < sorted[index - 1].heure_fin) { return `${day.label} : deux plages se chevauchent (${sorted[index - 1].heure_debut}–${sorted[index - 1].heure_fin} et ${sorted[index].heure_debut}–${sorted[index].heure_fin}).`; } } } return null; } /** * Met à plat la Map en la liste attendue par l'API : un objet par plage, * portant son numéro de jour. */ function flatEntries() { const entries = []; DAYS.forEach((day) => { rangesFor(day.weekday).forEach((range) => { entries.push({ jour_semaine: day.weekday, heure_debut: range.heure_debut, heure_fin: range.heure_fin, id_motif: range.id_motif, id_structure: range.id_structure, }); }); }); return entries; } /** * Enregistre le modèle. * Un 409 signifie qu'un modèle porte déjà ce nom : l'utilisateur se voit * proposer le remplacement, ce qui rappelle cette fonction avec * `replace = true`. */ async function save(replace = false) { const agent = agentId(); const name = nameInput?.value.trim() || ''; if (!agent || !name) return; syncDraftFromDom(); const error = localValidationError(); if (error) { setMessage(error, 'error'); return; } loading = true; refreshButtons(); try { const response = await fetch('api/save_week_template_entries.php', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, agent_id: agent, name, replace, entries: flatEntries(), }), }); const data = await readJsonResponse(response); if (response.status === 409 && data.code === 'TEMPLATE_EXISTS' && !replace) { loading = false; if (window.confirm(`Une semaine type « ${name} » existe déjà pour cet agent. La remplacer ?`)) { await save(true); return; } setMessage('Enregistrement annulé : le nom est déjà utilisé.', 'warning'); refreshButtons(); return; } if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’enregistrer la semaine type.')); currentTemplateId = Number(data.template.id); setMessage(`${data.message} Elle peut maintenant être dupliquée sur une période.`, 'success'); await loadTemplates(); renderTemplateSelect(); } catch (err) { setMessage(err.message, 'error'); } finally { loading = false; render(); } } /** Supprime le modèle ouvert, après confirmation. */ async function removeTemplate() { const agent = agentId(); if (!agent || currentTemplateId === null) return; const template = templates.find((item) => Number(item.id) === Number(currentTemplateId)); if (!window.confirm(`Supprimer définitivement la semaine type « ${template?.name || ''} » ? Les plannings déjà créés ne sont pas affectés.`)) { return; } loading = true; refreshButtons(); try { const response = await fetch('api/delete_week_template.php', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ csrf_token: window.PTA_CONFIG.csrfToken, agent_id: agent, template_id: currentTemplateId, }), }); const data = await readJsonResponse(response); if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de supprimer la semaine type.')); currentTemplateId = null; draft = emptyDraft(); if (nameInput) nameInput.value = ''; setMessage(data.message || 'Semaine type supprimée.', 'success'); await loadTemplates(); } catch (error) { setMessage(error.message, 'error'); } finally { loading = false; render(); } } /** * Recopie les plages d'un jour vers un autre, ou vers tous les autres. * * Les plages sont CLONÉES (`{ ...range }`) : sans cette copie, les jours * partageraient les mêmes objets et modifier l'un modifierait les autres. */ function copyDay(sourceWeekday, target) { syncDraftFromDom(); const source = rangesFor(sourceWeekday); if (source.length === 0) return; const targets = target === 'all' ? DAYS.filter((day) => day.weekday !== sourceWeekday).map((day) => day.weekday) : [Number(target)]; targets.forEach((weekday) => { draft.set(weekday, source.map((range) => ({ ...range }))); }); const sourceLabel = DAYS.find((day) => day.weekday === sourceWeekday)?.label || 'Ce jour'; const targetLabel = target === 'all' ? 'tous les autres jours' : (DAYS.find((day) => day.weekday === Number(target))?.label || 'le jour choisi').toLowerCase(); setMessage(`${sourceLabel} copié sur ${targetLabel}. Pensez à enregistrer la semaine type.`, 'info'); render(); } /** * Bascule entre les deux modes exclusifs de planning.php. * Appelée aussi par `page-bootstrap.js`, qui force le mode « week » sur les * liens profonds portant une semaine ou un focus. * * @param {'template'|'week'} mode */ function setMode(mode) { const isTemplate = mode === 'template'; if (panel) panel.hidden = !isTemplate; if (weekPanel) weekPanel.hidden = isTemplate; modeTemplateButton?.classList.toggle('is-active', isTemplate); modeWeekButton?.classList.toggle('is-active', !isTemplate); modeTemplateButton?.setAttribute('aria-selected', String(isTemplate)); modeWeekButton?.setAttribute('aria-selected', String(!isTemplate)); } /** * Changement d'agent : le brouillon est vidé et les modèles rechargés. * Un modèle appartient à un seul agent, celui de l'agent précédent n'a plus * de sens. */ function onAgentChanged() { currentTemplateId = null; draft = emptyDraft(); if (nameInput) nameInput.value = ''; setMessage(''); loadTemplates().then(render); } /** Point d'entrée appelé par page-bootstrap.js. */ function init() { draft = emptyDraft(); modeTemplateButton?.addEventListener('click', () => setMode('template')); modeWeekButton?.addEventListener('click', () => setMode('week')); agentSelect?.addEventListener('change', onAgentChanged); structureSelect?.addEventListener('change', () => { // Le lieu par défaut des nouvelles plages suit la sélection. loadTemplates(); }); templateSelect?.addEventListener('change', (event) => openTemplate(event.target.value)); nameInput?.addEventListener('input', refreshButtons); gridElement?.addEventListener('click', (event) => { const addButton = event.target.closest('.template-day-add'); if (addButton) { syncDraftFromDom(); const weekday = Number(addButton.dataset.weekday); draft.get(weekday)?.push(newRange()); render(); return; } const removeButton = event.target.closest('.template-range-remove'); if (removeButton) { const range = removeButton.closest('.template-range'); syncDraftFromDom(); const weekday = Number(range.dataset.weekday); const index = Number(range.dataset.index); draft.get(weekday)?.splice(index, 1); render(); } }); gridElement?.addEventListener('change', (event) => { const copySelect = event.target.closest('.template-day-copy'); if (copySelect) { const target = copySelect.value; copySelect.value = ''; if (target) copyDay(Number(copySelect.dataset.weekday), target); return; } // Toute autre modification met simplement les totaux à jour. syncDraftFromDom(); render(); }); saveButton?.addEventListener('click', () => save(false)); deleteButton?.addEventListener('click', removeTemplate); applyPeriodButton?.addEventListener('click', () => { PTA.modules.weekTemplates?.openPeriodModal?.(currentTemplateId); }); applyWeekButton?.addEventListener('click', () => { if (!weekInput?.value) { setMessage('Choisissez d’abord une semaine dans la sélection en haut de page.', 'warning'); return; } PTA.modules.weekTemplates?.openApplyModal?.(currentTemplateId); }); render(); if (agentId()) loadTemplates().then(render); } PTA.modules.weekTemplateEditor = { init, setMode, refresh: () => loadTemplates().then(render), }; })();