/** * Socle JavaScript commun — À CHARGER EN PREMIER, tous les autres modules en * dépendent. * * Il n'y a ni bundler ni module ES dans ce projet : chaque fichier est une IIFE, * et la seule interface entre eux est l'objet global `window.PTA` publié en fin * de fichier. Un module s'enregistre en écrivant dans `PTA.modules` : * * PTA.modules.agentOverview = { init, load }; * * `page-bootstrap.js` appelle ensuite `init()` sur chaque module déclaré par le * PageController, et les modules se rafraîchissent entre eux par des appels * optionnels : `PTA.modules.autre?.refresh?.({ silent: true })`. * * Chemin des données : PHP publie `window.PTA_CONFIG` (layouts/footer.php) et * `window.PTA_PAGE_CONFIG` ; `PTA.config` en est la copie, et contient * notamment `csrfToken`, `motifs` et `permissions`. * * Rappel de versionnement : après toute modification d'un fichier JS ou CSS, il * faut incrémenter le `?v=` dans layouts/header.php, layouts/footer.php ET * Views/pages/role.php, sinon nginx (expires 7d) et les navigateurs continuent * de servir l'ancienne version. */ (() => { 'use strict'; /** * Références DOM résolues UNE FOIS au chargement. * * Le même objet sert à toutes les pages : la plupart des entrées valent donc * `null` sur un écran donné. C'est voulu — d'où les `if (!element) return;` * systématiques dans les modules. Ces sélecteurs concernent principalement * l'éditeur de planning, seul écran assez partagé pour justifier un cache. */ 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'), }; /** * État de l'éditeur de planning, PARTAGÉ ET MUTABLE. * * Plusieurs modules le lisent et l'écrivent (planning-editor, week-templates, * week-template-editor). C'est la contrepartie assumée de l'absence de * framework : avant de modifier une de ces clés, vérifier qui d'autre s'en * sert. */ const state = { entries: [], otherStructureEntries: [], currentStructure: null, currentAgent: null, planningId: null, planningStatus: null, weekDays: [], quota: null, savedCurrentCountedMinutes: 0, }; /** * Ce qu'un module expose aux autres : des données brutes déjà chargées (pour * éviter de refaire un appel réseau) et des FABRIQUES d'écran, quand deux * pages rendent le même bloc à partir de la même API — voir * `comptes-acces.js`, partagé par l'espace DSI et l'onglet Administration. */ const shared = { lastAgentOverviewData: null, }; /** Semaine ouvrée : le modèle métier s'arrête au vendredi. */ 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("'", '''); } /** * Affiche un message dans le bandeau de la page. * @param {string} text * @param {'info'|'success'|'error'|'warning'} type Suffixe de la classe CSS. */ function showMessage(text, type = 'info') { if (!elements.messageBox) return; elements.messageBox.className = `message message-${type}`; elements.messageBox.textContent = text; } /** Vide le bandeau de message. */ function clearMessage() { if (!elements.messageBox) return; elements.messageBox.className = 'message'; elements.messageBox.textContent = ''; } /** * Lit une réponse d'API en JSON, en produisant un message LISIBLE quand ce * n'en est pas. * * Le corps est lu en texte avant d'être analysé : `response.json()` échouerait * sur une page d'erreur HTML avec un message incompréhensible. Ici, le HTML * est dépouillé de ses balises pour faire remonter le message PHP réel. * * Ce filet ne devrait jamais servir — `JsonResponse::bootstrap()` garantit * du JSON côté serveur — mais il reste la seule façon de diagnostiquer une * erreur survenue AVANT ce bootstrap (erreur de syntaxe, page nginx). * * @param {Response} response * @returns {Promise} * @throws {Error} Réponse vide ou non-JSON. */ 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(//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.'}` ); } } /** * Compose le message d'erreur à afficher à partir d'une réponse d'API. * `details` n'est présent que si le mode debug est actif côté serveur. */ function apiErrorMessage(data, fallback) { const base = data?.error || fallback; return data?.details ? `${base} Détail : ${data.details}` : base; } /** * Appel d'API mutualisé : lit le JSON et transforme toute réponse non-OK en * exception, de sorte que l'appelant n'a qu'un seul `catch` à écrire. * * Chaque écran avait recopié ces quatre lignes ; elles vivent ici depuis * qu'elles servaient à l'identique dans quatre modules. */ async function apiJson(url, options = {}) { const response = await fetch(url, options); const data = await readJsonResponse(response); if (!response.ok) throw new Error(apiErrorMessage(data, 'Opération impossible.')); return data; } /** * Raccourci des appels mutants : POST JSON avec le jeton CSRF DANS LE CORPS. * PTA ne lit jamais le jeton depuis un en-tête (voir Csrf::assertPayload). */ function postJson(url, payload) { return apiJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrf_token: window.PTA_CONFIG?.csrfToken, ...payload }), }); } /** * Message dans un bandeau désigné par son identifiant HTML. * * `showMessage()` vise le bandeau unique de l'éditeur de planning * (`elements.messageBox`) ; les écrans qui en portent plusieurs — comptes, * jetons, journal — passent par ici. */ function showMessageIn(id, text, type = 'info') { const box = document.getElementById(id); if (!box) return; box.className = `message message-${type}`; box.textContent = text; } /** * « 2026-08-20 09:41:07.482 » → « 20/08/2026 09:41 ». * Découpage de chaîne pour la même raison que `formatDate()` : `new Date()` * interpréterait la valeur en UTC. */ function formatDateTime(value) { if (!value) return '—'; const [date, time = ''] = String(value).split(' '); const [year, month, day] = date.split('-'); return `${day}/${month}/${year} ${time.slice(0, 5)}`; } /** * Date → « AAAA-MM-JJ » en heure LOCALE. * `toISOString()` convertirait en UTC et décalerait la date d'un jour selon * l'heure et le fuseau. */ 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}`; } /** * « 2026-W08 » → les cinq jours ouvrés de cette semaine ISO. * * Le calcul s'appuie sur la définition ISO 8601 : la semaine 1 est celle qui * contient le 4 JANVIER. On recule donc jusqu'au lundi de cette semaine-là, * puis on avance de (semaine - 1) × 7 jours. `getDay() || 7` ramène dimanche * de 0 à 7, la numérotation ISO commençant au lundi. * * @param {string} value Semaine au format de l'input HTML `type="week"`. * @returns {Array<{date: string, label: string, display: string}>} Vide si * le format ne correspond pas. */ 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' }), }; }); } /** « 09:30 » → 570. Comparer des minutes évite les pièges de tri de chaînes. */ function timeToMinutes(time) { const [hours, minutes] = String(time).split(':').map(Number); return hours * 60 + minutes; } /** * 450 → « 7 h 30 ». Gère les valeurs négatives (dépassement de quota) : le * signe est extrait avant la division. */ 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')}`; } /** * « 2026-02-17 » → « 17/02/2026 ». * Découpage de chaîne plutôt que `new Date()`, qui interpréterait la valeur * en UTC et pourrait afficher la veille. */ function formatDate(value) { if (!value) return ''; const [year, month, day] = String(value).split('-'); return `${day}/${month}/${year}`; } /** * Retrouve un motif dans le référentiel publié par PHP. * @returns {{id: number, code: string, label: string, quota: boolean}|null} * `quota` indique si le motif s'impute sur le quota annuel. */ 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; } /** * Interface publique, unique point de contact entre les modules. * Ne jamais y ajouter de logique métier : `utils` ne contient que des * fonctions sans état. */ window.PTA = { config: window.PTA_CONFIG || {}, elements, state, shared, modules: {}, utils: { escapeHtml, showMessage, clearMessage, readJsonResponse, apiErrorMessage, apiJson, postJson, showMessageIn, formatDateTime, parseIsoWeek, timeToMinutes, formatDuration, formatDate, motifInfo, }, }; })();