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

676 lines
28 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 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 `<option value="${Number(motif.id)}"${selected}>${escapeHtml(motif.label)}${quotaLabel}</option>`;
})
.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 `<option value="${Number(option.value)}"${selected}>${escapeHtml(option.textContent.trim())}</option>`;
})
.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 [
'<option value="">Copier vers…</option>',
...others.map((day) => `<option value="${day.weekday}">${escapeHtml(day.label)}</option>`),
'<option value="all">Tous les autres jours</option>',
].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 `
<div class="template-range" data-weekday="${weekday}" data-index="${index}">
<label class="template-range-field template-range-field-motif">
<span>Motif</span>
<select class="template-range-motif" aria-label="Motif ${escapeHtml(label)}">
${motifOptionsHtml(range.id_motif)}
</select>
</label>
<label class="template-range-field">
<span>Début</span>
<input class="template-range-start" type="time" step="900" value="${escapeHtml(range.heure_debut)}" aria-label="Début ${escapeHtml(label)}">
</label>
<span class="template-range-separator" aria-hidden="true">→</span>
<label class="template-range-field">
<span>Fin</span>
<input class="template-range-end" type="time" step="900" value="${escapeHtml(range.heure_fin)}" aria-label="Fin ${escapeHtml(label)}">
</label>
<label class="template-range-field template-range-field-structure">
<span>Lieu</span>
<select class="template-range-structure" aria-label="Lieu ${escapeHtml(label)}">
${structureOptionsHtml(range.id_structure)}
</select>
</label>
<button type="button" class="button button-ghost template-range-remove" aria-label="Supprimer ${escapeHtml(label)}" title="Supprimer cette plage">×</button>
</div>`;
}
/**
* 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 `
<article class="template-day${ranges.length === 0 ? ' is-empty' : ''}" data-weekday="${day.weekday}">
<header class="template-day-header">
<strong>${escapeHtml(day.label)}</strong>
<span class="template-day-total">${minutes > 0 ? formatDuration(minutes) : '—'}</span>
</header>
<div class="template-day-ranges">
${ranges.length === 0
? '<p class="template-day-empty">Aucune plage — jour non travaillé.</p>'
: ranges.map((range, index) => rangeHtml(day.weekday, index, range)).join('')}
</div>
<footer class="template-day-actions">
<button type="button" class="button button-secondary template-day-add" data-weekday="${day.weekday}">+ Ajouter une plage</button>
<label class="template-day-copy-field">
<span class="visually-hidden">Copier ${escapeHtml(day.label)} vers un autre jour</span>
<select class="template-day-copy" data-weekday="${day.weekday}" aria-label="Copier ${escapeHtml(day.label)} vers un autre jour"${ranges.length === 0 ? ' disabled' : ''}>
${copyTargetOptionsHtml(day.weekday)}
</select>
</label>
</footer>
</article>`;
}).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 = ['<option value="">➕ Nouvelle semaine type</option>'];
templates.forEach((template) => {
const selected = Number(template.id) === Number(currentTemplateId) ? ' selected' : '';
const detail = `${template.entry_count} plage${template.entry_count > 1 ? 's' : ''}`;
options.push(`<option value="${Number(template.id)}"${selected}>${escapeHtml(template.name)} — ${detail}</option>`);
});
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),
};
})();