Files
pta/assets/js/week-template-editor.js
PTA 4201894e28 V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation
Reprise dans le répertoire de production du travail mené dans le worktree
ux-service-enfance :

- tableau de bord Service Enfance (dashboard.php) et navigation regroupée ;
- guide d'utilisation (faq.php) accessible depuis toute la navigation ;
- éditeur de semaine type mis en avant sur l'écran de saisie ;
- droit à l'enveloppe de formation par agent, en remplacement de la case
  « formation déjà prévue dans la répartition annuelle » ;
- correctif de « [object Object] » sur les cartes de quota du PTA ;
- motif de travail par défaut à la saisie, heures travaillées complètes
  dans les PDF.

Le docker-compose.yml de production est conservé : celui du worktree décrit
une pile de test (conteneurs et volume suffixés « test », ports 9080/9081),
qui aurait fait redémarrer l'application sur un volume MariaDB vide.

Les migrations database/migration_v7_9*.sql sont obligatoires ; le service
db-migrate les applique au déploiement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:53:04 +02:00

580 lines
24 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.
(() => {
'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;
function setMessage(text = '', type = 'info') {
if (!messageElement) return;
messageElement.className = text ? `message message-${type}` : 'message';
messageElement.textContent = text;
}
function agentId() {
return Number(agentSelect?.value || 0) || null;
}
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;
}
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;
}
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('');
}
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('');
}
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('');
}
function rangesFor(weekday) {
return draft.get(weekday) || [];
}
function emptyDraft() {
const next = new Map();
DAYS.forEach((day) => next.set(day.weekday, []));
return next;
}
function newRange() {
return {
heure_debut: '',
heure_fin: '',
id_motif: defaultMotifId(),
id_structure: defaultStructureId(),
};
}
function rangeMinutes(range) {
if (!range.heure_debut || !range.heure_fin) return 0;
return Math.max(0, timeToMinutes(range.heure_fin) - timeToMinutes(range.heure_debut));
}
function dayMinutes(weekday) {
return rangesFor(weekday).reduce((total, range) => total + rangeMinutes(range), 0);
}
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 };
}
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>`;
}
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();
}
function hasAnyRange() {
return DAYS.some((day) => rangesFor(day.weekday).length > 0);
}
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;
}
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('');
}
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');
}
}
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;
}
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;
}
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();
}
}
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();
}
}
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();
}
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));
}
function onAgentChanged() {
currentTemplateId = null;
draft = emptyDraft();
if (nameInput) nameInput.value = '';
setMessage('');
loadTemplates().then(render);
}
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),
};
})();