353 lines
13 KiB
JavaScript
353 lines
13 KiB
JavaScript
/**
|
||
* 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<object>}
|
||
* @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(/<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.'}`
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
},
|
||
};
|
||
})();
|