Nouvelle colonne `agent.droit_enveloppe_formation`, cochée par défaut, saisie à la création de l'agent et modifiable depuis sa fiche. Un agent sans ce droit : - ne voit plus le motif « Formation » dans le sélecteur de motif de l'éditeur de planning ni dans le modal de la vue individuelle ; - se voit refuser toute affectation de ce motif par le serveur, avec un message qui nomme le motif et l'agent (save_planning.php et update_agent_entry.php) ; - a une enveloppe de formation de 0 min au PTA annuel, et une ALERTE FORMATION_SANS_DROIT si des heures de formation subsistent dans son planning. À ne pas confondre avec `formation_repartition_annuelle`, qui existait déjà : celle-ci dit que la formation est déjà comptée dans la répartition annuelle — l'agent y a droit, l'enveloppe complémentaire n'est simplement pas ajoutée. Les deux règles sont désormais rassemblées dans AgentModel::trainingEnvelopeMinutes(), qui remplace les deux calculs « 840 ou 0 » jusque-là dupliqués. Le motif interdit reste affiché lorsqu'il est déjà celui de la plage en cours d'édition : le retirer de la liste changerait silencieusement le motif d'une saisie existante. C'est le contrôle de saisie, puis le serveur, qui demandent alors de corriger la plage. La migration est nommée v7_9b et non v7_10 : les fichiers sont joués dans l'ordre alphabétique, où « v7_10 » passerait avant « v7_8 ». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
6.4 KiB
JavaScript
188 lines
6.4 KiB
JavaScript
(() => {
|
|
'use strict';
|
|
|
|
const elements = {
|
|
structureSelect: document.querySelector('#structure'),
|
|
agentSelect: document.querySelector('#agent'),
|
|
weekInput: document.querySelector('#week'),
|
|
weeklyEntryBody: document.querySelector('#weekly-entry-body'),
|
|
addButton: document.querySelector('#add-entry'),
|
|
saveButton: document.querySelector('#save-draft'),
|
|
validateButton: document.querySelector('#validate-planning'),
|
|
modifyButton: document.querySelector('#modify-planning'),
|
|
planningBoard: document.querySelector('#planning-board'),
|
|
emptyState: document.querySelector('#empty-state'),
|
|
weekLabel: document.querySelector('#week-label'),
|
|
workTotal: document.querySelector('#work-total'),
|
|
statusBadge: document.querySelector('#planning-status'),
|
|
messageBox: document.querySelector('#message'),
|
|
openAgentPdfButton: document.querySelector('#open-agent-pdf'),
|
|
openStructurePdfButton: document.querySelector('#open-structure-pdf'),
|
|
quotaPanel: document.querySelector('#editor-quota'),
|
|
quotaRate: document.querySelector('#quota-rate'),
|
|
quotaTarget: document.querySelector('#quota-target'),
|
|
quotaPeriod: document.querySelector('#quota-period'),
|
|
quotaUsed: document.querySelector('#quota-used'),
|
|
quotaUsedDetail: document.querySelector('#quota-used-detail'),
|
|
quotaRemaining: document.querySelector('#quota-remaining'),
|
|
};
|
|
|
|
const state = {
|
|
entries: [],
|
|
otherStructureEntries: [],
|
|
currentStructure: null,
|
|
currentAgent: null,
|
|
planningId: null,
|
|
planningStatus: null,
|
|
weekDays: [],
|
|
quota: null,
|
|
savedCurrentCountedMinutes: 0,
|
|
};
|
|
|
|
const shared = {
|
|
lastAgentOverviewData: null,
|
|
};
|
|
|
|
const dayNames = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi'];
|
|
|
|
/**
|
|
* Échappe une valeur pour l'insertion dans du HTML, texte ET attribut.
|
|
*
|
|
* La sérialisation via textContent/innerHTML n'échappe ni " ni ' : elle est
|
|
* sûre entre deux balises, mais permet de sortir d'un attribut value="…" ou
|
|
* title="…". PTA insère des libellés de vacances, des noms de lieux et des
|
|
* commentaires dans des attributs : les guillemets doivent être échappés.
|
|
*/
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
function showMessage(text, type = 'info') {
|
|
if (!elements.messageBox) return;
|
|
elements.messageBox.className = `message message-${type}`;
|
|
elements.messageBox.textContent = text;
|
|
}
|
|
|
|
function clearMessage() {
|
|
if (!elements.messageBox) return;
|
|
elements.messageBox.className = 'message';
|
|
elements.messageBox.textContent = '';
|
|
}
|
|
|
|
async function readJsonResponse(response) {
|
|
const raw = await response.text();
|
|
|
|
if (!raw.trim()) {
|
|
throw new Error(`Le serveur a renvoyé une réponse vide (HTTP ${response.status}).`);
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch (error) {
|
|
const readable = raw
|
|
.replace(/<br\s*\/?\s*>/gi, ' ')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /gi, ' ')
|
|
.replace(/"/gi, '"')
|
|
.replace(/'/gi, "'")
|
|
.replace(/</gi, '<')
|
|
.replace(/>/gi, '>')
|
|
.replace(/&/gi, '&')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
throw new Error(
|
|
`Le serveur PHP n'a pas renvoyé du JSON valide. ${readable.slice(0, 500) || 'Consultez les logs PHP.'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function apiErrorMessage(data, fallback) {
|
|
const base = data?.error || fallback;
|
|
return data?.details ? `${base} Détail : ${data.details}` : base;
|
|
}
|
|
|
|
function toIsoDate(date) {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function parseIsoWeek(value) {
|
|
const match = /^(\d{4})-W(\d{2})$/.exec(value);
|
|
if (!match) return [];
|
|
|
|
const year = Number(match[1]);
|
|
const week = Number(match[2]);
|
|
const jan4 = new Date(year, 0, 4);
|
|
const jan4Day = jan4.getDay() || 7;
|
|
const monday = new Date(jan4);
|
|
monday.setDate(jan4.getDate() - jan4Day + 1 + (week - 1) * 7);
|
|
|
|
return Array.from({ length: 5 }, (_, index) => {
|
|
const date = new Date(monday);
|
|
date.setDate(monday.getDate() + index);
|
|
return {
|
|
date: toIsoDate(date),
|
|
label: dayNames[index],
|
|
display: date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }),
|
|
};
|
|
});
|
|
}
|
|
|
|
function timeToMinutes(time) {
|
|
const [hours, minutes] = String(time).split(':').map(Number);
|
|
return hours * 60 + minutes;
|
|
}
|
|
|
|
function formatDuration(minutes) {
|
|
const sign = minutes < 0 ? '-' : '';
|
|
const absolute = Math.abs(Math.round(minutes));
|
|
const hours = Math.floor(absolute / 60);
|
|
const mins = absolute % 60;
|
|
return `${sign}${hours} h ${String(mins).padStart(2, '0')}`;
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '';
|
|
const [year, month, day] = String(value).split('-');
|
|
return `${day}/${month}/${year}`;
|
|
}
|
|
|
|
function motifInfo(id) {
|
|
const motif = (window.PTA_CONFIG?.motifs || []).find((item) => Number(item.id) === Number(id));
|
|
return motif ? {
|
|
id: Number(motif.id),
|
|
code: motif.code,
|
|
label: motif.label,
|
|
quota: Boolean(motif.quota),
|
|
} : null;
|
|
}
|
|
|
|
window.PTA = {
|
|
config: window.PTA_CONFIG || {},
|
|
elements,
|
|
state,
|
|
shared,
|
|
modules: {},
|
|
utils: {
|
|
escapeHtml,
|
|
showMessage,
|
|
clearMessage,
|
|
readJsonResponse,
|
|
apiErrorMessage,
|
|
parseIsoWeek,
|
|
timeToMinutes,
|
|
formatDuration,
|
|
formatDate,
|
|
motifInfo,
|
|
},
|
|
};
|
|
})();
|