Files
pta/assets/js/core.js
2026-08-20 13:22:49 +02:00

295 lines
11 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.
/**
* 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,
};
/**
* Cache de données brutes qu'un module expose aux autres, pour éviter de
* refaire un appel réseau déjà effectué.
*/
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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
/**
* 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(/&nbsp;/gi, ' ')
.replace(/&quot;/gi, '"')
.replace(/&#039;/gi, "'")
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&amp;/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;
}
/**
* 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,
parseIsoWeek,
timeToMinutes,
formatDuration,
formatDate,
motifInfo,
},
};
})();