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>
This commit is contained in:
PTA
2026-08-17 16:53:04 +02:00
parent 57bd8da5c8
commit 4201894e28
69 changed files with 5228 additions and 246 deletions

View File

@@ -0,0 +1,579 @@
(() => {
'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),
};
})();