// ============================================================================
// viewer.js v4.1 — vue claire du planning techniciens
// ============================================================================
// Différences clés avec v3 :
// 1. Une SEULE requête initiale (calendar_block) pour TOUT récupérer :
// ref, contact, lieu, catégorie, formLink, deadline — tout est déjà dans
// les attributs attr1/attr2/attr3/textContent du XML EasyVista.
// 2. Suppression du fetch xhr2 en masse au chargement (74 requêtes éliminées)
// 3. Suppression du fetch timeline (plus nécessaire)
// 4. Lazy-load du texte d'action détaillé : on fetch xhr2 UNIQUEMENT sur hover,
// et seulement pour l'intervention survolée (pour enrichir le tooltip avec
// Problème/À faire/Matériel/etc.)
// 5. Rendu utilisateur IDENTIQUE à v3 (même UI, mêmes infos au tooltip).
//
// Différences v4 → v4.1 :
// - Fetch des fiches SÉQUENTIEL (1 par 1) au lieu de 5 workers en parallèle.
// Raison : le serveur EasyVista sérialise de toute façon, et le séquentiel
// rend l'abort instantané quand l'user change de date.
// - Cache INCRÉMENTAL : écrit toutes les 5 fiches pendant le fetch, pas juste
// à la fin. Si l'user change de date en cours, les statuts déjà récupérés
// ne sont pas perdus.
// ============================================================================
// ============================================================================
// Configuration
// ============================================================================
// Équipe : ID EasyVista → nom affiché
const TEAM = {
"76272": "Ciuppa, Mathieu",
"83725": "De Almeida Martins, Solange",
"66635": "Makonda, Yannick",
"92235": "Mamouni, Anas",
"90070": "Paisana, David",
"40944": "Pillonel, Olivier",
"72485": "Rosset, Pascal",
"86874": "Rouiller, Quentin"
};
// Absences récurrentes (id tech → [jour JS, 0=dim..6=sam])
const RECURRING_ABSENCES = {
"40944": [5] // Pillonel absent tous les vendredis
};
// Statuts EasyVista qui déclenchent l'affichage "clos"
const CLOSED_STATUS = ["Clôturé", "Cloture", "Clôture"];
const RESOLVED_STATUS = ["Résolu", "Resolu"];
// Statuts qui indiquent qu'une intervention a été supprimée/annulée
// → si présente dans le cache mais disparue du planning : on retire
const CANCELLED_STATUS = ["Annulé", "Annule", "Supprimé", "Supprime"];
// Clés de stockage
const LS_THEME = "planning_theme";
const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD
const CACHE_DAYS = 7;
// v4.1 : plus de constante de concurrence. Les fiches sont fetchées
// séquentiellement (1 à la fois) car le serveur EasyVista est lent de toute
// façon, et ça garantit un abort instantané + pas de race sur le DOM.
// ============================================================================
// Mapping de catégorie → titre court + couleur
// ============================================================================
const CATEGORY_TO_TITLE = [
// Arrivées / nouvelles installations → Installation (bleu)
[/Arriv[ée]e\s+ou\s+mutation/i, "Installation", "installation"],
[/Accessoire\s+pour\s+PC/i, "Installation", "installation"],
[/Nouveau\s+Poste\s+Windows/i, "Installation", "installation"],
[/Nouveau\s+Poste\s+macOS/i, "Installation", "installation"],
// Récupération / départ (vert)
[/D[ée]part\s+d[\u2018\u2019']un\s+utilisateur/i, "Récupération", "recup"],
[/Reprise\s+du\s+mat[ée]riel/i, "Récupération", "recup"],
// Remplacement (orange)
[/Remplacement\s+de\s+mat[ée]riel/i, "Remplacement", "remplacement"],
];
/**
* Détecte si le texte de l'action commence par "Roll Out".
*/
function isRollOut(iv) {
const texts = [
iv.bulleDescription,
iv.infobulle && iv.infobulle.aFaire,
iv.label
];
for (const t of texts) {
if (!t) continue;
if (/^\s*[«"']?\s*roll[\s\-]*out/i.test(String(t))) return true;
if (/(?:^|\bA faire\s*:\s*)roll[\s\-]*out/i.test(String(t))) return true;
}
return false;
}
/**
* Détecte si le texte de l'action mentionne une récupération de matériel.
* Accepté : "RÉCUPÉRATION DE MATÉRIEL" ou "Récupération" au début de l'action,
* ou dans "A faire : Récupération ...".
*/
function isRecupAction(iv) {
const texts = [
iv.bulleDescription,
iv.infobulle && iv.infobulle.aFaire,
iv.label
];
for (const t of texts) {
if (!t) continue;
const s = String(t);
if (/^\s*r[ée]cup[ée]ration/i.test(s)) return true;
if (/\bA\s+faire\s*:\s*r[ée]cup[ée]ration/i.test(s)) return true;
}
return false;
}
/**
* Dérive un titre court et une clé de couleur à partir d'une intervention.
* Priorité :
* 1. Si la ref commence par I260 → "Incident" (violet)
* 2. Si l'action commence par "Roll Out" → "Roll Out" (brun)
* 3. Si l'action mentionne récupération → "Récupération" (vert)
* 4. Sinon, mapping par catégorie (fiche)
* 5. Sinon, "Autres" (gris)
*/
function deriveShortTitle(iv) {
if (iv.type === "AL-Reservation") return "Réservation";
if (iv.ref && /^I\d/.test(iv.ref)) return "Incident";
if (isRollOut(iv)) return "Roll Out";
if (isRecupAction(iv)) return "Récupération";
const cat = iv.categoryLine || "";
if (!cat) return "Autres";
for (const [regex, title] of CATEGORY_TO_TITLE) {
if (regex.test(cat)) return title;
}
return "Autres";
}
function deriveColorKey(iv) {
if (iv.type === "AL-Reservation") return "reservation";
if (iv.ref && /^I\d/.test(iv.ref)) return "incident";
if (isRollOut(iv)) return "rollout";
if (isRecupAction(iv)) return "recup";
const cat = iv.categoryLine || "";
if (!cat) return "autre";
for (const [regex, , colorKey] of CATEGORY_TO_TITLE) {
if (regex.test(cat)) return colorKey;
}
return "autre";
}
// ============================================================================
// État global
// ============================================================================
let state = {
session: null, // { phpsessid, origin, tabId }
currentDate: null, // "YYYY-MM-DD" affiché
currentData: null, // résultat parsé (techs, stats, ...)
loading: false
};
// ─── Annulation coopérative d'un refresh manuel (v3.1) ──────────────────────
// Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent
// isRefreshAborted() avant chaque fetch : si le jeton a changé ou si
// l'utilisateur a cliqué sur "Arrêter", ils s'arrêtent proprement.
//
// v3.2 : on ajoute une "abortPromise" par refresh. loadForDate race cette
// promesse avec son Promise.all, donc dès qu'on clique Arrêter, loadForDate
// sort immédiatement (masque le bouton, fait un toast), même si les fetches
// en cours continuent silencieusement. Le changement de token les rend
// inoffensifs (ils ne peuvent plus écrire le cache ni updater le DOM).
let currentRefreshToken = 0;
let abortedToken = -1;
let abortResolvers = new Map(); // token → resolve fn of the abort promise
function startNewRefresh() {
currentRefreshToken++;
return currentRefreshToken;
}
function makeAbortPromise(myToken) {
return new Promise(resolve => {
abortResolvers.set(myToken, resolve);
});
}
function abortCurrentRefresh() {
abortedToken = currentRefreshToken;
// Réveiller tous les loadForDate en attente (normalement un seul)
for (const [token, resolve] of abortResolvers) {
if (token <= currentRefreshToken) {
resolve("aborted");
abortResolvers.delete(token);
}
}
}
// v4.1.9 : isRefreshAborted(myToken) retourne true si :
// - un nouveau refresh a été lancé (currentRefreshToken > myToken), OU
// - l'utilisateur a explicitement cliqué "Arrêter" (abortedToken).
// Sans myToken fourni (compat), on ne teste que l'abort explicite.
function isRefreshAborted(myToken) {
if (abortedToken === currentRefreshToken) return true;
if (typeof myToken === "number" && myToken < currentRefreshToken) return true;
return false;
}
function cleanupAbortResolver(myToken) {
abortResolvers.delete(myToken);
}
// ============================================================================
// Boot
// ============================================================================
document.addEventListener("DOMContentLoaded", init);
async function init() {
initTheme();
bindTopbar();
bindTooltipInteractions();
// Initialiser la date = aujourd'hui
state.currentDate = todayISO();
document.getElementById("date-picker").value = state.currentDate;
// v4.2 : l'auto-refresh 12h/15h a été supprimé. Les rafraîchissements sont
// désormais soit manuels (boutons Actualiser / Tout recharger), soit au
// premier chargement si aucun cache n'existe pour la date.
// Charger la session puis le planning
await refreshSessionAndLoad();
}
async function refreshSessionAndLoad() {
const resp = await sendMessage({ type: "getSession" });
if (!resp.ok || !resp.session) {
showSessionNeeded();
return;
}
state.session = resp.session;
hideSessionNeeded();
hideEvUnreachable();
hideSessionExpiredBanner();
// v4.2 : en tâche de fond, identifier l'utilisateur EasyVista connecté et
// l'afficher dans la topbar. Ne pas bloquer le chargement du planning
// si ça échoue.
fetchAndShowCurrentUser();
await loadForDate(state.currentDate);
}
// v4.2 : fetche l'utilisateur EasyVista connecté (via background.js) et
// l'affiche dans la topbar. En cas d'échec ou si aucun nom n'est trouvé,
// le badge reste caché.
async function fetchAndShowCurrentUser() {
try {
const resp = await sendMessage({ type: "fetchCurrentUser" });
if (!resp || !resp.ok || !resp.user) return;
const badge = document.getElementById("user-badge");
if (!badge) return;
const fullName = resp.user.name || resp.user.login || null;
if (!fullName) return;
const initials = computeUserInitials(fullName);
badge.textContent = initials;
badge.title = fullName;
// v4.2.3 : couleur unique dérivée du nom, dans la palette neutre du thème
badge.style.setProperty("--user-badge-color", colorFromName(fullName));
badge.classList.remove("hidden");
state.currentUser = resp.user;
} catch (err) {
console.warn("[currentUser] fetch failed:", err);
}
}
// v4.2.3 : calcule les initiales depuis un nom au format "Nom, Prénom" ou
// "Nom Prénom" ou "Prénom Nom". On prend la 1re lettre majuscule de chaque
// mot/segment significatif, limité à 2 caractères.
function computeUserInitials(fullName) {
if (!fullName) return "?";
// Format "Nom, Prénom" → prendre initiale avant virgule et après
let parts;
if (fullName.includes(",")) {
parts = fullName.split(",").map(s => s.trim()).filter(Boolean);
} else {
parts = fullName.split(/\s+/).filter(Boolean);
}
const letters = parts
.map(p => p.charAt(0))
.filter(c => /[A-Za-zÀ-ÿ]/.test(c))
.slice(0, 2)
.join("")
.toUpperCase();
return letters || (fullName.charAt(0).toUpperCase() || "?");
}
// v4.2.3 : couleur déterministe à partir du nom. Palette neutre et sobre
// (tons tamisés), compatible avec les thèmes clair et sombre de l'extension.
function colorFromName(name) {
// Hash simple (djb2) pour dériver un index stable
let h = 5381;
for (let i = 0; i < name.length; i++) {
h = ((h << 5) + h + name.charCodeAt(i)) & 0xffffffff;
}
const palette = [
"#5b6372", // gris bleuté
"#6b7280", // gris neutre
"#4a5568", // ardoise
"#3b5a72", // bleu profond tamisé
"#4f6a5e", // vert sauge sombre
"#6b5a4f", // brun taupe
"#5d4a6b", // prune sombre
"#6a5a3a", // kaki bronze
"#3a5a5e", // sarcelle sombre
"#6c5c67" // mauve grisé
];
return palette[Math.abs(h) % palette.length];
}
// v4.2.3 : affiche/masque la popup nom complet sous la pastille
function toggleUserNamePopup() {
const badge = document.getElementById("user-badge");
const popup = document.getElementById("user-name-popup");
if (!badge || !popup) return;
if (!popup.classList.contains("hidden")) {
hideUserNamePopup();
return;
}
if (!state.currentUser || !state.currentUser.name) return;
popup.textContent = state.currentUser.name;
popup.classList.remove("hidden");
badge.classList.add("open");
// Positionne juste en dessous de la pastille
const r = badge.getBoundingClientRect();
popup.style.left = Math.max(8, r.left) + "px";
popup.style.top = (r.bottom + 6) + "px";
}
function hideUserNamePopup() {
const popup = document.getElementById("user-name-popup");
const badge = document.getElementById("user-badge");
if (popup) popup.classList.add("hidden");
if (badge) badge.classList.remove("open");
}
// ============================================================================
// Thème clair/sombre
// ============================================================================
function initTheme() {
const saved = localStorage.getItem(LS_THEME);
const theme = (saved === "light" || saved === "dark") ? saved : detectDefaultTheme();
applyTheme(theme);
}
function detectDefaultTheme() {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
}
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
const icon = document.getElementById("theme-icon");
if (icon) icon.textContent = theme === "dark" ? "☀️" : "🌙";
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme") || "light";
const next = current === "dark" ? "light" : "dark";
applyTheme(next);
localStorage.setItem(LS_THEME, next);
}
// ============================================================================
// Topbar handlers
// ============================================================================
function bindTopbar() {
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
// v4.1.10 : 2 boutons de rafraîchissement.
// - refresh-btn (Total) : force le re-fetch de toutes les fiches (même celles
// déjà enrichies), utile pour voir les statuts évoluer.
// - refresh-partial-btn (Partiel) : re-fetch juste le XML planning pour
// détecter nouvelles/disparues interventions, mais ne refetch PAS les
// fiches déjà connues → rapide.
document.getElementById("refresh-btn").addEventListener("click", () => {
setActiveRefreshButton("total");
refreshPlanning({ total: true });
});
const partialBtn = document.getElementById("refresh-partial-btn");
if (partialBtn) {
partialBtn.addEventListener("click", () => {
setActiveRefreshButton("partial");
refreshPlanning({ partial: true });
});
}
document.getElementById("abort-btn").addEventListener("click", () => {
// Feedback visuel instantané : masquer le bouton tout de suite, sans
// attendre que loadForDate finisse sa race.
showAbortButton(false);
abortCurrentRefresh();
showAbortToast();
});
document.getElementById("clear-cache-btn").addEventListener("click", onClearCache);
document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1));
document.getElementById("nav-next").addEventListener("click", () => navigateDate(+1));
document.getElementById("nav-today").addEventListener("click", () => loadForDate(todayISO()));
document.getElementById("date-picker").addEventListener("change", (e) => {
if (e.target.value) loadForDate(e.target.value);
});
// v4.2.3 : clic sur la pastille d'initiales → toggle popup nom complet
const userBadge = document.getElementById("user-badge");
if (userBadge) {
userBadge.addEventListener("click", (e) => {
e.stopPropagation();
toggleUserNamePopup();
});
}
// Clic ailleurs ou touche Escape ferme la popup
document.addEventListener("click", (e) => {
const popup = document.getElementById("user-name-popup");
if (!popup || popup.classList.contains("hidden")) return;
// Ne pas fermer si le clic est dans la popup elle-même ou sur le badge
if (e.target.closest("#user-name-popup") || e.target.closest("#user-badge")) return;
hideUserNamePopup();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") hideUserNamePopup();
});
document.getElementById("open-ev-btn").addEventListener("click", openEasyVista);
// v4.2 : écran "EasyVista inaccessible"
const openEvBtn2 = document.getElementById("open-ev-btn-2");
if (openEvBtn2) openEvBtn2.addEventListener("click", openEasyVista);
const retryBtn = document.getElementById("retry-btn");
if (retryBtn) retryBtn.addEventListener("click", async () => {
hideEvUnreachable();
document.getElementById("loading").classList.remove("hidden");
await refreshSessionAndLoad();
});
// v4.1.12 : bindings bannière session expirée
const reconnectBtn = document.getElementById("session-banner-reconnect");
if (reconnectBtn) reconnectBtn.addEventListener("click", openEasyVista);
const closeBtn = document.getElementById("session-banner-close");
if (closeBtn) closeBtn.addEventListener("click", hideSessionExpiredBanner);
}
async function openEasyVista() {
// Ouvrir sur le domaine externe (accessible depuis l'extérieur).
// Le domaine interne (itsma.etat-de-vaud.ch) n'est accessible que depuis le réseau VD.
// Une fois connecté, l'extension détectera automatiquement le PHPSESSID quel que
// soit le domaine où tu es connecté.
await chrome.tabs.create({ url: "https://itsma.vd.ch/" });
}
// Navigation ±1 jour en sautant les week-ends
function navigateDate(direction) {
const d = isoToDate(state.currentDate);
d.setDate(d.getDate() + direction);
// Sauter les week-ends
while (d.getDay() === 0 || d.getDay() === 6) {
d.setDate(d.getDate() + direction);
}
loadForDate(dateToISO(d));
}
async function onClearCache() {
// v4.1.20 : modal central avec 2 choix (jour / tout) + annuler
showClearCacheModal();
}
// v4.1.20 : modal central de confirmation pour vider le cache. L'arrière-plan
// est flouté, l'utilisateur a deux choix explicites + Annuler.
function showClearCacheModal() {
// Ne pas ouvrir 2x si déjà affiché
if (document.getElementById("clear-cache-modal")) return;
const dateTxt = formatDateDM(state.currentDate);
const overlay = document.createElement("div");
overlay.id = "clear-cache-modal";
overlay.className = "modal-overlay";
overlay.innerHTML = `
Vider le cache
Le cache stocke les données des interventions pour éviter de
re-télécharger à chaque ouverture. Que voulez-vous supprimer ?
`;
document.body.appendChild(overlay);
const close = () => {
overlay.remove();
};
overlay.addEventListener("click", async (e) => {
const action = e.target.closest("[data-action]")?.dataset.action;
if (!action) {
// Clic sur le fond (pas sur la carte) → fermer
if (e.target === overlay) close();
return;
}
if (action === "cancel") {
close();
return;
}
if (action === "clear-day") {
close();
await chrome.storage.local.remove(CACHE_PREFIX + state.currentDate);
await loadForDate(state.currentDate, { forceRefetch: true });
return;
}
if (action === "clear-all") {
close();
// Supprimer toutes les clés CACHE_PREFIX*
const all = await chrome.storage.local.get(null);
const toRemove = Object.keys(all).filter(k => k.startsWith(CACHE_PREFIX));
if (toRemove.length) {
await chrome.storage.local.remove(toRemove);
}
await loadForDate(state.currentDate, { forceRefetch: true });
return;
}
});
// Échap ferme la modale
const escHandler = (e) => {
if (e.key === "Escape") {
close();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
}
// ============================================================================
// Date helpers
// ============================================================================
function todayISO() {
const d = new Date();
return dateToISO(d);
}
function dateToISO(d) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
function isoToDate(iso) {
const [y, m, d] = iso.split("-").map(n => parseInt(n, 10));
return new Date(y, m - 1, d);
}
function isoToDDMMYYYY(iso) {
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
}
function formatDateDM(iso) {
const [, m, d] = iso.split("-");
return `${d}/${m}`;
}
function isoToUnixDate(iso) {
// Renvoie le timestamp Unix à midi local du jour (pour que le serveur comprenne bien le jour demandé)
const d = isoToDate(iso);
d.setHours(12, 0, 0, 0);
return Math.floor(d.getTime() / 1000);
}
// ============================================================================
// Messages → background
// ============================================================================
function sendMessage(msg) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(msg, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve(response || {});
});
});
}
// ============================================================================
// Cache (chrome.storage.local)
// ============================================================================
async function readCache(isoDate) {
const key = CACHE_PREFIX + isoDate;
const obj = await chrome.storage.local.get(key);
return obj[key] || null;
}
async function writeCache(isoDate, data) {
const key = CACHE_PREFIX + isoDate;
await chrome.storage.local.set({ [key]: { ...data, savedAt: Date.now() } });
}
// ============================================================================
// Flux principal : charger une date
// ============================================================================
async function loadForDate(isoDate, opts = {}) {
state.currentDate = isoDate;
document.getElementById("date-picker").value = isoDate;
if (!state.session) {
showSessionNeeded();
return;
}
// (v3.1.1) Tout chargement = un nouveau jeton d'annulation. Le bouton
// "Arrêter" apparaît pour TOUT refresh (clic manuel, navigation date,
// ouverture vue claire), pas juste refreshPlanning(). Le bouton disparaît
// quand le chargement est vraiment fini (finally).
const myToken = startNewRefresh();
showAbortButton(true);
const t0 = performance.now();
console.log(`[load] début pour ${isoDate} (token=${myToken})`);
// v4.1.14 : choix du bouton qui tourne
// - Clic explicite "Actualiser" → _fromPartialBtn → "partial"
// - Clic explicite "Tout recharger" → doStatusRefresh → "total"
// - Sinon (nav date / chargement auto) :
// - cache présent → "partial" (c'est juste un diff XML)
// - cache absent → "total" (on charge tout pour la 1re fois)
// La détermination se fait APRÈS readCache.
try {
// 1. Afficher immédiatement depuis le cache si disponible
const cached = await readCache(isoDate);
if (!opts._fromPartialBtn) {
if (opts.doStatusRefresh) {
setActiveRefreshButton("total");
} else {
setActiveRefreshButton(cached ? "partial" : "total");
}
}
if (cached && !opts.forceRefetch) {
renderFromData({
techs: cached.techs,
targetDate: isoDate,
captureTime: cached.savedAt || null,
source: "cache"
});
// v4.1.9 : on NE retourne PAS ici. On continue pour refetch le XML
// du planning afin de détecter les nouvelles iv et celles disparues
// (diff avec le cache). Les iv déjà présentes dans le cache gardent
// leur enrichissement (ficheActionText, statut) → pas de re-fetch
// inutile, seules les nouvelles passent par refreshStatuses.
} else {
showLoading();
}
if (isRefreshAborted(myToken)) return;
// 2. Fetch frais du planning (XML calendar_block — rapide, ~40 ko)
const tXml = performance.now();
const fresh = await fetchPlanningForDate(isoDate);
console.log(`[load] XML planning récupéré en ${Math.round(performance.now() - tXml)} ms`);
if (!fresh) return;
if (isRefreshAborted(myToken)) return;
// 3. Fusionner cache + frais
const merged = mergeCacheAndFresh(cached, fresh);
// v4.1.9 : retirer immédiatement les iv du cache qui ne sont plus dans
// le fresh (elles ont été supprimées / déplacées / annulées dans
// EasyVista). Le user veut qu'elles disparaissent visuellement tout de
// suite, pas qu'elles restent en "ghost".
for (const tech of merged.techs) {
tech.interventions = tech.interventions.filter(iv => !iv.ghost);
}
// 4. Afficher immédiatement (v4 : tout est déjà rempli depuis le XML !)
// Le calendar_block contient attr1/attr2/attr3 = contact/lieu/catégorie,
// et textContent = ref. Donc ce 1er rendu est DÉJÀ complet visuellement
// (manquent juste : statut clos/résolu, et détails dans le tooltip au
// survol). Plus d'étapes 5a et 5b successives comme en v3.
renderFromData({
techs: merged.techs,
targetDate: isoDate,
captureTime: Date.now(),
source: "fresh",
lastRefreshKind: activeRefreshButton // v4.1.20
});
console.log(`[load] 1er rendu complet à ${Math.round(performance.now() - t0)} ms`);
// 5. Fetch des fiches en arrière-plan UNIQUEMENT pour obtenir :
// - le statut Clôturé/Résolu (pour le ✓ vert et le fond vert)
// - le commentaire technicien (affiché dans le tooltip)
// - le checksum pour ouvrir la fiche (en vrai déjà dans formLink, mais
// on garde la fiche comme source de vérité pour le statut)
//
// v4.1 : fetch séquentiel (1 à la fois) avec cache écrit tous les 5 fiches.
// Voir refreshStatuses() pour les détails.
const needFetch = merged.techs.some(tech =>
tech.interventions.some(iv =>
iv.type === "AL-Intervention" && !iv.ficheFetched
)
);
if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted(myToken)) {
const tFiches = performance.now();
const nFiches = merged.techs.flatMap(t=>t.interventions).filter(i=>i.type==="AL-Intervention").length;
console.log(`[load] début fetch des ${nFiches} fiches (statuts)…`);
// forceAll : uniquement si refresh manuel (bouton "Rafraîchir").
// À la navigation normale entre dates, on ne refetch que les iv non
// encore enrichies (ficheFetched=false) — ça reprend là où on s'était
// arrêté si un refresh précédent a été interrompu par un changement de
// date.
await refreshStatuses(merged.techs, isoDate, { forceAll: !!opts.doStatusRefresh, myToken });
console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`);
}
// 6. Sauvegarder dans le cache (une seule fois, après que tout soit enrichi)
if (!isRefreshAborted(myToken)) {
await writeCache(isoDate, { techs: merged.techs });
}
if (!isRefreshAborted(myToken)) {
showRefreshDone();
console.log(`[load] TOTAL: ${Math.round(performance.now() - t0)} ms`);
} else {
// v4.1.9 : toast "annulé" uniquement si c'était un vrai clic "Arrêter",
// pas un simple changement de date (qui abort l'ancien silencieusement).
const wasExplicitAbort = (abortedToken === myToken);
console.log(`[load] annulé à ${Math.round(performance.now() - t0)} ms (explicite=${wasExplicitAbort})`);
if (wasExplicitAbort) showAbortToast();
}
} finally {
// Masquer le bouton "Arrêter" uniquement si c'est NOTRE chargement qui
// se termine (pas un chargement postérieur que l'utilisateur aurait lancé
// entre-temps en naviguant ailleurs).
if (currentRefreshToken === myToken) {
showAbortButton(false);
}
cleanupAbortResolver(myToken);
}
}
async function refreshPlanning(opts = {}) {
if (!state.session) {
await refreshSessionAndLoad();
return;
}
if (opts.partial) {
// v4.1.13 : _fromPartialBtn empêche loadForDate de reset activeRefreshButton à "total"
await loadForDate(state.currentDate, { doStatusRefresh: false, _fromPartialBtn: true });
} else {
await loadForDate(state.currentDate, { doStatusRefresh: true });
}
}
// ============================================================================
// Fetch du planning (via background)
// ============================================================================
async function fetchPlanningForDate(isoDate) {
setRefreshing(true);
try {
const unixDate = isoToUnixDate(isoDate);
const resp = await sendMessage({
type: "fetchPlanning",
session: state.session,
unixDate: unixDate
});
if (!resp.ok) {
if (resp.error === "no_session" || resp.error === "session_expired") {
state.session = null;
showSessionNeeded();
} else if (resp.error === "ev_unreachable") {
// v4.2 : EasyVista inaccessible (500/503/réseau/etc.)
showEvUnreachable();
} else {
showError("Erreur de fetch : " + (resp.error || "inconnue"));
}
return null;
}
// Safeguard (v3.1) : le serveur EasyVista répond parfois 200 avec un
// corps vide — typiquement quand la session vient d'être invalidée, ou
// quand il soupçonne du scraping (trop de requêtes parallèles). Dans
// les deux cas, on traite ça comme une session expirée : inutile de
// parser (ça ferait "Document is empty") ni de retry en boucle.
if (!resp.xml || resp.xml.length < 20) {
console.warn("[viewer] XML planning vide — session probablement invalide");
state.session = null;
showSessionNeeded();
return null;
}
// Parser le HTML complet du planning (contient TOUT : ref, catégorie,
// contact, lieu, description, formLinks, request_id + checksum)
const techs = parsePlanningXml(resp.xml, isoDate);
return { techs };
} catch (err) {
showError("Erreur inattendue : " + (err.message || err));
return null;
} finally {
setRefreshing(false);
}
}
// ============================================================================
// Parsing du XML du planning
// ============================================================================
/**
* Parse le XML retourné par planning_xhr.php?div=calendar_block.
* Contient les interventions (actions) par technicien, avec :
* - action_id, done_by_id, action_label (parfois juste "AL-Intervention"),
* - start_time / end_time, start_date / end_date,
* - formLink (eventName=formEvent&target=ACTIONID&checksum=...) pour ouvrir l'action,
* - request_id (ID de la fiche SD_REQUEST, utilisé pour ouvrir la fiche).
*/
function parsePlanningXml(xml, isoDate) {
const doc = new DOMParser().parseFromString(xml, "text/xml");
const parserError = doc.querySelector("parsererror");
if (parserError) {
console.warn("Parser error:", parserError.textContent);
}
const actionNodes = doc.querySelectorAll("action");
const byTechId = new Map();
for (const id of Object.keys(TEAM)) {
byTechId.set(id, { id, name: TEAM[id], interventions: [] });
}
for (const node of actionNodes) {
const iv = actionNodeToIntervention(node);
if (!iv) continue;
if (!byTechId.has(iv.techId)) continue;
if (!actionCoversDate(iv, isoDate)) continue;
byTechId.get(iv.techId).interventions.push(iv);
}
for (const tech of byTechId.values()) {
tech.interventions.sort((a, b) =>
(a.startTime || "").localeCompare(b.startTime || "")
);
}
return [...byTechId.values()];
}
function actionNodeToIntervention(node) {
const get = name => node.getAttribute(name) || "";
const actionId = get("action_id");
if (!actionId) return null;
const actionType = get("action_type");
const techId = get("done_by_id");
const label = get("action_label");
const cssClass = get("Css_Class");
const startDate = get("start_date");
const endDate = get("end_date");
const startTime = get("start_time");
const endTime = get("end_time");
const currentDate = get("current_date");
const formLink = get("formLink");
const deadline = get("max_resolution_date") || get("max_intervention_date");
const requestId = get("request_id");
// ─── v4 : infos enrichies disponibles directement dans le XML ──────────────
// EasyVista envoie déjà contact/lieu/catégorie dans attr1/attr2/attr3.
// La ref est dans le textContent du nœud (format "SYYMMDD_NNNNN (CM)" ou
// "IYYMMDD_NNNNN (SD)"). Plus besoin de fetcher xhr2 ni la fiche pour ça.
const attr1 = get("attr1"); // contact
const attr2 = get("attr2"); // lieu
const attr3 = get("attr3"); // catégorie complète
const nodeText = (node.textContent || "").trim();
// Extraire la ref en priorité du textContent (où elle est complète), sinon
// fallback sur le label. v4.1.9 : pattern générique [SI]\d+_\d+ (plus
// hardcodé sur "2..." qui était pour 2020-2029).
let ref = null;
const refFromText = nodeText.match(/\b([SI]\d{5,8}_\d{4,6})\b/);
if (refFromText) {
ref = refFromText[1];
} else {
const refFromLabel = label.match(/\b([SI]\d{5,8}_\d{4,6})\b/);
if (refFromLabel) ref = refFromLabel[1];
}
// Détection du type "Réservation" : un coordinateur a bloqué un créneau.
// Dans le XML, action_type = "AL-Absence" pour ce genre de créneau, mais
// action_label contient le vrai pattern :
// action_label = "Xxxxx / Créé par : Nom, Prénom"
// Ex: "Ecrans / Créé par : Nom20, Prénom20"
// "Rollout / Créé par : Nom24, Prénom24"
// "Congés / Créé par : ..." → pas une réservation, c'est une absence
// "Maladie / Créé par : ..." → idem
// "Pompier / Créé par : ..." → idem
let effectiveType = actionType;
let reservationLabel = null;
let reservationCreator = null;
const reservationMatch = label.match(/^([^/]+?)\s*\/\s*Créé par\s*:\s*(.+)$/i);
if (reservationMatch) {
const label1 = reservationMatch[1].trim();
const creator = reservationMatch[2].trim();
// Les "absences" connues (Congés/Maladie/Pompier) restent des absences
if (/^(cong[ée]s|maladie|pompier)$/i.test(label1)) {
effectiveType = "AL-Absence";
} else {
// Tout autre label (Ecrans, Rollout, ...) → Réservation
effectiveType = "AL-Reservation";
reservationLabel = label1;
reservationCreator = creator;
}
}
// ─── v4 : pré-remplissage immédiat depuis les attributs XML ─────────────────
// On renseigne bulleContact/bulleLieu/categoryLine DÈS la création de l'objet.
// Plus besoin d'attendre xhr2 ou la fiche pour avoir l'affichage de base.
// Seuls restent à fetcher (en arrière-plan, sur fiche) : status + commentaireTech.
// Et sur hover (lazy, seulement si l'user survole) : bulleDescription complet.
const isIntervention = effectiveType === "AL-Intervention";
const bulleContact = isIntervention && attr1 ? attr1 : null;
const bulleLieu = isIntervention && attr2 ? attr2 : null;
const categoryLine = isIntervention && attr3 ? attr3 : null;
return {
actionId: actionId,
requestId: requestId,
techId: techId,
label: label,
type: effectiveType, // "AL-Intervention" | "AL-Absence" | "AL-Reservation"
originalType: actionType, // type brut (pour debug)
reservationLabel: reservationLabel, // "Ecrans", "Rollout", etc.
reservationCreator: reservationCreator, // "Nom, Prénom" du coordinateur
cssClass: cssClass,
isPompier: /pompier/i.test(label) || /pompier/i.test(actionType),
ref: ref,
startDate: startDate,
endDate: endDate,
startTime: startTime,
endTime: endTime,
currentDate: currentDate,
formLink: formLink,
deadline: deadline,
// v4 : renseignés directement depuis le XML (plus d'attente de xhr2)
bulleContact: bulleContact,
bulleLieu: bulleLieu,
categoryLine: categoryLine,
bulleDescription: null, // reste null, rempli lazy au premier hover (xhr2)
infobulle: null, // reste null, rempli lazy aussi
status: null, // toujours rempli par fetch fiche (en arrière-plan)
commentaireTech: null, // toujours rempli par fetch fiche (en arrière-plan)
// v4 : ficheTarget/Checksum déjà présents dans formLink (extraits à la demande)
ficheTarget: null,
ficheChecksum: null,
ficheFetched: false,
ficheFetchError: null,
xhr2Fetched: false, // lazy : passe à true après le 1er hover
xhr2Fetching: false, // évite les doubles fetchs simultanés
ghost: false
};
}
/**
* Parse le body de planning_xhr_2.php?id=ACTIONID (ou similaire).
* Format observé :
* @@DESCRIPTION_S@@...texte complet de l'action...@@DESCRIPTION_E@@
* @@LABEL_S@@AL-Intervention@@LABEL_E@@
* @@LAST_S@@Nom, Prénom@@LAST_E@@
* @@PLANNED_TIME_S@@@@PLANNED_TIME_E@@
* @@PLANNED_CHANGE_S@@@@PLANNED_CHANGE_E@@
*/
function parseXhr2Body(body) {
if (!body || typeof body !== "string") return null;
const out = { description: null, label: null, last: null };
const rxD = /@@DESCRIPTION_S@@([\s\S]*?)@@DESCRIPTION_E@@/;
const rxL = /@@LABEL_S@@([\s\S]*?)@@LABEL_E@@/;
const rxLa = /@@LAST_S@@([\s\S]*?)@@LAST_E@@/;
const md = body.match(rxD);
const ml = body.match(rxL);
const mla = body.match(rxLa);
if (md) out.description = md[1].trim();
if (ml) out.label = ml[1].trim();
if (mla) out.last = mla[1].trim();
return out;
}
// v4 : fetchBullesForInterventions (fetch xhr2 en masse au chargement) a été
// supprimée. Le contact/lieu/catégorie viennent maintenant directement des
// attributs attr1/attr2/attr3 du calendar_block. Pour le TEXTE complet de
// l'action (Problème/À faire/Matériel/TFS/...), voir ensureBulleDescription()
// qui lazy-load UNIQUEMENT au premier hover de l'intervention.
function actionCoversDate(iv, isoDate) {
if (!iv.startDate || !iv.endDate) return true; // manque info → on garde
const target = isoToDDMMYYYY(isoDate);
return ddmmyyyyLE(iv.startDate, target) && ddmmyyyyLE(target, iv.endDate);
}
function ddmmyyyyLE(a, b) {
// Compare deux dates JJ/MM/AAAA
const toNum = s => {
const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
return m ? parseInt(m[3] + m[2] + m[1], 10) : 0;
};
return toNum(a) <= toNum(b);
}
// ============================================================================
// Fusion cache ↔ fresh
// ============================================================================
function mergeCacheAndFresh(cached, fresh) {
// fresh.techs : liste des techs avec interventions d'aujourd'hui (depuis EasyVista)
// cached.techs : dernière liste sauvegardée pour ce jour (avec statuts)
//
// Règles v4 :
// - Le fresh APPORTE (depuis le XML calendar_block) : actionId, type,
// startTime/endTime, formLink, ref (textContent), bulleContact (attr1),
// bulleLieu (attr2), categoryLine (attr3), deadline.
// - Le cache APPORTE : status (clôturé/résolu), commentaireTech,
// bulleDescription (lazy-load xhr2 au hover) + infobulle, ficheFetched,
// xhr2Fetched.
// - Règle générale : fresh wins sur les champs live, cache wins sur les
// champs enrichis qui ne sont pas dans le fresh.
// - Une intervention en cache mais plus en fresh → marquée "ghost"
if (!cached || !cached.techs) {
return { techs: fresh.techs };
}
// Indexer le cache par actionId
const cachedByAction = new Map();
for (const tech of cached.techs) {
for (const iv of tech.interventions || []) {
cachedByAction.set(iv.actionId, iv);
}
}
const resultTechs = fresh.techs.map(t => ({ ...t, interventions: [] }));
const freshActionIds = new Set();
for (const tech of fresh.techs) {
const outTech = resultTechs.find(t => t.id === tech.id);
for (const iv of tech.interventions) {
freshActionIds.add(iv.actionId);
const cachedIv = cachedByAction.get(iv.actionId);
if (cachedIv) {
// On part du cache (qui a les champs enrichis), puis on remplace
// les champs "live" depuis le fresh (horaires, type, formLink).
const merged = {
...cachedIv,
// Champs live venant du fresh (le planning peut avoir bougé)
techId: iv.techId || cachedIv.techId,
type: iv.type || cachedIv.type,
label: iv.label || cachedIv.label,
cssClass: iv.cssClass || cachedIv.cssClass,
isPompier: iv.isPompier,
startDate: iv.startDate || cachedIv.startDate,
endDate: iv.endDate || cachedIv.endDate,
startTime: iv.startTime || cachedIv.startTime,
endTime: iv.endTime || cachedIv.endTime,
currentDate: iv.currentDate || cachedIv.currentDate,
formLink: iv.formLink || cachedIv.formLink,
deadline: iv.deadline || cachedIv.deadline,
requestId: iv.requestId || cachedIv.requestId,
// v4 : la ref du fresh est maintenant FIABLE (textContent XML),
// on la privilégie sur le cache (inversé vs v3).
ref: iv.ref || cachedIv.ref,
// v4 : categoryLine vient désormais du XML (attr3), on la privilégie.
categoryLine: iv.categoryLine || cachedIv.categoryLine,
// Contact/lieu : fresh est plus à jour (attr1/attr2 du XML)
bulleContact: iv.bulleContact || cachedIv.bulleContact,
bulleLieu: iv.bulleLieu || cachedIv.bulleLieu,
// bulleDescription : on privilégie le cache, qui contient le texte
// lazy-load au hover. Le fresh n'a pas ce texte (null au chargement).
bulleDescription: cachedIv.bulleDescription || iv.bulleDescription,
infobulle: cachedIv.infobulle || iv.infobulle,
xhr2Fetched: cachedIv.xhr2Fetched || iv.xhr2Fetched,
// ghost : on retire (cette intervention est bien là dans le fresh)
ghost: false
};
outTech.interventions.push(merged);
} else {
outTech.interventions.push(iv);
}
}
}
// Ajouter les interventions qui sont en cache mais plus en fresh
for (const tech of cached.techs) {
const outTech = resultTechs.find(t => t.id === tech.id);
if (!outTech) continue;
for (const iv of tech.interventions || []) {
if (!freshActionIds.has(iv.actionId)) {
const ghost = { ...iv, ghost: true };
outTech.interventions.push(ghost);
}
}
// Retrier
outTech.interventions.sort((a, b) =>
(a.startTime || "").localeCompare(b.startTime || "")
);
}
return { techs: resultTechs };
}
// ============================================================================
// Fetch des fiches individuelles (pour obtenir le statut et les détails)
// ============================================================================
async function refreshStatuses(techs, isoDate, opts = {}) {
const forceAll = !!opts.forceAll;
const myToken = opts.myToken;
// Construire la liste des interventions à fetcher, dans l'ordre de priorité :
// 1. Interventions du (des) pompier(s) en premier
// 2. Puis les autres techs par ordre alphabétique du nom de famille
// 3. (Les absents n'ont pas d'interventions à fetcher)
const sortedTechs = [...techs].sort((a, b) => compareTechs(a, b, isoDate));
const toFetch = [];
for (const tech of sortedTechs) {
for (const iv of tech.interventions) {
if (iv.type !== "AL-Intervention") continue;
if (!iv.formLink) continue;
// v4 : on skip les interventions déjà closes/résolues dont la fiche a
// déjà été fetchée une fois (statut + commentaire tech déjà récupérés).
// Le statut "Clôturé" ne change plus une fois atteint, pas la peine de
// refetcher à chaque refresh.
const statusClosed = isClosedStatus(iv.status) || isResolvedStatus(iv.status);
if (statusClosed && iv.ficheFetched) continue;
// v4.1.7 : pause/reprise par date. Sans forceAll (= chargement normal
// au retour sur une date), on skip les iv déjà enrichies (ficheFetched)
// pour ne pas refetcher inutilement. Un clic sur "Rafraîchir" active
// forceAll, ce qui refetche les non-closes même si déjà enrichies (pour
// voir passer les statuts "En cours" → "Exécution" → "Clôturé").
if (!forceAll && iv.ficheFetched) continue;
toFetch.push(iv);
}
}
if (toFetch.length === 0) return;
setRefreshing(true);
// v4.1.7 : barre de progression visible uniquement si on est en train de
// rafraîchir la date actuellement affichée. Si l'user change de date
// pendant le refresh, isRefreshAborted() deviendra true et on sortira.
const showBar = (state.currentDate === isoDate);
if (showBar) {
updateProgressBar(0, toFetch.length);
showProgressBar();
}
try {
// v4.1 : SÉQUENTIEL (1 fiche à la fois) au lieu de 5 workers en parallèle.
// Raisons :
// - Le serveur EasyVista est lent et sérialise les requêtes de toute façon
// - L'abort devient instantané : un seul fetch en vol, si l'user change
// de date, le prochain await sendMessage() n'est même pas lancé
// - Plus de races de DOM (5 workers qui écrivaient la même carte en
// concurrence, ça générait des artefacts visuels)
//
// Cache incrémental : on sauve le cache toutes les CACHE_WRITE_EVERY fiches
// ET à la fin. Comme ça si l'user change de date en cours, on ne perd pas
// les statuts déjà récupérés.
const CACHE_WRITE_EVERY = 5;
let sinceLastCacheWrite = 0;
for (let i = 0; i < toFetch.length; i++) {
if (isRefreshAborted(myToken)) break;
await fetchAndUpdateIntervention(toFetch[i], myToken);
sinceLastCacheWrite++;
// Progression — uniquement si la barre concerne la date visible
if (showBar && state.currentDate === isoDate) {
updateProgressBar(i + 1, toFetch.length);
}
// Sauvegarde périodique du cache pendant le fetch
if (sinceLastCacheWrite >= CACHE_WRITE_EVERY) {
try {
await writeCache(isoDate, { techs });
sinceLastCacheWrite = 0;
} catch (err) {
console.warn("[cache] écriture intermédiaire échouée:", err);
}
}
}
// Si annulé : on laisse les résultats partiels dans le DOM et on sauve
// quand même ce qu'on a déjà récupéré (cache incrémental).
if (isRefreshAborted(myToken)) {
try { await writeCache(isoDate, { techs }); } catch {}
return;
}
// Résoudre le sort des ghosts
for (const tech of techs) {
tech.interventions = tech.interventions.filter(iv => {
if (!iv.ghost) return true;
if (CANCELLED_STATUS.includes(iv.status)) return false;
return true;
});
}
// Sauvegarde finale du cache
await writeCache(isoDate, { techs });
// Re-rendre pour afficher les mises à jour finales (ghosts filtrés,
// tri à jour, etc.). updateInterventionRow a déjà patché chaque ligne,
// mais ce re-render final garantit la cohérence globale.
renderFromData({
techs,
targetDate: isoDate,
captureTime: Date.now(),
source: "fresh+statuses",
lastRefreshKind: activeRefreshButton // v4.1.20
});
} finally {
setRefreshing(false);
if (showBar) hideProgressBar();
}
}
async function fetchAndUpdateIntervention(iv, myToken) {
try {
// Bail-out coopératif : si l'utilisateur a cliqué sur "Arrêter" ou a
// changé de date, on ne fetch pas cette intervention.
if (isRefreshAborted(myToken)) {
iv.ficheFetched = true;
iv.ficheFetchError = "aborted";
return;
}
// v4.1.2 : pour chaque intervention on fait xhr2 PUIS fiche.
// - xhr2 : récupère les VRAIES infos contact/lieu (attr1/attr2 du XML
// sont parfois erronées si le tech a corrigé après planif).
// On met à jour la carte tout de suite avec les vraies infos.
// - fiche : récupère statut Clôturé/Résolu + commentaire tech + checksum
// valide pour l'ouverture au clic.
// ─── Étape 1 : xhr2 (rapide, ~400 o) ────────────────────────────────
if (!iv.xhr2Fetched && !isRefreshAborted(myToken)) {
try {
const xhr2Resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId });
// v4.1.9 : si on a été aborté pendant l'attente, ne PAS appliquer
// le résultat au DOM (on ne doit plus toucher à une ligne qui
// appartient à la date précédente).
if (isRefreshAborted(myToken)) return;
if (xhr2Resp && xhr2Resp.ok) {
const parsed = parseXhr2Body(xhr2Resp.body);
if (parsed) {
if (parsed.description) {
iv.bulleDescription = parsed.description;
const infob = parseActionText(parsed.description);
if (infob) iv.infobulle = infob;
}
if (parsed.label) iv.label = parsed.label;
iv.xhr2Fetched = true;
// Met à jour la carte avec les vraies infos xhr2
updateInterventionRow(iv);
}
}
} catch (err) {
console.warn("[xhr2] erreur iv", iv.actionId, err);
}
}
if (isRefreshAborted(myToken)) return;
// ─── Étape 2 : fetch fiche (statut + commentaire + checksum) ──────────
// Retry léger : une erreur réseau ponctuelle (timeout, 5xx) ne doit pas
// perdre la ligne. 1 seul retry après 400ms. Session expirée n'est PAS
// retryée (ça ne passera pas mieux la 2e fois).
let ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
if (isRefreshAborted(myToken)) return;
if (!ficheResp.ok && ficheResp.error !== "session_expired" && !isRefreshAborted(myToken)) {
await new Promise(r => setTimeout(r, 400));
if (!isRefreshAborted(myToken)) {
ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
}
}
if (isRefreshAborted(myToken)) return;
if (!ficheResp.ok) {
iv.ficheFetched = true;
iv.ficheFetchError = ficheResp.error || "fetch_failed";
if (ficheResp.error === "session_expired") {
state.session = null;
// v4.1.12 : afficher immédiatement la bannière de session expirée
// pour que l'utilisateur voie pourquoi le fetch s'arrête.
showSessionExpiredBanner();
}
return;
}
const fiche = parseFicheHtml(ficheResp.html);
iv.status = fiche.status;
// Rétrocompat : champ plus utilisé, on le laisse à null pour ne pas casser
// d'anciens caches avec un champ undefined.
iv.commentaireTech = null;
// Fallback : si l'XML n'avait pas la ref (rare, mais possible pour des
// actions hors-standard), on prend celle de la fiche.
if (fiche.rfc && !iv.ref) {
iv.ref = fiche.rfc;
}
// v4.1.18 : persister le formSenderGuid sur l'iv pour qu'il soit
// disponible au clic pour ouvrir la fiche avec le bon sender (S vs I).
if (fiche.formSenderGuid) {
iv.formSenderGuid = fiche.formSenderGuid;
}
// ─── Étape 3 : API timeline → texte complet de l'action ─────────────
// Le HTML brut de la fiche ne contient PAS les valeurs d'action (elles
// sont injectées côté client par Angular via un appel REST). On appelle
// donc le même endpoint REST qu'Angular pour récupérer la description
// complète, match par ACTION_ID === iv.actionId (fiable, numérique).
//
// Ce texte REMPLACE le texte xhr2 tronqué dans le tooltip.
// Si l'appel échoue ou ne trouve rien, on garde le fallback xhr2 dans
// iv.bulleDescription (déjà stocké à l'étape 1).
if (fiche.formId && fiche.formChecksum && fiche.formSenderGuid &&
iv.actionId && !isRefreshAborted(myToken)) {
try {
const tlResp = await sendMessage({
type: "fetchTimelineApi",
guid: fiche.formSenderGuid,
formId: fiche.formId,
formChecksum: fiche.formChecksum
});
if (isRefreshAborted(myToken)) return;
if (tlResp && tlResp.ok) {
const fullText = parseTimelineJsonForAction(tlResp.body, iv.actionId);
if (fullText) {
iv.ficheActionText = fullText;
}
} else if (tlResp && tlResp.error === "session_expired") {
state.session = null;
showSessionExpiredBanner();
}
} catch (err) {
console.warn("[timeline] erreur iv", iv.actionId, err);
}
}
// ─── Extraire le checksum pour ouvrir la fiche ─────────────────────
// STRICTEMENT IDENTIQUE à v4 originale (qui fonctionne pour l'ouverture) :
// - On n'extrait QUE si ficheChecksum n'est pas déjà là (une fois trouvé
// c'est bon, pas la peine de ré-extraire à chaque refresh et risquer
// de l'écraser avec une mauvaise valeur).
// - Pas de "Tentative 3" ultime : elle peut matcher le checksum du form
// principal qui n'est PAS le bon pour l'action → casse l'ouverture.
if (iv.requestId && !iv.ficheChecksum) {
// Tentative 1 : target=ID&checksum=... (pattern le plus courant)
const rx1 = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`);
const m1 = ficheResp.html.match(rx1);
if (m1) {
iv.ficheTarget = iv.requestId;
iv.ficheChecksum = m1[1];
} else {
// Tentative 2 : JSON formData
const rx2a = new RegExp(`"id"\\s*:\\s*"${iv.requestId}"[\\s\\S]{0,200}?"checksum"\\s*:\\s*"([a-f0-9]{40})"`);
const m2a = ficheResp.html.match(rx2a);
if (m2a) {
iv.ficheTarget = iv.requestId;
iv.ficheChecksum = m2a[1];
} else {
const rx2b = new RegExp(`"checksum"\\s*:\\s*"([a-f0-9]{40})"[\\s\\S]{0,200}?"id"\\s*:\\s*"${iv.requestId}"`);
const m2b = ficheResp.html.match(rx2b);
if (m2b) {
iv.ficheTarget = iv.requestId;
iv.ficheChecksum = m2b[1];
}
}
}
}
iv.ficheFetched = true;
// Rendu incrémental : mettre à jour la ligne dans le DOM immédiatement
// (statut clos → fond vert + ✓, commentaire tech dans le tooltip).
// v4.1.9 : ne touche au DOM que si on est toujours sur la même date
// qui a été demandée initialement (sinon on corromprait la nouvelle vue).
if (!isRefreshAborted(myToken)) {
updateInterventionRow(iv);
}
} catch (err) {
iv.ficheFetched = true;
iv.ficheFetchError = String(err);
console.warn("fetchAndUpdate error:", err);
}
}
/**
* v4 : Lazy-load du texte d'action détaillé au premier survol d'une intervention.
*
* Le calendar_block nous donne déjà contact/lieu/catégorie via attr1/attr2/attr3
* (planification initiale), mais pas le TEXTE COMPLET de l'action (Problème/
* À faire/Matériel/TFS/...) et surtout pas les VRAIES infos à jour : un tech
* peut avoir mis à jour le contact ou le lieu après la planification initiale,
* et ces vraies infos ne sont PAS dans attr1/attr2.
*
* Ce texte vient de planning_xhr_2.php. On le fetch à la demande (premier hover)
* pour ne pas surcharger le serveur au chargement initial.
*
* v4.1.2 : quand les infos arrivent, on MET À JOUR la carte car ces infos
* (venant du texte d'action validé par le tech) sont plus fiables que
* attr1/attr2 (planification initiale parfois erronée).
*/
async function ensureBulleDescription(iv) {
// Déjà chargé : rien à faire
if (iv.xhr2Fetched) return true;
// Fetch déjà en cours (évite les races si l'utilisateur survole plusieurs fois)
if (iv.xhr2Fetching) return false;
// Pas applicable (réservation, absence, ghost, ou pas d'actionId)
if (iv.type !== "AL-Intervention") return false;
if (!iv.actionId || iv.ghost) return false;
iv.xhr2Fetching = true;
try {
const resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId });
if (!resp || !resp.ok) return false;
const parsed = parseXhr2Body(resp.body);
if (!parsed) return false;
if (parsed.description) {
iv.bulleDescription = parsed.description;
const infob = parseActionText(parsed.description);
if (infob) {
iv.infobulle = infob;
}
}
if (parsed.label) iv.label = parsed.label;
iv.xhr2Fetched = true;
// Mettre à jour la carte : lieu/contact du xhr2 sont les VRAIES infos à
// jour (le tech les a peut-être corrigées après la planification initiale).
updateInterventionRow(iv);
return true;
} catch (err) {
console.warn("[xhr2 lazy] erreur iv", iv.actionId, err);
return false;
} finally {
iv.xhr2Fetching = false;
}
}
function isClosedStatus(s) {
return !!s && CLOSED_STATUS.some(x => s.includes(x));
}
function isResolvedStatus(s) {
return !!s && RESOLVED_STATUS.some(x => s.includes(x));
}
function isCancelledStatus(s) {
return !!s && CANCELLED_STATUS.some(x => s.includes(x));
}
// ============================================================================
// Parsing d'une fiche individuelle (HTML)
// ============================================================================
// v4 : simplifié. On ne cherche plus dans la fiche que :
// - le statut Clôturé/Résolu (pour le ✓ vert)
// - le commentaire technicien (affiché dans le tooltip)
// - la ref RFC_NUMBER (utilisée seulement en fallback, si le XML n'avait pas)
// Les autres extractions (categoryLine, intervenant, actionDescription) sont
// supprimées car ces infos viennent maintenant du XML attr1/attr2/attr3 ou du
// lazy-load xhr2 au hover.
/**
* Parse le HTML brut d'une fiche EasyVista (rendu serveur, ~460 Ko, NON hydraté
* par Angular donc ne contient PAS les valeurs d'actions — celles-ci sont
* chargées séparément via l'API timeline).
*
* Rôle : extraire les champs nécessaires :
* - status : STATUS_FR (affichage ✓ et fond vert si clos)
* - rfc : RFC_NUMBER (fallback si pas dans XML)
* - formId : id numérique du form (SD_REQUEST pour S... ou incident)
* - formChecksum : checksum du form (pour appel API timeline)
* - formSenderGuid : v4.1.9 — GUID du form (différent pour incident I...
* vs demande S...). Extrait dynamiquement depuis les
* liens target=FORM_ID&checksum=...&sender={GUID} du
* HTML lui-même. Pour les demandes S → C99ECD05..., pour
* les incidents I → 07ED9C68... (ou autre selon config).
*/
function parseFicheHtml(html) {
const out = {
status: null,
rfc: null,
formId: null,
formChecksum: null,
formSenderGuid: null
};
// STATUS_FR (valeur parfois encodée en \u00XX)
let m = html.match(/"dbFieldName"\s*:\s*"STATUS_FR"[^}]*?"value"\s*:\s*"([^"]{2,30})"/);
if (m) out.status = decodeJsonString(m[1]);
// RFC_NUMBER (fallback au cas où le XML n'aurait pas la ref)
m = html.match(/"dbFieldName"\s*:\s*"RFC_NUMBER"[^}]*?"value"\s*:\s*"([^"]{5,30})"/);
if (m) out.rfc = m[1];
// formData.form.{id,checksum} : indispensable pour l'API timeline.
// On matche dans les deux ordres possibles.
m = html.match(/var formData\s*=\s*\{"form":\{[^}]*?"checksum":"([a-f0-9]{40})"[^}]*?"id":"(\d+)"/);
if (m) {
out.formChecksum = m[1];
out.formId = m[2];
} else {
m = html.match(/var formData\s*=\s*\{"form":\{[^}]*?"id":"(\d+)"[^}]*?"checksum":"([a-f0-9]{40})"/);
if (m) {
out.formId = m[1];
out.formChecksum = m[2];
}
}
// v4.1.9 : déduire le GUID du form. On cherche dans le HTML un lien qui
// référence notre formId (target=FORM_ID...) avec un sender. C'est le GUID
// du form principal utilisé pour l'API timeline :
// - demande S... → {C99ECD05-3D48-4C62-ABF0-66292053AED6}
// - incident I... → {07ED9C68-6172-48EA-8A58-90912B0A283E}
// v4.1.10 (fix) : regex robuste qui accepte &, &, et parcourt jusqu'à
// 300 chars entre target=ID et sender= (au lieu de stopper au 1er "/'/espace
// ce qui peut échouer sur certains HTML).
if (out.formId) {
const rx = new RegExp(
`target=${out.formId}(?:&(?:amp;)?\\w+=[^&"'\\s<>]*){0,10}?&(?:amp;)?sender=(%7B[A-F0-9\\-]{36}%7D)`,
"i"
);
const sm = html.match(rx);
if (sm) {
out.formSenderGuid = sm[1]; // garder encodé (déjà prêt pour URL)
} else {
// Fallback : chercher le GUID le plus fréquent associé à notre formId
// dans tout le HTML (tolérant à n'importe quelle séquence entre les 2).
const rxLoose = new RegExp(
`target=${out.formId}[\\s\\S]{0,300}?sender=(%7B[A-F0-9\\-]{36}%7D)`,
"gi"
);
const counts = new Map();
let lm;
while ((lm = rxLoose.exec(html)) !== null) {
counts.set(lm[1], (counts.get(lm[1]) || 0) + 1);
}
// Prendre le plus fréquent
let best = null;
let bestCount = 0;
for (const [guid, c] of counts) {
if (c > bestCount) { best = guid; bestCount = c; }
}
if (best) out.formSenderGuid = best;
}
// v4.1.10 (fix définitif) : si toujours pas trouvé, fallback par défaut
// sur le GUID des demandes S... (le plus courant). Pour les rares
// incidents I... où le HTML brut n'aurait aucun lien target=FORM_ID, le
// timeline ne sera pas chargé mais le reste fonctionne.
if (!out.formSenderGuid && out.rfc) {
if (/^S/i.test(out.rfc)) {
out.formSenderGuid = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D";
} else if (/^I/i.test(out.rfc)) {
out.formSenderGuid = "%7B07ED9C68-6172-48EA-8A58-90912B0A283E%7D";
}
}
}
return out;
}
/**
* Parse le JSON renvoyé par /api/v1/internal/forms/{GUID}/timeline et en
* extrait le texte de description complet pour UNE action donnée.
*
* Structure du JSON :
* { data: { data: {
* columns: [...13 cols],
* values: [ ← 1 entrée par action dans la fiche
* { rows: [
* {value:"..."}, // [0..10] statut, groupe, dates, etc.
* {value:"Date : ... Heure : ... Lieu : ..."}, // [11] DESCRIPTION ⭐
* {value:""},
* {value:"{\"ACTION_ID\":\"57700033\",...}"} // [13] JSON stringifié
* ] }
* ] }}}
*
* On cherche l'action dont rows[13].ACTION_ID === actionId ; si trouvée, on
* retourne rows[11] nettoyé (br→\n, entités décodées) ; sinon null.
*/
function parseTimelineJsonForAction(jsonText, actionId) {
if (!jsonText || !actionId) return null;
let data;
try {
data = JSON.parse(jsonText);
} catch (e) {
console.warn("[timeline] JSON parse failed:", e);
return null;
}
const values = data?.data?.data?.values;
if (!Array.isArray(values)) return null;
const targetId = String(actionId);
for (const entry of values) {
const rows = entry?.rows;
if (!Array.isArray(rows) || rows.length < 14) continue;
// rows[13] = JSON stringifié qui contient ACTION_ID
const extraRaw = rows[13]?.value;
if (!extraRaw || typeof extraRaw !== "string") continue;
let extra;
try {
extra = JSON.parse(extraRaw);
} catch {
continue;
}
if (String(extra.ACTION_ID) !== targetId) continue;
// Trouvé : extraire la description (rows[11]) et la nettoyer.
const rawDesc = rows[11]?.value || extra["AM_ACTION.DESCRIPTION"] || "";
const cleaned = cleanHtmlBlock(rawDesc);
return cleaned || null;
}
return null;
}
/**
* Nettoie un bloc HTML pour obtenir du texte brut lisible.
* - (avec ou sans attributs) → \n
* - entités HTML décodées ( > etc.)
* - tags HTML restants supprimés
* - espaces multiples compactés
*/
function cleanHtmlBlock(html) {
if (!html) return "";
let s = html;
// , , , → \n
s = s.replace(/ ]*>/gi, "\n");
// Entités HTML
s = s.replace(/ /g, " ")
.replace(/>/g, ">")
.replace(/</g, "<")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/&/g, "&")
.replace(/\u200b/g, ""); // zero-width space
// Tags HTML restants
s = s.replace(/<[^>]+>/g, "");
// Espaces compactés, lignes trimmed, lignes vides retirées
s = s.split("\n").map(l => l.trim().replace(/[ \t]+/g, " ")).filter(Boolean).join("\n");
return s;
}
function decodeJsonString(s) {
return s
.replace(/\\r/g, "")
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\\//g, "/")
.replace(/\\"/g, '"')
.replace(/\\\\/g, "\\")
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
try { return String.fromCharCode(parseInt(hex, 16)); }
catch { return _; }
});
}
/**
* Parse le texte d'une action au format :
* Date : lundi 20.04 Heure : matin
* Lieu : Ville1/Rue1 1
* Service : Service1/...
* Contact : Nom1, Prénom1 +41000000001
* ...
*
* → renvoie un objet { date, heure, lieu, service, contact, etage, bureau,
* probleme, aFaire, tfsAncien, tfsNouveau, materiel, dateProposee, autres }
*/
function parseActionText(text) {
if (!text) return null;
const out = { _raw: text };
// v4.2 : on track toutes les occurrences de "Contact" / "Personne de contact"
// pour détecter l'anomalie (les 2 présents = situation suspecte).
const contactOccurrences = []; // { kind: "contact"|"personne", value: string }
// Pré-filtrer les lignes "Date proposée par ..." : on NE prend PAS ce champ
// nulle part (ni en infobulle.dateProposee, ni dans autres).
const lines = text.split(/\n+/)
.map(l => l.trim())
.filter(Boolean)
.filter(l => !/^\s*date\s+propos[ée]e\s+par\b/i.test(l));
const labelMap = {
"date": "date",
"heure": "heure",
"lieu": "lieu",
"service": "service",
"contact": "contact",
"bénéficiaire": "beneficiaire",
"beneficiaire": "beneficiaire",
"étage": "etage",
"etage": "etage",
"bureau": "bureau",
"problème": "probleme",
"probleme": "probleme",
"a faire": "aFaire",
"à faire": "aFaire",
"matériel": "materiel",
"materiel": "materiel",
"tfs ancien poste": "tfsAncien",
"tfs nouveau poste": "tfsNouveau"
};
const autres = [];
for (const line of lines) {
// Si la ligne CONTIENT "Date proposée par ..." à l'intérieur (pas juste au
// début), on coupe cette partie-là avant de parser le reste.
let cleanLine = line.replace(/\bdate\s+propos[ée]e\s+par\s+(?:le\s+|la\s+)?contact\s*[:?]\s*\S+.*$/i, "").trim();
if (!cleanLine) continue;
// v4.2 : on détecte aussi "Personne de contact..." (spécifique à la demande
// / sur site / de l'entité quittée / interne / etc.). On la marque comme
// un 2e candidat possible pour le contact affiché.
const rxPersonne = /Personne\s+de\s+contact(?:\s+(?:sur\s+site|sp[ée]cifique[^:]*|de\s+l[''`]?entit[ée][^:]*|interne[^:]*))?\s*:\s*/gi;
let pm;
while ((pm = rxPersonne.exec(cleanLine)) !== null) {
// Valeur = jusqu'au prochain label connu OU fin de ligne
const after = cleanLine.substring(pm.index + pm[0].length);
const stop = after.search(/\b(Date|Heure|Lieu|Service|Contact|B[ée]n[ée]ficiaire|[ÉE]tage|Bureau|Probl[èe]me|A\s*faire|À\s*faire|Mat[ée]riel|TFS|Personne\s+de\s+contact|Num[ée]ro\s+de\s+t[ée]l[ée]phone)\s*:/i);
const val = (stop >= 0 ? after.substring(0, stop) : after).trim()
.replace(/[,;]+$/, "").trim();
if (val) {
contactOccurrences.push({ kind: "personne", value: val });
}
}
// "Date : lundi 20.04 Heure : matin" → split en plusieurs paires
const markers = [];
// v4.2 : on ajoute un lookbehind négatif (?= 2) {
out.contactAnomalie = true;
// On prend quand même le 1er "contact" pur (pas "personne") si possible
const firstReal = contactOccurrences.find(x => x.kind === "contact");
out.contact = (firstReal || contactOccurrences[0]).value;
}
if (autres.length) out.autres = autres.join("\n");
return out;
}
// ============================================================================
// Rendu général
// ============================================================================
// Compteur de fetches en cours. La flèche tourne tant que ce compteur > 0.
// On le maintient manuellement au lieu d'un booléen pour gérer correctement
// les appels imbriqués (loadForDate + refreshStatuses en parallèle).
let refreshCounter = 0;
// Timer pour effacer le ✓ vert après 5 s
let refreshDoneTimer = null;
// v4.1.13 : quel bouton doit tourner pendant le refresh en cours.
// Valeurs : "total" (par défaut / chargement auto), "partial", ou "xml_only".
let activeRefreshButton = "total";
function setActiveRefreshButton(kind) {
activeRefreshButton = kind || "total";
// v4.1.20 : si le bouton Arrêter est affiché, le repositionner selon
// le nouveau type de refresh actif. Sinon rien à faire (il prendra sa
// position au prochain showAbortButton(true)).
positionAbortButton();
}
// v4.1.20 : place le bouton Arrêter à sa position correcte selon
// activeRefreshButton. Fonction idempotente, sûre à appeler plusieurs fois.
function positionAbortButton() {
const btn = document.getElementById("abort-btn");
if (!btn) return;
const partialBtn = document.getElementById("refresh-partial-btn");
const totalBtn = document.getElementById("refresh-btn");
if (!partialBtn || !totalBtn) return;
if (activeRefreshButton === "partial") {
// Entre Actualiser (partial) et Tout recharger (total)
if (btn.previousElementSibling !== partialBtn) {
totalBtn.parentNode.insertBefore(btn, totalBtn);
}
} else {
// Après Tout recharger
if (totalBtn.nextSibling !== btn) {
totalBtn.parentNode.insertBefore(btn, totalBtn.nextSibling);
}
}
}
function setRefreshing(on) {
const iconTotal = document.getElementById("refresh-icon");
const iconPartial = document.getElementById("refresh-partial-icon");
// Quel icône doit tourner ? Seulement celui correspondant au bouton
// qui a lancé le refresh (ou "total" par défaut).
const targetIcon = (activeRefreshButton === "partial") ? iconPartial : iconTotal;
if (on) {
refreshCounter++;
if (targetIcon) targetIcon.classList.add("spinning");
clearCheckMark();
// Afficher "Rafraîchissement en cours…" si on n'a pas déjà les données
updateCaptureInfoText();
} else {
refreshCounter = Math.max(0, refreshCounter - 1);
if (refreshCounter === 0) {
// Arrêt : stopper les deux icônes au cas où
if (iconTotal) iconTotal.classList.remove("spinning");
if (iconPartial) iconPartial.classList.remove("spinning");
}
updateCaptureInfoText();
}
}
// Force le rafraîchissement du texte "MAJ HH:MM" ou "Rafraîchissement en cours…"
// selon refreshCounter.
function updateCaptureInfoText() {
if (state.currentData) {
renderCaptureInfo(state.currentData);
}
}
/**
* Appelé quand TOUS les fetches (y compris les fetches fiches en
* arrière-plan) sont terminés. Affiche un ✓ vert à côté de l'heure MAJ
* pendant 5 secondes.
*/
function showRefreshDone() {
const check = document.getElementById("refresh-check");
if (!check) return;
check.classList.remove("hidden");
check.classList.add("visible");
if (refreshDoneTimer) clearTimeout(refreshDoneTimer);
refreshDoneTimer = setTimeout(() => {
check.classList.remove("visible");
setTimeout(() => check.classList.add("hidden"), 300); // après transition
}, 5000);
}
function clearCheckMark() {
const check = document.getElementById("refresh-check");
if (check) {
check.classList.remove("visible");
check.classList.add("hidden");
}
if (refreshDoneTimer) {
clearTimeout(refreshDoneTimer);
refreshDoneTimer = null;
}
}
// ─── Barre de progression (v4.1.7) ─────────────────────────────────────
// État global : on affiche la progression du fetch en cours, uniquement si
// c'est le fetch de la page actuellement visible. Si l'utilisateur change
// de date, la barre suit la nouvelle date (son propre état).
function showProgressBar() {
const bar = document.getElementById("progress-bar");
if (bar) bar.classList.remove("hidden");
}
function hideProgressBar() {
const bar = document.getElementById("progress-bar");
if (bar) bar.classList.add("hidden");
updateProgressBar(0, 0);
}
function updateProgressBar(done, total) {
const fill = document.getElementById("progress-bar-fill");
const label = document.getElementById("progress-bar-label");
if (!fill || !label) return;
if (total <= 0) {
fill.style.width = "0%";
label.textContent = "";
return;
}
const pct = Math.min(100, Math.round((done / total) * 100));
fill.style.width = pct + "%";
// v4.1.20 : message différencié selon le type de refresh actif
const prefix = (activeRefreshButton === "partial") ? "Actualisation" : "Rafraîchissement";
label.textContent = `${prefix}… ${done} / ${total}`;
}
// Affiche/masque le bouton "Arrêter". N'est montré que pendant un refresh
// manuel (clic utilisateur), pas pendant les chargements normaux ni les
// refresh auto 12h/15h.
function showAbortButton(on) {
const btn = document.getElementById("abort-btn");
if (!btn) return;
if (on) {
positionAbortButton();
btn.classList.remove("hidden");
} else {
btn.classList.add("hidden");
}
}
/**
* Toast de feedback quand l'user clique Arrêter. Les fetches en cours peuvent
* encore prendre 1-2 secondes avant de se terminer (on ne peut pas vraiment
* annuler un fetch() en cours), mais du point de vue de l'interface tout
* est arrêté : plus de mise à jour, plus de cache, plus rien.
*/
function showAbortToast() {
showToast("Rafraîchissement", "arrêté");
}
function renderFromData(data) {
state.currentData = data;
document.getElementById("loading").classList.add("hidden");
document.getElementById("error-box").classList.add("hidden");
document.getElementById("session-needed").classList.add("hidden");
document.getElementById("cards").classList.remove("hidden");
// Calculer les stats
const stats = computeStats(data.techs, data.targetDate);
renderCaptureInfo(data, stats);
renderStats(stats);
renderCards(data);
}
function renderCaptureInfo(data, stats) {
const info = document.getElementById("capture-info");
if (refreshCounter > 0) {
// v4.1.20 : message différencié selon le type de refresh actif
// - partial (Actualiser) → "Actualisation en cours…"
// - total (Tout recharger) → "Rafraîchissement en cours…"
if (activeRefreshButton === "partial") {
info.textContent = "Actualisation en cours…";
} else {
info.textContent = "Rafraîchissement en cours…";
}
info.classList.add("refreshing");
return;
}
info.classList.remove("refreshing");
const parts = [];
if (data.captureTime) {
const d = new Date(data.captureTime);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const today = new Date();
const isSameDay = d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate();
// v4.1.20 : préfixe selon le type de refresh qui a généré cette capture
// - lastRefreshKind === "partial" → "Actualisé à HH:MM"
// - lastRefreshKind === "total" → "Synchronisé à HH:MM"
// - data.source === "cache" → "Cache de HH:MM"
let prefix;
if (data.source === "cache") {
prefix = "Cache de ";
} else if (data.lastRefreshKind === "partial") {
prefix = "Actualisé à ";
} else {
prefix = "Synchronisé à ";
}
if (isSameDay) {
parts.push(`${prefix}${hh}:${mm}`);
} else {
const dd = String(d.getDate()).padStart(2, "0");
const mo = String(d.getMonth() + 1).padStart(2, "0");
let prefixDate;
if (data.source === "cache") {
prefixDate = "Cache du ";
} else if (data.lastRefreshKind === "partial") {
prefixDate = "Actualisé le ";
} else {
prefixDate = "Synchronisé le ";
}
parts.push(`${prefixDate}${dd}.${mo} ${hh}:${mm}`);
}
}
info.textContent = parts.join(" · ");
}
function computeStats(techs, targetDate) {
let pompiers = 0, absents = 0;
let totalInterventions = 0, morning = 0, afternoon = 0;
let closed = 0, resolved = 0;
for (const tech of techs) {
const isPompier = tech.interventions.some(iv => iv.isPompier);
const isAbsent = isTechAbsent(tech, targetDate);
if (isPompier) pompiers++;
if (isAbsent) absents++;
const real = tech.interventions.filter(iv =>
iv.type !== "AL-Absence" && !iv.isPompier
);
for (const iv of real) {
totalInterventions++;
const s = timeToMinutes(iv.startTime);
if (s !== null && s < 12 * 60) morning++;
else if (s !== null) afternoon++;
if (isClosedStatus(iv.status)) closed++;
else if (isResolvedStatus(iv.status)) resolved++;
}
}
return { totalTechs: techs.length, pompiers, absents, totalInterventions, morning, afternoon, closed, resolved };
}
function renderStats(s) {
const el = document.getElementById("stats");
el.innerHTML = `
${s.totalInterventions} intervention${s.totalInterventions > 1 ? "s" : ""}(${s.morning} matin · ${s.afternoon} après-midi)
${(s.closed + s.resolved > 0) ? `·${s.closed + s.resolved} clos` : ""}
·${s.totalTechs} techs·${s.pompiers} pompier${s.pompiers > 1 ? "s" : ""}·${s.absents} absent${s.absents > 1 ? "s" : ""}
`;
el.classList.remove("hidden");
}
function renderCards(data) {
const container = document.getElementById("cards");
container.innerHTML = "";
// Tri : pompier(s) > actifs alphabétique nom de famille > absents alphabétique
const sorted = [...data.techs].sort((a, b) => compareTechs(a, b, data.targetDate));
for (const tech of sorted) {
container.appendChild(buildCard(tech, data.targetDate));
}
}
function compareTechs(a, b, targetDate) {
const aP = a.interventions.some(iv => iv.isPompier);
const bP = b.interventions.some(iv => iv.isPompier);
if (aP && !bP) return -1;
if (bP && !aP) return 1;
const aAbs = isTechAbsent(a, targetDate);
const bAbs = isTechAbsent(b, targetDate);
if (aAbs && !bAbs) return 1;
if (bAbs && !aAbs) return -1;
// Sinon : alphabétique sur le nom de famille
// Les noms sont stockés au format "Nom, Prénom"
const aLast = (a.name || "").split(",")[0].trim();
const bLast = (b.name || "").split(",")[0].trim();
return aLast.localeCompare(bLast, "fr");
}
function isTechAbsent(tech, isoDate) {
const recurring = RECURRING_ABSENCES[tech.id];
if (recurring) {
const day = isoToDate(isoDate).getDay();
if (recurring.includes(day)) return true;
}
if (tech.interventions.length === 0) return false;
return tech.interventions.every(iv => iv.type === "AL-Absence" && !iv.isPompier);
}
// ============================================================================
// Construction d'une carte
// ============================================================================
// v4.1.20 : détecte si tech = Pillonel Olivier ET jour = vendredi.
// Hardcodé car c'est une absence récurrente connue spécifique à lui.
function isPillonelAbsentFriday(tech, isoDate) {
if (!tech || !tech.name) return false;
// Normaliser le nom (tolère "Pillonel, Olivier", "Pillonel Olivier", etc.)
const name = tech.name.toLowerCase();
if (!name.includes("pillonel")) return false;
if (!name.includes("olivier")) return false;
// Jour de la semaine : 5 = vendredi (en JS, 0=dim, 1=lun, ..., 5=ven)
const d = isoToDate(isoDate);
return d.getDay() === 5;
}
function buildCard(tech, isoDate) {
const card = document.createElement("section");
card.className = "card";
card.dataset.techId = tech.id;
const isPompier = tech.interventions.some(iv => iv.isPompier);
const isAbsent = isTechAbsent(tech, isoDate);
if (isPompier) card.classList.add("is-pompier");
if (isAbsent) card.classList.add("is-absent");
const realInterventions = tech.interventions.filter(iv =>
iv.type !== "AL-Absence" && !iv.isPompier
);
const absenceBlocks = tech.interventions.filter(iv => iv.type === "AL-Absence");
const pompierBlocks = tech.interventions.filter(iv => iv.isPompier);
const morning = realInterventions.filter(iv => {
const s = timeToMinutes(iv.startTime);
return s !== null && s < 12 * 60;
}).length;
const afternoon = realInterventions.length - morning;
// --- Header ---
const header = document.createElement("div");
header.className = "card-header";
const nameEl = document.createElement("div");
nameEl.className = "card-tech-name";
nameEl.textContent = tech.name;
header.appendChild(nameEl);
if (isPompier || isAbsent) {
const badge = document.createElement("div");
badge.className = "card-tech-badge";
if (isPompier) {
badge.classList.add("badge-pompier");
badge.textContent = "Pompier";
} else {
badge.classList.add("badge-absent");
badge.textContent = "Absent";
}
header.appendChild(badge);
}
card.appendChild(header);
// --- Body ---
const body = document.createElement("div");
body.className = "card-body";
// Note statut
if (isPompier && pompierBlocks.length) {
const note = document.createElement("div");
note.className = "card-status-note pompier";
const pb = pompierBlocks[0];
if (pb.startDate && pb.endDate && pb.startDate !== pb.endDate) {
note.textContent = `En pompier du ${pb.startDate.substring(0, 5)} au ${pb.endDate.substring(0, 5)}`;
} else {
note.textContent = "En pompier aujourd'hui";
}
body.appendChild(note);
} else if (isAbsent && absenceBlocks.length) {
const note = document.createElement("div");
note.className = "card-status-note absent";
const ab = absenceBlocks[0];
if (ab.startDate && ab.endDate && ab.startDate !== ab.endDate) {
note.textContent = `Absent du ${ab.startDate.substring(0, 5)} au ${ab.endDate.substring(0, 5)}`;
} else {
note.textContent = "Absent toute la journée";
}
body.appendChild(note);
}
// v4.1.20 : cas spécifique Pillonel Olivier, absent tous les vendredis.
// Affichage d'un message explicite au lieu de "Pas d'intervention planifiée".
// v4.2 : prioritaire même si un bloc AL-Absence couvre le vendredi (ce qui
// est le cas normal), pour TOUJOURS afficher "Absent le vendredi".
const isPillonelFriday = isPillonelAbsentFriday(tech, isoDate);
// Absent sans interv → on stop là (après avoir posé le message Pillonel
// si vendredi).
if (isAbsent && realInterventions.length === 0) {
if (isPillonelFriday) {
const note = document.createElement("div");
note.className = "tech-absence-recurring";
note.textContent = "Absent le vendredi";
body.appendChild(note);
}
card.appendChild(body);
return card;
}
if (realInterventions.length === 0 && !isPompier) {
if (isPillonelFriday) {
const note = document.createElement("div");
note.className = "tech-absence-recurring";
note.textContent = "Absent le vendredi";
body.appendChild(note);
} else {
const empty = document.createElement("div");
empty.className = "card-empty";
empty.textContent = "Pas d'intervention planifiée";
body.appendChild(empty);
}
card.appendChild(body);
return card;
}
// Pillonel vendredi avec quand même des interv planifiées ? Rare mais possible.
if (isPillonelFriday && realInterventions.length > 0) {
const note = document.createElement("div");
note.className = "tech-absence-recurring";
note.textContent = "Absent le vendredi";
body.appendChild(note);
}
// Timeline
body.appendChild(buildTimeline(realInterventions, pompierBlocks, absenceBlocks, card, isPompier, isAbsent));
// Stats de carte
if (realInterventions.length > 0) {
const stats = document.createElement("div");
stats.className = "card-stats";
stats.innerHTML = `
`;
body.appendChild(stats);
}
// Liste interventions
for (const iv of realInterventions) {
body.appendChild(buildInterventionRow(iv, card));
}
card.appendChild(body);
return card;
}
// ============================================================================
// Timeline
// ============================================================================
function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) {
const DAY_START = 8 * 60;
const DAY_END = 18 * 60;
const DAY_LEN = DAY_END - DAY_START;
const wrap = document.createElement("div");
wrap.className = "timeline";
if (isPompier) wrap.classList.add("timeline-pompier");
const bar = document.createElement("div");
bar.className = "timeline-bar";
const segments = [];
for (let i = 0; i < realInterventions.length; i++) {
const iv = realInterventions[i];
const s = timeToMinutes(iv.startTime);
const e = timeToMinutes(iv.endTime);
if (s === null || e === null) continue;
const cs = Math.max(s, DAY_START);
const ce = Math.min(e, DAY_END);
if (ce <= cs) continue;
segments.push({
kind: "intervention",
colorKey: deriveColorKey(iv),
iv, ivIdx: i,
start: cs, end: ce,
statusClass: getStatusClass(iv)
});
}
for (const ab of absenceBlocks || []) {
const s = timeToMinutes(ab.startTime);
const e = timeToMinutes(ab.endTime);
if (s === null || e === null) continue;
const cs = Math.max(s, DAY_START);
const ce = Math.min(e, DAY_END);
if (cs <= DAY_START && ce >= DAY_END) continue;
if (ce <= cs) continue;
segments.push({ kind: "absence", start: cs, end: ce, iv: ab });
}
// Calcul des trous (que si pas absent complet)
const occupiedRanges = segments.map(s => [s.start, s.end]).sort((a, b) => a[0] - b[0]);
const merged = [];
for (const [s, e] of occupiedRanges) {
if (merged.length && s <= merged[merged.length - 1][1]) {
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], e);
} else {
merged.push([s, e]);
}
}
const holes = [];
let cursor = DAY_START;
for (const [s, e] of merged) {
if (s > cursor) holes.push([cursor, s]);
cursor = Math.max(cursor, e);
}
if (cursor < DAY_END) holes.push([cursor, DAY_END]);
if (!isAbsent) {
for (const [s, e] of holes) {
if (e - s < 15) continue;
const h = document.createElement("div");
h.className = "timeline-hole";
h.style.left = ((s - DAY_START) / DAY_LEN) * 100 + "%";
h.style.width = ((e - s) / DAY_LEN) * 100 + "%";
h.dataset.startMin = s;
h.dataset.endMin = e;
h.dataset.kind = "hole";
bindTimelinePopover(h);
bar.appendChild(h);
}
}
for (const seg of segments) {
const el = document.createElement("div");
el.className = "timeline-slot kind-" + seg.kind;
if (seg.colorKey) el.classList.add("color-" + seg.colorKey);
if (seg.statusClass) el.classList.add(seg.statusClass);
el.style.left = ((seg.start - DAY_START) / DAY_LEN) * 100 + "%";
el.style.width = ((seg.end - seg.start) / DAY_LEN) * 100 + "%";
el.dataset.startMin = seg.start;
el.dataset.endMin = seg.end;
el.dataset.kind = seg.kind;
if (seg.iv) {
el.dataset.title = deriveShortTitle(seg.iv);
if (seg.iv.ref) el.dataset.ref = seg.iv.ref;
}
if (seg.ivIdx !== undefined) {
el.dataset.ivIdx = seg.ivIdx;
el.addEventListener("mouseenter", () => highlightIntervention(cardEl, seg.ivIdx, true));
el.addEventListener("mouseleave", () => highlightIntervention(cardEl, seg.ivIdx, false));
}
bindTimelinePopover(el);
bar.appendChild(el);
}
const noon = document.createElement("div");
noon.className = "timeline-noon";
noon.style.left = (((12 * 60) - DAY_START) / DAY_LEN) * 100 + "%";
bar.appendChild(noon);
wrap.appendChild(bar);
const scale = document.createElement("div");
scale.className = "timeline-scale";
for (const h of [8, 10, 12, 14, 16, 18]) {
const t = document.createElement("span");
t.className = "timeline-tick";
t.style.left = (((h * 60) - DAY_START) / DAY_LEN * 100) + "%";
t.textContent = h + "h";
scale.appendChild(t);
}
wrap.appendChild(scale);
return wrap;
}
function getStatusClass(iv) {
if (isClosedStatus(iv.status)) return "status-closed";
if (isResolvedStatus(iv.status)) return "status-resolved";
return null;
}
function bindTimelinePopover(el) {
el.addEventListener("mouseenter", (e) => showTimelinePopover(e, el));
// v4.2.3 : la petite popup timeline SUIT la souris (différent de la grande
// popup des lignes d'intervention qui est ancrée). On n'utilise pas
// moveTooltip() (no-op depuis v4.1.12) mais une fonction dédiée.
el.addEventListener("mousemove", (e) => moveTimelineTooltip(e));
el.addEventListener("mouseleave", hideTooltip);
// v4.2.3 : clic / double-clic / Ctrl+clic sur un segment timeline
// - clic simple : ferme la petite popup et ouvre la GRANDE popup
// (ancrée juste en dessous de la timeline, persistante pour permettre
// de sélectionner du texte / copier)
// - double-clic : ouvre la fiche EasyVista dans un nouvel onglet actif
// - Ctrl+clic (Cmd+clic sur Mac) : ouvre dans un onglet en arrière-plan
const kind = el.dataset.kind;
const ivIdxStr = el.dataset.ivIdx;
// Seulement sur les segments avec une intervention (pas les "hole" libres
// ni certaines absences sans ivIdx)
if (ivIdxStr === undefined) return;
let singleClickTimer = null;
el.addEventListener("click", (e) => {
// Ctrl / Cmd / molette → ouvrir fiche en arrière-plan
if (e.ctrlKey || e.metaKey || e.button === 1) {
e.preventDefault();
e.stopPropagation();
openInterventionFromTimeline(el, { background: true });
return;
}
// Clic simple (sans Ctrl) : on attend un éventuel double-clic avant
// d'ouvrir la grande popup persistante.
e.stopPropagation();
if (singleClickTimer) clearTimeout(singleClickTimer);
singleClickTimer = setTimeout(() => {
singleClickTimer = null;
openPersistentTimelinePopup(el);
}, 250);
});
el.addEventListener("dblclick", (e) => {
// Annuler le clic simple en attente
if (singleClickTimer) { clearTimeout(singleClickTimer); singleClickTimer = null; }
e.preventDefault();
e.stopPropagation();
openInterventionFromTimeline(el, { background: false });
});
}
// v4.2.3 : positionne la petite popup timeline à côté du curseur
function moveTimelineTooltip(e) {
const tip = tooltipEl();
if (!tip || !tip.classList.contains("visible")) return;
// La popup ancrée (grande bulle) ne doit pas être déplacée par la souris
if (bulleState.pinned) return;
// Si la popup affiche une grande bulle d'intervention (classe pinned-like),
// on ne la bouge pas non plus : on la laisse ancrée.
if (tip.dataset.mode === "anchored") return;
const offsetX = 14, offsetY = 16;
let x = e.clientX + offsetX;
let y = e.clientY + offsetY;
const rect = tip.getBoundingClientRect();
// Ajuster si on sort de la fenêtre
if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offsetX;
if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offsetY;
if (x < 4) x = 4;
if (y < 4) y = 4;
tip.style.left = x + "px";
tip.style.top = y + "px";
currentTooltipPos = { x, y };
}
// v4.2.3 : trouve l'iv correspondant au segment timeline et ouvre sa fiche
function openInterventionFromTimeline(el, opts) {
const ivIdx = el.dataset.ivIdx;
if (ivIdx === undefined) return;
const cardEl = el.closest(".card");
if (!cardEl) return;
const row = cardEl.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`);
if (!row) return;
const actionId = row.dataset.actionId;
if (!actionId) return;
// Récupère l'iv depuis state
const iv = findIvByActionId(actionId);
if (!iv) return;
openInterventionInNewTab(iv, opts || {});
}
function findIvByActionId(actionId) {
const data = state.currentData;
if (!data || !data.techs) return null;
for (const tech of data.techs) {
for (const iv of (tech.interventions || [])) {
if (String(iv.actionId) === String(actionId)) return iv;
}
}
return null;
}
// v4.2.3 : ouvre la GRANDE popup (comme au hover sur une ligne) mais ancrée
// juste en dessous du segment timeline cliqué, et épinglée pour qu'elle
// reste ouverte et autorise la sélection de texte.
function openPersistentTimelinePopup(el) {
const ivIdx = el.dataset.ivIdx;
if (ivIdx === undefined) return;
const cardEl = el.closest(".card");
if (!cardEl) return;
const row = cardEl.querySelector(`.intervention-v2[data-iv-idx="${ivIdx}"]`);
if (!row) return;
const actionId = row.dataset.actionId;
const iv = findIvByActionId(actionId);
if (!iv) return;
// Fermer toute popup en cours (petite timeline ou grande bulle)
const tip = tooltipEl();
if (!tip) return;
// Reset de l'état pour qu'un hideTooltip forcé ne soit pas bloqué
bulleState.pinned = false;
hideTooltip({ force: true });
// Reset position mémorisée pour éviter re-application au scroll sur
// l'ancienne position
currentTooltipPos = null;
// Construit et affiche la grande bulle, ancrée sous la timeline
tip.innerHTML = buildTooltipHTML(iv);
tip.classList.remove("hidden");
tip.classList.add("visible");
tip.dataset.mode = "anchored";
state.currentTooltipIv = iv;
// Position : juste en dessous de l'élément timeline, aligné à gauche
const r = el.getBoundingClientRect();
const tipRect = tip.getBoundingClientRect();
let x = r.left;
let y = r.bottom + 8;
if (x + tipRect.width > window.innerWidth - 8) x = window.innerWidth - tipRect.width - 8;
if (x < 4) x = 4;
if (y + tipRect.height > window.innerHeight - 8) {
// Pas assez de place en bas : on met au-dessus de la timeline
y = r.top - tipRect.height - 8;
}
if (y < 4) y = 4;
tip.style.left = x + "px";
tip.style.top = y + "px";
currentTooltipPos = { x, y };
// Épingler pour que la popup reste ouverte (elle ne se fermera que sur
// Échap, clic sur ✕ ou double-Ctrl). Permet aussi la sélection de texte.
bulleState.pinned = true;
tip.classList.add("pinned");
}
function showTimelinePopover(e, el) {
const s = parseInt(el.dataset.startMin, 10);
const eMin = parseInt(el.dataset.endMin, 10);
const kind = el.dataset.kind;
const dur = eMin - s;
let html;
if (kind === "hole") {
const h = Math.floor(dur / 60);
const min = dur % 60;
let d;
if (h === 0) d = `${min} min`;
else if (min === 0) d = `${h} h`;
else d = `${h} h ${min} min`;
html = `
Libre
${minutesToTime(s)}–${minutesToTime(eMin)}
Durée
${d} disponible
`;
} else {
const t = el.dataset.title || "";
const ref = el.dataset.ref || "";
const k = kind === "absence" ? "Absence" : "Intervention";
html = `
${k}
${minutesToTime(s)}–${minutesToTime(eMin)}
${t ? `
Type
${escapeHtml(t)}
` : ""}
${ref ? `
Réf
${escapeHtml(ref)}
` : ""}
`;
}
const tip = tooltipEl();
// v4.2.3 : si une grande bulle est déjà épinglée via clic timeline, on ne
// la remplace pas par la petite popup hover.
if (bulleState.pinned && tip.dataset.mode === "anchored") return;
tip.innerHTML = html;
tip.classList.remove("hidden", "pinned");
tip.classList.add("visible");
// v4.2.3 : mode "hover" = petite popup qui suit la souris
tip.dataset.mode = "hover";
moveTimelineTooltip(e);
}
// ============================================================================
// Ligne d'intervention
// ============================================================================
function buildInterventionRow(iv, cardEl) {
const row = document.createElement("div");
row.className = "intervention-v2";
row.dataset.actionId = iv.actionId;
if (iv.isPompier) row.classList.add("is-pompier-line");
if (iv.ghost) row.classList.add("is-ghost");
const colorKey = deriveColorKey(iv);
row.classList.add("color-" + colorKey);
const statusClass = getStatusClass(iv);
if (statusClass) row.classList.add(statusClass);
const ivIdx = cardEl._rowIdxCounter || 0;
cardEl._rowIdxCounter = ivIdx + 1;
row.dataset.ivIdx = ivIdx;
if (iv.formLink && !iv.ghost) {
row.classList.add("clickable");
// v4.1.8 : plus de title au survol (info déjà dans le tooltip en bas)
// Clic normal : ouvre l'onglet et change de page
// Ctrl/Cmd+Clic : ouvre en arrière-plan (reste sur le planning)
row.addEventListener("click", (e) => {
if (e.target.closest(".intervention-copy")) return;
const background = !!(e.ctrlKey || e.metaKey);
openInterventionInNewTab(iv, { background });
});
// Clic molette (button === 1) : ouvre en arrière-plan
// On utilise 'auxclick' pour les boutons du milieu/droite (standard W3C).
row.addEventListener("auxclick", (e) => {
if (e.button !== 1) return; // que la molette
if (e.target.closest(".intervention-copy")) return;
e.preventDefault();
openInterventionInNewTab(iv, { background: true });
});
// Empêcher le scroll auto quand on clique la molette sur la ligne
row.addEventListener("mousedown", (e) => {
if (e.button === 1) e.preventDefault();
});
}
// Pastille colorée à gauche (barre verticale, toute la hauteur)
const dot = document.createElement("div");
dot.className = "intervention-dot";
row.appendChild(dot);
// ─── Ligne 1 : Ref centrée (TITRE en gros + gras) ────────────────────────
const refHeader = document.createElement("div");
refHeader.className = "iv-ref-header";
if (iv.type === "AL-Reservation") {
refHeader.textContent = "Réservation";
refHeader.classList.add("is-reservation-title");
} else if (iv.ref) {
refHeader.textContent = iv.ref;
} else {
refHeader.textContent = "—";
refHeader.classList.add("no-ref");
}
row.appendChild(refHeader);
// Check ✓ + bouton copier à droite de la ref (pas pour réservation)
if (statusClass && iv.type !== "AL-Reservation") {
const statusEl = document.createElement("div");
statusEl.className = "iv-status-check";
statusEl.textContent = "✓";
row.appendChild(statusEl);
}
if (iv.ref && iv.type !== "AL-Reservation") {
const copyBtn = document.createElement("button");
copyBtn.className = "intervention-copy";
copyBtn.type = "button";
copyBtn.title = "Copier la référence";
copyBtn.innerHTML = "📋";
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
copyRef(iv.ref, copyBtn);
});
row.appendChild(copyBtn);
}
// ─── Ligne 2 gauche : heures VERTICALES (début / ↓ / fin) ─────────────────
const timeEl = document.createElement("div");
timeEl.className = "iv-time-vertical";
if (iv.startTime && iv.endTime) {
const s = document.createElement("div");
s.className = "iv-time-start";
s.textContent = iv.startTime;
const sep = document.createElement("div");
sep.className = "iv-time-arrow";
sep.textContent = "↓";
const e = document.createElement("div");
e.className = "iv-time-end";
e.textContent = iv.endTime;
timeEl.appendChild(s);
timeEl.appendChild(sep);
timeEl.appendChild(e);
} else {
timeEl.textContent = "—";
}
row.appendChild(timeEl);
// ─── Ligne 2 droite : lieu / contact+tél / catégorie+signature ───────────
// Pour une RÉSERVATION : affichage différent (par + sujet)
const rightCol = document.createElement("div");
rightCol.className = "iv-right";
if (iv.type === "AL-Reservation") {
// Bloc "Par Nom, Prénom" (en gras)
if (iv.reservationCreator) {
const parEl = document.createElement("div");
parEl.className = "iv-reservation-par";
parEl.textContent = "Par " + iv.reservationCreator;
rightCol.appendChild(parEl);
}
// Sujet (ex: "Ecrans", "Rollout")
if (iv.reservationLabel) {
const sujetEl = document.createElement("div");
sujetEl.className = "iv-reservation-sujet";
sujetEl.textContent = "Sujet : " + iv.reservationLabel;
rightCol.appendChild(sujetEl);
}
row.appendChild(rightCol);
// Tooltip (fixe, ne suit pas la souris — v4.1.12)
row.addEventListener("mouseenter", (e) => {
showTooltip(e, iv, row);
highlightIntervention(cardEl, ivIdx, true);
});
row.addEventListener("mouseleave", () => {
hideTooltip();
highlightIntervention(cardEl, ivIdx, false);
});
return row;
}
// v4.1.2 : priorité à iv.infobulle (venant du xhr2 = données réelles vérifiées
// par le tech sur place) puis fallback sur iv.bulleContact/iv.bulleLieu
// (venant de attr1/attr2 = planification initiale, parfois incorrecte).
const info = iv.infobulle || {};
const contactRaw = info.contact || iv.bulleContact || null;
const lieuRaw = info.lieu || iv.bulleLieu || null;
// Rendu initial de lieu + contacts dans rightCol
renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, info.contactAnomalie);
// ── Bas : Catégorie (à gauche) + Signature planificateur (à droite) ──────
const bottomEl = document.createElement("div");
bottomEl.className = "iv-bottom-line";
const categoryEl = document.createElement("span");
categoryEl.className = "iv-category";
categoryEl.textContent = deriveShortTitle(iv);
bottomEl.appendChild(categoryEl);
// v4.1.8 : extraire la signature depuis le texte COMPLET (fiche) en
// priorité, sinon depuis le xhr2 tronqué. Le xhr2 tronqué peut couper la
// signature, la fiche a toujours le texte complet.
const signature = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription);
if (signature) {
const sigEl = document.createElement("span");
sigEl.className = "iv-signature";
sigEl.textContent = signature;
bottomEl.appendChild(sigEl);
}
rightCol.appendChild(bottomEl);
row.appendChild(rightCol);
// Tooltip (fixe, ne suit pas la souris — v4.1.12)
row.addEventListener("mouseenter", (e) => {
showTooltip(e, iv, row);
highlightIntervention(cardEl, ivIdx, true);
});
row.addEventListener("mouseleave", () => {
hideTooltip();
highlightIntervention(cardEl, ivIdx, false);
});
return row;
}
// Sender correct pour ouvrir une fiche EasyVista (vu dans les URLs qui marchent)
const FICHE_SENDER = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D";
async function openInterventionInNewTab(iv, opts = {}) {
if (!iv.formLink) return;
// Toast de feedback visuel dès le clic
showToast("Ouverture", iv.ref || iv.actionId);
// Récupérer la session actuelle pour construire une URL valide
let session = state.session;
if (!session) {
const resp = await sendMessage({ type: "getSession" });
session = resp && resp.session;
}
if (!session) {
alert("Pas de session EasyVista active. Ouvre d'abord un onglet EasyVista.");
return;
}
if (!iv.requestId) {
alert("Impossible d'ouvrir : identifiant de fiche (request_id) manquant.\n" +
"Essaie d'actualiser le planning (bouton Rafraîchir).");
return;
}
let target = null;
let checksum = null;
// v4.1.4 : on fetch TOUJOURS la fiche à la volée au clic pour extraire un
// checksum FRAIS. Ne pas utiliser iv.ficheChecksum du cache : les checksums
// EasyVista peuvent expirer entre le fetch arrière-plan et le clic utilisateur.
//
// Retry automatique en cas d'échec du pattern checksum.
{
console.log("[click] fetch fiche fraîche pour iv", iv.actionId, "requestId=", iv.requestId);
let attempts = 0;
const maxAttempts = 2;
while (attempts < maxAttempts && (!target || !checksum)) {
attempts++;
try {
const ficheResp = await sendMessage({
type: "fetchFiche",
formLink: iv.formLink
});
if (!ficheResp.ok) {
if (attempts >= maxAttempts) {
alert("Impossible d'ouvrir la fiche : " + (ficheResp.error || "erreur"));
return;
}
continue; // retry
}
// Extraire le checksum lié au requestId précis
const rx = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`, 'g');
const allMatches = [...ficheResp.html.matchAll(rx)];
console.log(`[click] Trouvé ${allMatches.length} occurrence(s) de target=${iv.requestId}&checksum=... dans HTML de la fiche (taille ${ficheResp.html.length})`);
allMatches.forEach((m, idx) => console.log(` [${idx}] checksum = ${m[1]}`));
if (allMatches.length === 0) {
console.warn(`[click] tentative ${attempts}: pattern target=${iv.requestId} introuvable`);
if (attempts >= maxAttempts) {
alert("Impossible de trouver le checksum pour cette fiche (après retry).");
return;
}
// Attendre un peu avant retry
await new Promise(r => setTimeout(r, 300));
continue;
}
// On prend le PREMIER checksum trouvé (comme avant, comportement v4)
target = iv.requestId;
checksum = allMatches[0][1];
console.log(`[click] checksum retenu: ${checksum}`);
// On stocke aussi en cache pour accélérer le prochain clic (au cas où)
iv.ficheTarget = target;
iv.ficheChecksum = checksum;
} catch (err) {
if (attempts >= maxAttempts) {
alert("Erreur lors du fetch de la fiche : " + err.message);
return;
}
}
}
}
// v4.1.18 : sender à utiliser dépend du type de fiche :
// - demande S... → {C99ECD05-...}
// - incident I... → {07ED9C68-...}
// On préfère le formSenderGuid extrait du HTML de la fiche si connu, sinon
// fallback sur préfixe de la ref.
let sender = FICHE_SENDER;
if (iv.formSenderGuid) {
sender = iv.formSenderGuid;
} else if (iv.ref && /^I/i.test(iv.ref)) {
sender = "%7B07ED9C68-6172-48EA-8A58-90912B0A283E%7D";
}
// Construire l'URL qui fonctionne (format identique à l'URL manuelle qui
// marche dans le navigateur quand on ouvre une fiche depuis l'UI EasyVista).
const internalurltime = Math.floor(Date.now() / 1000);
const url =
`${session.origin}/index.php` +
`?PHPSESSID=${encodeURIComponent(session.phpsessid)}` +
`&internalurltime=${internalurltime}` +
`&eventName=formEvent` +
`&target=${encodeURIComponent(target)}` +
`&checksum=${encodeURIComponent(checksum)}` +
`&sender=${sender}`;
console.log("[click] ouverture fiche iv=", iv.actionId, "ref=", iv.ref, "target=", target, "bg=", !!opts.background);
// Si background (Ctrl+Clic ou clic molette) : onglet ouvert mais pas actif,
// on reste sur la page du planning.
await chrome.tabs.create({ url, active: !opts.background });
}
const TOAST_MAX = 3;
const TOAST_DURATION_MS = 2400;
/**
* Affiche un toast en bas à droite. S'empile, max 3, animations in/out.
*/
function showToast(label, ref) {
const stack = document.getElementById("toast-stack");
if (!stack) return;
// Si on dépasse le max, supprimer le plus ancien (= premier enfant)
while (stack.children.length >= TOAST_MAX) {
const oldest = stack.firstChild;
if (oldest) stack.removeChild(oldest);
}
const toast = document.createElement("div");
toast.className = "toast";
const labelEl = document.createElement("span");
labelEl.className = "toast-label";
labelEl.textContent = label;
const refEl = document.createElement("span");
refEl.className = "toast-ref";
refEl.textContent = ref || "…";
toast.appendChild(labelEl);
toast.appendChild(refEl);
stack.appendChild(toast);
// Forcer reflow puis animer en entrée
void toast.offsetWidth;
toast.classList.add("visible");
// Auto-disparition après TOAST_DURATION_MS
setTimeout(() => {
toast.classList.remove("visible");
toast.classList.add("leaving");
setTimeout(() => {
if (toast.parentNode === stack) stack.removeChild(toast);
}, 220);
}, TOAST_DURATION_MS);
}
/**
* Formate un numéro de téléphone suisse / français.
* 079 123 45 67 (mobile CH)
* 021 123 45 67 (fixe CH)
* +41 79 123 45 67
* +33 1 23 45 67 89
* Si le format n'est pas reconnu, renvoie le numéro tel quel (avec les chiffres seuls).
*/
function formatPhone(raw) {
if (!raw) return null;
const digits = String(raw).replace(/[^\d+]/g, "");
if (!digits) return null;
// +41 (Suisse international, 9 chiffres après +41)
let m = digits.match(/^\+41(\d{9})$/);
if (m) {
const d = m[1];
return `+41 ${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
}
// v4.2 : 41XXXXXXXXX sans + (format EasyVista qui colle parfois le préfixe)
m = digits.match(/^41(\d{9})$/);
if (m) {
const d = m[1];
return `+41 ${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
}
// +33 (France)
m = digits.match(/^\+33(\d{9})$/);
if (m) {
const d = m[1];
return `+33 ${d.slice(0, 1)} ${d.slice(1, 3)} ${d.slice(3, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
}
// v4.2 : 33XXXXXXXXX sans +
m = digits.match(/^33(\d{9})$/);
if (m) {
const d = m[1];
return `+33 ${d.slice(0, 1)} ${d.slice(1, 3)} ${d.slice(3, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
}
// 0XX XXX XX XX (fixe ou mobile CH, 10 chiffres commençant par 0)
m = digits.match(/^0(\d{9})$/);
if (m) {
const d = m[1];
return `0${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
}
// Numéro court interne (5 chiffres) : 78999, 68999, 88999, etc.
m = digits.match(/^(\d{5})$/);
if (m) {
return m[1]; // tel quel (déjà court et lisible)
}
// Fallback : retour brut
return digits;
}
/**
* Extrait le numéro de téléphone d'une chaîne contact.
* Accepte les préfixes : +41, +33, 07x, 02x, 03x (CH), 01-09 FR.
* Retourne un objet { name, phone } où phone est déjà formaté.
*/
function extractContactNameAndPhone(raw) {
if (!raw) return { name: null, phone: null };
const contacts = extractContacts(raw);
if (contacts.length === 0) return { name: null, phone: null };
// Pour compat avec l'ancien usage qui ne prend qu'1 contact
return contacts[0];
}
/**
* Extrait TOUS les contacts d'une chaîne (potentiellement plusieurs séparés
* par "ou", "/", des retours à la ligne, etc.).
* Retourne un tableau [{ name, phone }, { name, phone }, ...]
* Format d'entrée typique :
* "Nom1, Prénom1 +41000000001"
* "Nom1, Prénom1 +41000000001 ou Nom2, Prénom2 +41000000002"
* "Nom1, Prénom1 +41...\nNom2, Prénom2 +41..."
*/
function extractContacts(raw) {
if (!raw) return [];
let s = String(raw).trim();
// Virer les labels parasites (Nom utilisateur, etc.) qui traînent
s = s.replace(/\b(Nom utilisateur|Utilisateur)\s*:\s*[^\n]+/gi, "");
// v4.2.3 : séparer sur plus de délimiteurs pour gérer les cas type
// "Nom1 Prénom1 +41XXXXXXXXX et Nom2 Prénom2 0XXXXXXXXX"
// Délimiteurs acceptés :
// - " ou " / " et " / " and " (mots de liaison)
// - " / " suivi d'une majuscule (nouveau contact)
// - " ; " (point-virgule)
// - saut de ligne
// IMPORTANT : on ne touche PAS aux virgules (car "Nom, Prénom" en contient).
const parts = s.split(/\s+ou\s+|\s+et\s+|\s+and\s+|\s*;\s*|\n+|\s*\/\s*(?=[A-ZÉÈÀÂÎÔÛÇ])/i)
.map(p => p.trim())
.filter(Boolean);
const results = [];
for (const part of parts) {
const { name, phone } = splitOneContact(part);
if (name || phone) results.push({ name, phone });
}
return results;
}
/**
* Split UN seul bloc "Nom Prénom +41... [autres tels] [commentaires]" en
* { name, phone }.
*
* Stratégie robuste (v4.1.8) :
* - On cherche TOUS les numéros de téléphone (long ou court).
* - Le nom = ce qui précède le PREMIER numéro.
* - Le champ phone concatène les numéros trouvés (séparés par " / ").
* - Ce qui suit les numéros (commentaires "S'annoncer à la réception...",
* "téléphone à l'utilisateur") est JETÉ : ça ne fait pas partie du contact.
*
* Pattern numéro (inchangé, connu pour marcher) :
* Long : +41 / +33 / 0X suivi de 8+ caractères de [chiffres espaces . -]
* Court: 5 chiffres isolés (entre espaces, parenthèses, ou début/fin)
*/
function splitOneContact(raw) {
if (!raw) return { name: null, phone: null };
// v4.1.20 : regex plus permissives pour tolérer les erreurs humaines :
// - pas d'espace après le numéro (ex: "021555555Textecoller")
// - pas d'espace/parenthèse avant un court numéro
// LONG : +41 / +33 / 0X suivis de chiffres/espaces/points/tirets
// On ne limite plus par séparateur après — on laisse le moteur
// consommer le numéro le plus long possible (greedy) puis on
// s'arrête dès qu'on tombe sur un caractère non numérique.
// v4.2 : on accepte aussi le format "41XXXXXXXXX" sans + devant (fréquent
// quand EasyVista concatène "prefixe+tel" sans espace : Nom,
// Prénom 41XXXXXXXXX → extraire 41XXXXXXXXX puis reformater en
// +41 XX XXX XX XX). On exige exactement 11 chiffres collés pour
// éviter de matcher des codes postaux ou autres nombres.
const rxLong = /(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(?= 9) {
matches.push({ start: mm.index, end: mm.index + mm[1].length, tel: mm[1] });
}
}
while ((mm = rxShort.exec(raw)) !== null) {
// v4.2.3 : soit le 1er groupe (format avec espaces "7 68 43"), soit le
// 2e groupe (format collé "12345") a matché.
const rawTel = mm[1] || mm[2];
if (!rawTel) continue;
// On normalise en 5 chiffres sans séparateur
const shortTel = rawTel.replace(/[\s.\-]/g, "");
if (!/^\d{5}$/.test(shortTel)) continue;
const rawStart = mm.index + mm[0].indexOf(rawTel);
const rawEnd = rawStart + rawTel.length;
const overlaps = matches.some(x => rawStart < x.end && rawEnd > x.start);
if (!overlaps) {
matches.push({ start: rawStart, end: rawEnd, tel: shortTel });
}
}
matches.sort((a, b) => a.start - b.start);
let name = raw;
let phone = null;
if (matches.length > 0) {
name = raw.substring(0, matches[0].start).trim();
const tels = matches.map(x => formatPhone(x.tel)).filter(Boolean);
phone = tels.length > 0 ? tels.join(" / ") : null;
}
name = cleanContactName(name);
return { name, phone };
}
/**
* Nettoie le nom du contact :
* - retire tout ce qui est dans des parenthèses (...)
* - retire les éventuels "Nom utilisateur :" ou libellés
* - retire les virgules en trop en fin
* - v4.1.8 : tronque les commentaires parasites après le nom
* (ex: "Dupont, Jean S'annoncer à la réception" → "Dupont, Jean")
* - Conserve juste "Nom, Prénom" (ou "Nom Prénom" si pas de virgule)
*/
function cleanContactName(raw) {
if (!raw) return null;
let s = String(raw);
// Retirer parenthèses COMPLÈTES et leur contenu : (RH), (support)...
s = s.replace(/\s*\([^)]*\)\s*/g, " ");
// Retirer parenthèses non fermées en fin : "Bento, Joao (" → "Bento, Joao"
s = s.replace(/\s*\([^)]*$/g, " ");
// Retirer parenthèses non ouvertes en début : ")Bento" → "Bento"
s = s.replace(/^[^(]*\)\s*/g, "");
// Retirer tout caractère parenthèse isolé restant
s = s.replace(/[()]/g, " ");
// Retirer labels type "Nom utilisateur :", "Utilisateur :", "Bénéficiaire :"
s = s.replace(/\b(Nom utilisateur|Utilisateur|B[ée]n[ée]ficiaire)\s*:\s*[^\n,]*/gi, "");
// v4.1.20 : virer les commentaires parasites fréquents AVANT la logique
// des 4-mots (ils peuvent apparaître au tout début quand EasyVista n'a
// pas de nom saisi et commence directement par un commentaire).
// On détecte et coupe DÈS que ces expressions apparaissent.
// NOTE: on évite \b avant/après les caractères accentués (à, é) car
// \b est basé sur [a-zA-Z0-9_] et donne de faux négatifs.
const parasitePhrases = [
// Instructions d'appel (avec "à" ou "a")
/t[ée]l[ée]phone(?:r)?\s*[àa]\s*l[''`]?utilisateur/gi,
/t[ée]l[ée]phone(?:r)?\s*[àa](?:\s|$)/gi,
/t[ée]l[ée]phone(?:r)?\s*[àa]$/gi,
/\bappeler?\s+l[''`]?utilisateur\b/gi,
/\bappeler?\s+(?:le\s+)?b[ée]n[ée]ficiaire\b/gi,
/\bappeler?\s+la\s+personne\b/gi,
/\bappeler?\s+[àa]\s+/gi,
/\brappeler?\s+l[''`]?utilisateur\b/gi,
/\brappeler?\s+(?:le\s+)?b[ée]n[ée]ficiaire\b/gi,
// Instructions de présentation
/s[''`]annoncer?\s+[àa]\s+(?:la\s+r[ée]ception|l[''`]?accueil|.+?)(?=\.|,|$)/gi,
/\bse\s+pr[ée]senter\s+[àa]\s+.+?(?=\.|,|$)/gi,
// Autres
/\bbonjour\b/gi,
/\bmerci\b/gi,
// v4.1.20 : mots isolés qui restent parfois après les nettoyages ci-dessus
/\butilisateur\b/gi,
/\bb[ée]n[ée]ficiaire\b/gi
];
for (const rx of parasitePhrases) {
s = s.replace(rx, " ");
}
// Espaces multiples → un seul
s = s.replace(/\s{2,}/g, " ").trim();
// Ponctuation en bord
s = s.replace(/^[\s,;:.\-]+|[\s,;:.\-]+$/g, "").trim();
if (!s) return null;
// v4.1.8 : tronquer les commentaires parasites qui suivent le nom.
const words = s.split(/\s+/);
const keep = [];
for (let i = 0; i < words.length; i++) {
const w = words[i];
if (i === 0) { keep.push(w); continue; }
if (/^(de|da|du|van|von|le|la|del|di|der)$/i.test(w)) { keep.push(w); continue; }
if (keep.length >= 2 && /^[a-zéèêàâîôûç]/.test(w)) break;
if (keep.length >= 4) break;
keep.push(w);
}
s = keep.join(" ");
s = s.replace(/[\s,;:.\-]+$/, "").trim();
// v4.1.20 : dernier garde-fou : si le résultat final est juste un mot
// parasite (ex: "téléphone" tout seul, "appeler" tout seul), on retourne
// null plutôt qu'afficher un faux nom.
if (/^(t[ée]l[ée]phone|t[ée]l|appeler?|rappeler?|s[''`]?annoncer|bonjour|merci)$/i.test(s)) {
return null;
}
return s || null;
}
/**
* Split un lieu du type "Lausanne/Rue Caroline 9 bis" en
* { ville: "Lausanne", adresse: "Rue Caroline 9 bis" }
* Si format inconnu, retourne { ville: null, adresse: raw }.
*/
function splitLieu(raw) {
if (!raw) return { ville: null, adresse: null };
let s = String(raw).trim();
// Retirer un / final (avec ou sans espaces)
s = s.replace(/\s*\/\s*$/, "").trim();
if (!s) return { ville: null, adresse: null };
const idx = s.indexOf("/");
let ville, adresse;
if (idx < 0) {
ville = null;
adresse = s;
} else {
ville = s.substring(0, idx).trim();
adresse = s.substring(idx + 1).trim();
}
// Capitaliser la 1ère lettre des mots de voie (Rue, Chemin, Route, Avenue,
// Boulevard, Place, Quai, Impasse + abréviations Av., Ch., Rte, Bd)
if (adresse) {
adresse = adresse.replace(
/\b(rue|chemin|route|avenue|boulevard|place|quai|impasse|ruelle|allée|allee|passage|sentier|av\.?|ch\.?|rte\.?|bd\.?)\b/gi,
(match) => {
// Conserver la casse existante si déjà majuscule, sinon capitaliser
if (/^[A-ZÉÈÀÂÎÔÛÇ]/.test(match)) return match;
return match.charAt(0).toUpperCase() + match.slice(1).toLowerCase();
}
);
}
return { ville: ville || null, adresse: adresse || null };
}
/**
* Extrait la "signature planificateur" de la description d'action.
* Formats acceptés : "ECM 16.04", "JKF 17.04", "AWR 13/04/26", "ECM 16.04.2026".
* Parcourt d'abord les lignes depuis la fin (si la signature est sur sa ligne),
* sinon cherche à la fin de la description entière.
* Retourne null si rien trouvé.
*/
/**
* Normalise une date trouvée dans une signature :
* - "27/03" → "27.03"
* - "27.03" → "27.03"
* - "10/04/26" → "10.04" (on retire l'année)
* - "13/04/2026" → "13.04"
*/
function normalizeSignatureDate(date) {
if (!date) return "";
// Prendre les 2 premiers blocs de chiffres (JJ et MM) et les joindre avec "."
const parts = String(date).split(/[./]/);
if (parts.length < 2) return date;
const dd = parts[0].padStart(2, "0");
const mm = parts[1].padStart(2, "0");
return `${dd}.${mm}`;
}
function extractPlanifSignature(actionText) {
if (!actionText) return null;
// Formater le texte d'abord pour avoir des lignes séparées
const text = formatActionTextMultiline(String(actionText)).trim();
// 1. Dernière ligne non vide : regarder si c'est une signature (avec ou sans date)
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
if (lines.length > 0) {
const last = lines[lines.length - 1];
// 1a. Lettres (majuscules OU minuscules) + date
// Ex: "FRD 07/04", "csh 27.03", "AWR 13/04/26", "JKF 17.04"
const mFull = last.match(/^([A-Za-z]{2,4})\s+(\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)$/);
if (mFull) {
return `${mFull[1].toUpperCase()} ${normalizeSignatureDate(mFull[2])}`;
}
// 1b. Juste les lettres seules (JKF, NDV) sur leur propre ligne
const mSolo = last.match(/^([A-Za-z]{2,4})$/);
if (mSolo) return mSolo[1].toUpperCase();
}
// 2. Sinon chercher la dernière signature "lettres + date" collée en fin
let lastMatch = null;
let m;
const rxGlobal = /([A-Za-z]{2,4})\s+(\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)/g;
while ((m = rxGlobal.exec(text)) !== null) {
lastMatch = { sigs: m[1], date: m[2], pos: m.index };
}
if (lastMatch && lastMatch.pos >= text.length - 100) {
return `${lastMatch.sigs.toUpperCase()} ${normalizeSignatureDate(lastMatch.date)}`;
}
return null;
}
// v4.1.1 : shortMeta() et buildMetaDom() supprimées (code mort, héritage v1).
// Le rendu actuel utilise renderLieuContactBlocks() + buildInterventionRow().
async function copyRef(ref, btn) {
if (!ref) return;
try {
await navigator.clipboard.writeText(ref);
btn.classList.add("copied");
btn.textContent = "✓";
setTimeout(() => { btn.classList.remove("copied"); btn.textContent = "📋"; }, 1200);
} catch {
alert("Référence : " + ref);
}
}
// ─── Rendu incrémental (v3.1) ───────────────────────────────────────────────
// Met à jour UNE ligne d'intervention dans le DOM (après qu'un fetch fiche
// ait enrichi l'objet iv avec sa ref, son statut, etc.). Appelée par
// fetchAndUpdateIntervention pour afficher la ref dès qu'elle arrive, sans
// attendre que tous les workers aient fini ni re-rendre toute la vue.
//
// Doit rester en phase avec la structure DOM construite par
// buildInterventionRow (classes iv-ref-header, iv-status-check,
// intervention-copy, intervention-dot, timeline-slot...).
const ALL_COLOR_CLASSES = [
"color-livraison", "color-installation", "color-recup",
"color-remplacement", "color-incident", "color-rollout",
"color-reservation", "color-autre"
];
/**
* (Re)génère les blocs Lieu et Contact(s) dans le conteneur .iv-right.
* Supprime d'abord les anciens blocs (.iv-lieu-block + .iv-contact-line),
* puis insère les nouveaux AVANT le bloc .iv-bottom-line (si présent) pour
* conserver l'ordre d'affichage. Utilisé à la création ET lors de la
* mise à jour après fetch de la fiche.
*/
function renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, contactAnomalie) {
// Supprime les anciens blocs lieu/contact
rightCol.querySelectorAll(".iv-lieu-block, .iv-contact-line").forEach(el => el.remove());
const contacts = extractContacts(contactRaw);
const { ville, adresse } = splitLieu(lieuRaw);
// Point d'insertion : avant .iv-bottom-line (catégorie + signature), sinon à la fin
const anchor = rightCol.querySelector(".iv-bottom-line");
const insert = (el) => {
if (anchor) rightCol.insertBefore(el, anchor);
else rightCol.appendChild(el);
};
// ── Lieu : ville (MAJUSCULES GRAS) puis adresse (italique noir) ──────────
if (ville || adresse) {
const lieuBlock = document.createElement("div");
lieuBlock.className = "iv-lieu-block";
if (ville) {
const villeEl = document.createElement("div");
villeEl.className = "iv-lieu-ville";
villeEl.textContent = ville.toUpperCase();
lieuBlock.appendChild(villeEl);
}
if (adresse) {
const addrEl = document.createElement("div");
addrEl.className = "iv-lieu-adresse";
addrEl.textContent = adresse;
lieuBlock.appendChild(addrEl);
}
insert(lieuBlock);
}
// ── Contact(s) + téléphone — un par ligne si plusieurs ──────────────────
for (const c of contacts) {
if (!c.name && !c.phone) continue;
const contactEl = document.createElement("div");
contactEl.className = "iv-contact-line";
// v4.2 : si anomalie (les 2 champs Contact + Personne de contact existent
// dans l'action), afficher en rouge pour signaler à l'user de vérifier.
if (contactAnomalie) contactEl.classList.add("iv-contact-anomalie");
if (c.name) {
const nameSpan = document.createElement("span");
nameSpan.className = "iv-contact";
nameSpan.textContent = c.name;
contactEl.appendChild(nameSpan);
}
if (c.phone) {
if (c.name) {
const sep = document.createElement("span");
sep.className = "iv-sep";
sep.textContent = " | ";
contactEl.appendChild(sep);
}
const phoneSpan = document.createElement("span");
phoneSpan.className = "iv-phone";
phoneSpan.textContent = c.phone;
contactEl.appendChild(phoneSpan);
}
insert(contactEl);
}
}
function updateInterventionRow(iv) {
// Réservations : pas concerné (pas de fetch fiche pour elles)
if (iv.type === "AL-Reservation") return;
const row = document.querySelector(
`.intervention-v2[data-action-id="${iv.actionId}"]`
);
if (!row) return;
// Classes de statut sur la ligne
const sc = getStatusClass(iv);
row.classList.remove("status-closed", "status-resolved");
if (sc) row.classList.add(sc);
// Classe de couleur sur la ligne (la pastille hérite via CSS)
const colorKey = deriveColorKey(iv);
row.classList.remove(...ALL_COLOR_CLASSES);
row.classList.add("color-" + colorKey);
// Ref (le titre gros en haut de la ligne)
const refEl = row.querySelector(".iv-ref-header");
if (refEl) {
if (iv.ref) {
refEl.textContent = iv.ref;
refEl.classList.remove("no-ref");
} else {
refEl.textContent = "—";
refEl.classList.add("no-ref");
}
}
// Check ✓ : ajouter/retirer selon statut
let checkEl = row.querySelector(".iv-status-check");
if (sc && !checkEl) {
checkEl = document.createElement("div");
checkEl.className = "iv-status-check";
checkEl.textContent = "✓";
// Insérer après la ref (avant le bouton copier s'il existe)
const copy = row.querySelector(".intervention-copy");
if (copy) row.insertBefore(checkEl, copy);
else if (refEl && refEl.nextSibling) row.insertBefore(checkEl, refEl.nextSibling);
else row.appendChild(checkEl);
} else if (!sc && checkEl) {
checkEl.remove();
}
// Bouton 📋 copier : ajouter si on a maintenant une ref et qu'il n'existe pas
let copyBtn = row.querySelector(".intervention-copy");
if (iv.ref && !copyBtn) {
copyBtn = document.createElement("button");
copyBtn.className = "intervention-copy";
copyBtn.type = "button";
copyBtn.title = "Copier la référence";
copyBtn.innerHTML = "📋";
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
copyRef(iv.ref, copyBtn);
});
row.appendChild(copyBtn);
}
// Catégorie affichée en bas (dépend de la ref pour Incident, etc.)
const catEl = row.querySelector(".iv-category");
if (catEl) catEl.textContent = deriveShortTitle(iv);
// v4.1.8 : signature planificateur (XXX JJ.MM). Si le texte fiche (complet)
// est arrivé, il peut maintenant fournir une signature que le xhr2 tronqué
// n'avait pas. On met à jour le span .iv-signature en conséquence.
const bottomEl = row.querySelector(".iv-bottom-line");
if (bottomEl) {
let sigEl = bottomEl.querySelector(".iv-signature");
const sig = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription);
if (sig) {
if (!sigEl) {
sigEl = document.createElement("span");
sigEl.className = "iv-signature";
bottomEl.appendChild(sigEl);
}
sigEl.textContent = sig;
} else if (sigEl) {
sigEl.remove();
}
}
// v4.1.2 : régénérer les blocs lieu/contact depuis les valeurs actuelles.
// Priorité à iv.infobulle (xhr2 lazy, vraies infos) puis attr1/attr2 (planif).
const rightCol = row.querySelector(".iv-right");
if (rightCol) {
const info = iv.infobulle || {};
const contactRaw = info.contact || iv.bulleContact || null;
const lieuRaw = info.lieu || iv.bulleLieu || null;
renderLieuContactBlocks(rightCol, lieuRaw, contactRaw, info.contactAnomalie);
}
// Segment timeline correspondant : même couleur + même classe statut
const card = row.closest(".card");
if (card && row.dataset.ivIdx !== undefined) {
const slot = card.querySelector(
`.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]`
);
if (slot) {
slot.classList.remove("status-closed", "status-resolved", ...ALL_COLOR_CLASSES);
slot.classList.add("color-" + colorKey);
if (sc) slot.classList.add(sc);
// Maj du dataset pour le popover (titre + ref)
slot.dataset.title = deriveShortTitle(iv);
if (iv.ref) slot.dataset.ref = iv.ref;
}
}
}
// ============================================================================
// Tooltip
// ============================================================================
const tooltipEl = () => document.getElementById("tooltip");
// v4.1.10 : état persistant de la bulle
// - pinned : une fois épinglée (double Ctrl), la bulle reste à sa position,
// ne suit plus la souris, et ne se ferme ni au mouseleave ni au
// mouseleave suivant. On peut sélectionner le texte dedans.
// Clic hors bulle (ailleurs que sur une autre intervention) ou
// nouveau double-Ctrl → désépingle.
// - hoveredInBulle : si la souris entre DANS la bulle elle-même, la bulle
// reste visible même si elle n'est pas épinglée. Elle ne
// disparaît que quand la souris sort à la fois de la carte ET
// de la bulle.
let bulleState = {
pinned: false,
hoveredInBulle: false,
hoveredInRow: false,
hideTimer: null
};
function showTooltip(e, iv, rowEl) {
// v4.1.15 : si la bulle est épinglée sur une autre iv, on NE REMPLACE PAS
// son contenu (l'user veut garder la fiche épinglée même en survolant
// d'autres cartes).
if (bulleState.pinned && state.currentTooltipIv && state.currentTooltipIv !== iv) {
return;
}
const el = tooltipEl();
el.innerHTML = buildTooltipHTML(iv);
el.classList.remove("hidden");
el.classList.add("visible");
// Conserver le pinned si on revient sur la même iv
if (bulleState.pinned && state.currentTooltipIv === iv) {
el.classList.add("pinned");
} else {
el.classList.remove("pinned");
}
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
}
bulleState.hoveredInRow = true;
// v4.1.12 : positionner la bulle UNE SEULE FOIS au mouseenter, près de la
// carte (row) et pas du curseur. Elle ne bouge plus pendant le survol.
// v4.1.15 : si pinned, NE PAS repositionner (la bulle doit rester fixe).
if (!bulleState.pinned) {
positionTooltipAnchored(rowEl || (e && e.currentTarget));
}
// v4 : lazy-load du texte complet de l'action au premier hover.
// Sans await : on affiche le tooltip IMMÉDIATEMENT avec ce qu'on a (lieu,
// contact, catégorie, ref venant du XML) ; quand le xhr2 arrive (50-200 ms
// plus tard typiquement), on régénère le tooltip s'il est encore visible.
if (iv && iv.type === "AL-Intervention" && !iv.xhr2Fetched && !iv.xhr2Fetching) {
ensureBulleDescription(iv).then(ok => {
// Si ça a marché ET que le tooltip est toujours visible sur CETTE iv,
// on régénère le HTML pour afficher les détails Problème/À faire/Matériel.
if (!ok) return;
const tip = tooltipEl();
if (!tip.classList.contains("visible")) return;
// Vérifie qu'on affiche toujours la même intervention (pas un autre hover
// intervenu entretemps)
if (state.currentTooltipIv === iv) {
tip.innerHTML = buildTooltipHTML(iv);
}
});
}
// Mémoriser quelle iv est actuellement affichée (utilisé pour éviter
// d'écraser un tooltip différent si un autre hover s'est produit entretemps)
state.currentTooltipIv = iv;
}
function hideTooltip(opts = {}) {
// Si la bulle est épinglée, on ignore (sauf force: true = unpin explicite)
if (bulleState.pinned && !opts.force) return;
bulleState.hoveredInRow = false;
// Petit délai : laisse le temps à la souris d'ENTRER dans la bulle elle-même
// (si l'user veut sélectionner du texte). On annule la fermeture si
// hoveredInBulle passe à true entre-temps.
if (bulleState.hideTimer) clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = setTimeout(() => {
if (bulleState.hoveredInBulle || bulleState.hoveredInRow) return;
if (bulleState.pinned) return;
// v4.2 : si l'utilisateur a une sélection de texte ACTIVE dans la bulle,
// on ne ferme pas (sinon la sélection disparaît avant d'avoir pu copier).
if (!opts.force && hasTextSelectionInTooltip()) return;
const el = tooltipEl();
el.classList.remove("visible", "pinned");
el.classList.add("hidden");
state.currentTooltipIv = null;
currentTooltipPos = null;
}, 120);
}
// v4.2 : détecte si l'utilisateur a une sélection de texte active dans la bulle.
// Utilisé pour empêcher la fermeture automatique tant qu'on n'a pas fini de
// sélectionner/copier.
function hasTextSelectionInTooltip() {
try {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return false;
const tip = tooltipEl();
if (!tip) return false;
const range = sel.getRangeAt(0);
// La sélection est dans la bulle si au moins un endpoint y est
return tip.contains(range.startContainer) || tip.contains(range.endContainer);
} catch {
return false;
}
}
function moveTooltip(e) {
// v4.1.12 : la bulle est FIXE (positionnée une fois au mouseenter). Cette
// fonction est conservée pour compat mais ne fait plus rien.
}
// v4.1.12 : positionnement fixe de la bulle, ancrée par rapport à la ligne
// (rowEl). Par défaut à droite de la ligne, avec fallback à gauche si pas
// assez de place, et ajustement vertical pour rester dans la fenêtre.
// v4.1.17 : position actuelle de la bulle dans le viewport. On la mémorise
// pour pouvoir la ré-appliquer à chaque scroll (au cas où un ancêtre
// casse position:fixed sans qu'on s'en rende compte).
let currentTooltipPos = null;
function positionTooltipAnchored(rowEl) {
const el = tooltipEl();
if (!rowEl || !el) return;
const pad = 14;
const rowRect = rowEl.getBoundingClientRect();
const tipRect = el.getBoundingClientRect();
// Position X : à droite de la ligne par défaut
let x = rowRect.right + pad;
if (x + tipRect.width > window.innerWidth - 8) {
x = rowRect.left - tipRect.width - pad;
}
if (x < 4) x = 4;
// Position Y : aligné en haut de la ligne
let y = rowRect.top;
if (y + tipRect.height > window.innerHeight - 8) {
y = window.innerHeight - tipRect.height - 8;
}
if (y < 4) y = 4;
currentTooltipPos = { x, y };
el.style.left = x + "px";
el.style.top = y + "px";
}
// v4.1.17 : ré-applique la position de la bulle au scroll. Safety net au
// cas où un ancêtre casse position:fixed. Marche peu importe la cause.
function reapplyTooltipPosition() {
if (!currentTooltipPos) return;
const el = tooltipEl();
if (!el || !el.classList.contains("visible")) return;
el.style.left = currentTooltipPos.x + "px";
el.style.top = currentTooltipPos.y + "px";
}
// v4.1.10 : pin/unpin la bulle. Quand pin, on ajoute la classe CSS "pinned"
// qui change le curseur (text) et autorise la sélection.
function pinTooltip() {
if (!state.currentTooltipIv) return;
bulleState.pinned = true;
const el = tooltipEl();
el.classList.add("pinned");
}
// v4.1.14/19 : recharger UNIQUEMENT cette intervention. Fetch DIRECT sans
// passer par isRefreshAborted (pour ne pas être bloqué par un abort global
// ou un refresh précédent). Animation sur le bouton ↻ de la bulle.
async function reloadSingleIntervention(iv, btnEl) {
if (!iv || iv.type === "AL-Reservation") return;
// Empêcher double-clic en cours
if (iv._reloading) return;
iv._reloading = true;
// v4.1.19 : NE PAS reset les champs AVANT le fetch (sinon si le fetch
// échoue ou est interrompu, on perd les données précédentes). On les
// mettra à jour uniquement si le fetch réussit.
const previousState = {
xhr2Fetched: iv.xhr2Fetched,
ficheFetched: iv.ficheFetched,
ficheActionText: iv.ficheActionText,
ficheFetchError: iv.ficheFetchError,
bulleDescription: iv.bulleDescription,
infobulle: iv.infobulle,
status: iv.status,
label: iv.label,
ficheChecksum: iv.ficheChecksum,
ficheTarget: iv.ficheTarget,
formSenderGuid: iv.formSenderGuid
};
// Marquer le bouton ↻ comme en cours (visuel immédiat)
if (btnEl) btnEl.classList.add("spinning");
// v4.1.19 : toast de feedback en bas à droite
showToast("Rafraîchissement", iv.ref || iv.actionId);
try {
// ─── xhr2 (rapide) ─────────────────────────────────────────────────
try {
const xhr2Resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId });
if (xhr2Resp && xhr2Resp.ok) {
const parsed = parseXhr2Body(xhr2Resp.body);
if (parsed) {
if (parsed.description) {
iv.bulleDescription = parsed.description;
const infob = parseActionText(parsed.description);
if (infob) iv.infobulle = infob;
}
if (parsed.label) iv.label = parsed.label;
iv.xhr2Fetched = true;
}
}
} catch (err) {
console.warn("[reloadSingle/xhr2] iv", iv.actionId, err);
}
// ─── fiche HTML ────────────────────────────────────────────────────
const ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
if (ficheResp.ok) {
const fiche = parseFicheHtml(ficheResp.html);
iv.status = fiche.status;
if (fiche.rfc && !iv.ref) iv.ref = fiche.rfc;
if (fiche.formSenderGuid) iv.formSenderGuid = fiche.formSenderGuid;
// ─── timeline API : texte complet ──────────────────────────────
if (fiche.formId && fiche.formChecksum && fiche.formSenderGuid && iv.actionId) {
try {
const tlResp = await sendMessage({
type: "fetchTimelineApi",
guid: fiche.formSenderGuid,
formId: fiche.formId,
formChecksum: fiche.formChecksum
});
if (tlResp && tlResp.ok) {
const fullText = parseTimelineJsonForAction(tlResp.body, iv.actionId);
if (fullText) iv.ficheActionText = fullText;
}
} catch (err) {
console.warn("[reloadSingle/timeline] iv", iv.actionId, err);
}
}
// ─── Extraire checksum pour ouverture ───────────────────────────
if (iv.requestId && !iv.ficheChecksum) {
const rx1 = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`);
const m1 = ficheResp.html.match(rx1);
if (m1) {
iv.ficheTarget = iv.requestId;
iv.ficheChecksum = m1[1];
}
}
iv.ficheFetched = true;
iv.ficheFetchError = null;
} else {
iv.ficheFetchError = ficheResp.error || "fetch_failed";
if (ficheResp.error === "session_expired") {
state.session = null;
showSessionExpiredBanner();
}
}
// Mettre à jour la carte (statut clos → ✓ vert, catégorie, etc.)
updateInterventionRow(iv);
// Si la bulle est toujours ouverte sur cette iv, régénérer son HTML
const tip = tooltipEl();
if (tip.classList.contains("visible") && state.currentTooltipIv === iv) {
tip.innerHTML = buildTooltipHTML(iv);
}
// Sauvegarder le cache
try {
const cached = await readCache(state.currentDate);
if (cached && cached.techs) {
for (const tech of cached.techs) {
for (let i = 0; i < (tech.interventions || []).length; i++) {
if (tech.interventions[i].actionId === iv.actionId) {
tech.interventions[i] = iv;
}
}
}
await writeCache(state.currentDate, { techs: cached.techs });
}
} catch (err) {
console.warn("[reloadSingle/cache]", err);
}
// v4.1.19 : toast de succès
showToast("Mis à jour", iv.ref || iv.actionId);
} catch (err) {
console.warn("[reloadSingle] erreur iv", iv.actionId, err);
// Restaurer l'état précédent en cas d'erreur globale
Object.assign(iv, previousState);
} finally {
iv._reloading = false;
if (btnEl) btnEl.classList.remove("spinning");
}
}
function unpinTooltip() {
bulleState.pinned = false;
const el = tooltipEl();
el.classList.remove("pinned");
// v4.1.13 : test immédiat si la souris est toujours dans la bulle ou sur
// la ligne. Si ni l'un ni l'autre, on ferme tout de suite (sans timer).
if (!bulleState.hoveredInBulle && !bulleState.hoveredInRow) {
el.classList.remove("visible");
el.classList.add("hidden");
state.currentTooltipIv = null;
currentTooltipPos = null;
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
}
}
// Sinon : la bulle reste visible, et c'est le mouseleave qui la fermera
// normalement quand la souris sortira.
}
// v4.1.10 : interactions bulle (double-Ctrl pour pin/unpin, hover dans la
// bulle pour persistance, clic hors pour unpin).
function bindTooltipInteractions() {
const el = tooltipEl();
if (!el) return;
// v4.1.17 : ré-applique la position au scroll de la page (safety net
// contre un ancêtre qui casserait position:fixed silencieusement).
window.addEventListener("scroll", reapplyTooltipPosition, { passive: true });
window.addEventListener("resize", () => {
// Au resize, on laisse fermer la bulle (position probablement invalidée)
if (bulleState.pinned) return;
hideTooltip({ force: true });
});
// v4.1.17 : bloquer le scroll de la page quand la souris est DANS la
// bulle. Le scroll interne de la bulle (overflow-y auto) reste OK.
// On utilise "wheel" non-passif pour pouvoir preventDefault.
el.addEventListener("wheel", (e) => {
// Si la bulle a un scroll interne et n'est pas à la limite, laisser
// le scroll naturel se faire. Sinon, bloquer le scroll global.
const canScrollDown = el.scrollTop + el.clientHeight < el.scrollHeight;
const canScrollUp = el.scrollTop > 0;
if ((e.deltaY > 0 && !canScrollDown) || (e.deltaY < 0 && !canScrollUp)) {
e.preventDefault();
}
// Ne pas laisser le scroll se propager au body
e.stopPropagation();
}, { passive: false });
// Hover sur la bulle elle-même : empêche la fermeture
el.addEventListener("mouseenter", () => {
bulleState.hoveredInBulle = true;
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
}
});
el.addEventListener("mouseleave", () => {
bulleState.hoveredInBulle = false;
if (!bulleState.hoveredInRow && !bulleState.pinned) {
hideTooltip();
}
});
// Double-Ctrl : pin/unpin
// On détecte 2 keydown Control dans une fenêtre de 400 ms.
let lastCtrlTs = 0;
document.addEventListener("keydown", (e) => {
if (e.key !== "Control") return;
// Ignorer si la touche est répétée (hold)
if (e.repeat) return;
const now = performance.now();
if (now - lastCtrlTs < 400) {
// Double-Ctrl détecté
lastCtrlTs = 0;
if (bulleState.pinned) {
unpinTooltip();
} else if (state.currentTooltipIv) {
pinTooltip();
}
} else {
lastCtrlTs = now;
}
});
// v4.1.13 : clic sur le bouton 📌 ou ↻ (bouton d'action de la bulle)
el.addEventListener("click", (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
e.stopPropagation();
e.preventDefault();
const action = btn.dataset.action;
if (action === "pin") {
if (bulleState.pinned) {
unpinTooltip();
} else if (state.currentTooltipIv) {
pinTooltip();
}
} else if (action === "reload") {
// v4.1.14 : recharger uniquement l'intervention actuellement affichée
if (state.currentTooltipIv) {
reloadSingleIntervention(state.currentTooltipIv, btn);
}
} else if (action === "copy-ref") {
// v4.1.15 : copier la référence depuis la bulle
const ref = btn.dataset.ref;
if (ref) {
navigator.clipboard.writeText(ref).then(() => {
btn.classList.add("copied");
const original = btn.textContent;
btn.textContent = "✓";
setTimeout(() => {
btn.classList.remove("copied");
btn.textContent = original;
}, 1200);
}).catch(() => {});
}
}
});
// Clic hors bulle : unpin si épinglé.
// Attention : ne pas déclencher sur clic DANS la bulle (elle contient du
// texte sélectionnable), ni sur clic sur une intervention (qui ouvre la
// fiche — le user n'attend pas que la bulle reste épinglée dans ce cas
// mais le comportement "ouvrir la fiche" reste prioritaire).
document.addEventListener("mousedown", (e) => {
if (!bulleState.pinned) return;
// Clic dans la bulle → on laisse (sélection de texte)
if (el.contains(e.target)) return;
// Dans tous les autres cas (y compris clic sur une autre intervention),
// on désépingle. Si c'était un clic sur intervention, le handler
// d'ouverture de la fiche s'exécutera ensuite normalement.
unpinTooltip();
});
}
function buildTooltipHTML(iv) {
const i = iv.infobulle || {};
const rows = [];
// Cas spécial : réservation (créneau bloqué par un coordinateur)
if (iv.type === "AL-Reservation") {
rows.push(`
Type
Réservation
`);
if (iv.startTime && iv.endTime) {
rows.push(row("Horaire", `${iv.startTime}–${iv.endTime}`));
}
if (iv.reservationLabel) rows.push(row("Sujet", iv.reservationLabel));
if (iv.reservationCreator) rows.push(row("Par", iv.reservationCreator));
return `
${rows.join("")}
`;
}
// Statut en premier (si connu)
if (iv.status) {
let cls = "other";
if (isClosedStatus(iv.status)) cls = "closed";
else if (isResolvedStatus(iv.status)) cls = "resolved";
else if (/en cours|ex[ée]cution/i.test(iv.status)) cls = "ongoing";
rows.push(`
Statut
${escapeHtml(iv.status)}
`);
}
if (iv.startTime && iv.endTime) {
rows.push(row("Horaire", `${iv.startTime}–${iv.endTime}`));
}
// ─── Texte d'action : fiche (complet) en priorité, sinon xhr2 (tronqué) ──
// v4.1.8 : un seul bloc "Action" qui s'enrichit automatiquement. Au début,
// le xhr2 tronqué s'affiche ; dès que le fetch timeline est revenu,
// iv.ficheActionText remplace le texte dans le même bloc.
const actionText = iv.ficheActionText ||
(iv.bulleDescription ? formatActionTextMultiline(iv.bulleDescription) : null);
if (actionText) {
const htmlAction = escapeHtml(actionText).replace(/\n/g, " ");
rows.push(`
Action
${htmlAction}
`);
} else {
// Si pas de description (même pas de xhr2), afficher les infos structurées qu'on a
const hasAction = !!(i.date || i.heure || i.lieu || i.contact || i.service ||
i.probleme || i.aFaire || i.materiel);
if (i.date || i.heure) {
const dh = [i.date, i.heure].filter(Boolean).join(" · ");
if (dh) rows.push(row("Quand", dh));
}
const contact = i.contact || iv.bulleContact;
if (contact) rows.push(row("Contact", contact));
const lieu = i.lieu || iv.bulleLieu;
if (lieu) rows.push(row("Lieu", lieu));
if (i.service) rows.push(row("Service", i.service));
if (i.probleme) rows.push(row("Problème", i.probleme));
if (i.aFaire) rows.push(row("À faire", i.aFaire));
if (!hasAction && !contact && !lieu) {
if (iv.ficheFetched) {
rows.push(`
Info
Aucun détail pour cette intervention.
`);
} else {
rows.push(`
Info
Chargement des détails…
`);
}
}
}
// Deadline (si connue et différente)
if (iv.deadline && !i.date) rows.push(row("Deadline", iv.deadline));
if (iv.ref) {
rows.push(``);
// v4.1.15 : ref avec bouton copier inline
const refSafe = escapeHtml(iv.ref);
rows.push(`
Référence
${refSafe}
`);
}
if (iv.ghost) {
rows.push(``);
rows.push(`
⚠
Intervention disparue d'EasyVista (clôturée, déplacée ou annulée)
`);
} else if (iv.formLink) {
rows.push(``);
rows.push(`
Cliquer pour ouvrir la fiche
`);
}
if (rows.length === 0) {
return `
📌
Info
Aucun détail disponible
`;
}
// v4.1.13/14 : boutons d'action en haut à droite (recharger + épingler)
return `
📌
${rows.join("")}
`;
}
/**
* Met en forme un texte d'action EasyVista en ajoutant des retours à la ligne
* avant chaque étiquette connue ("Date :", "Lieu :", "Contact :", etc.).
* Transforme :
* "Date : 20.04 Heure : MatinLieu : Ville1/Rue1 1 bisContact : Nom..."
* En :
* "Date : 20.04 Heure : Matin
* Lieu : Ville1/Rue1 1 bis
* Contact : Nom..."
*/
function formatActionTextMultiline(text) {
if (!text) return "";
const newlineLabels = [
"Lieu", "Contact",
"Service", "Étage", "Bureau",
"Nom utilisateur",
"Problème", "A faire", "À faire",
"Matériel", "Materiel",
"Bénéficiaire", "Beneficiaire"
];
let result = String(text);
for (const label of newlineLabels) {
const rx = new RegExp(`([^\\n])(${escapeRegex(label)}\\s*:\\s*)`, "g");
result = result.replace(rx, "$1\n$2");
}
// Isoler la signature planificateur finale ("ECM 16.04", "csh 27.03", etc.)
// qui se trouve typiquement en fin sans préfixe de label.
// On utilise un look-behind pour ne PAS manger la lettre précédente
// (et donc ne pas couper le "F" de "FRD 07/04").
result = result.replace(/(?<=[^\n])(\s*)([A-Za-z]{2,4}\s+\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)\s*$/, "\n$2");
// Nettoyer
result = result.replace(/\n{2,}/g, "\n").trim();
return result;
}
function escapeRegex(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function row(label, value) {
return `
${escapeHtml(label)}
${escapeHtml(value)}
`;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function highlightIntervention(cardEl, ivIdx, on) {
const row = cardEl.querySelector(`.intervention[data-iv-idx="${ivIdx}"]`);
const slot = cardEl.querySelector(`.timeline-slot[data-iv-idx="${ivIdx}"]`);
if (row) row.classList.toggle("highlight", on);
if (slot) slot.classList.toggle("highlight", on);
}
// ============================================================================
// Helpers temps
// ============================================================================
function timeToMinutes(hhmm) {
if (!hhmm) return null;
const m = hhmm.match(/^(\d{1,2}):(\d{2})$/);
if (!m) return null;
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
}
function minutesToTime(mins) {
const h = Math.floor(mins / 60);
const m = mins % 60;
return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
}
// ============================================================================
// Écrans d'erreur
// ============================================================================
function showLoading() {
document.getElementById("loading").classList.remove("hidden");
document.getElementById("error-box").classList.add("hidden");
document.getElementById("session-needed").classList.add("hidden");
document.getElementById("stats").classList.add("hidden");
document.getElementById("cards").innerHTML = "";
}
function showError(msg) {
document.getElementById("loading").classList.add("hidden");
document.getElementById("stats").classList.add("hidden");
document.getElementById("session-needed").classList.add("hidden");
const evUnr = document.getElementById("ev-unreachable");
if (evUnr) evUnr.classList.add("hidden");
document.getElementById("cards").innerHTML = "";
const box = document.getElementById("error-box");
box.textContent = msg;
box.classList.remove("hidden");
}
function showSessionNeeded() {
document.getElementById("loading").classList.add("hidden");
document.getElementById("error-box").classList.add("hidden");
document.getElementById("stats").classList.add("hidden");
const evUnr = document.getElementById("ev-unreachable");
if (evUnr) evUnr.classList.add("hidden");
document.getElementById("cards").innerHTML = "";
document.getElementById("session-needed").classList.remove("hidden");
}
function hideSessionNeeded() {
document.getElementById("session-needed").classList.add("hidden");
}
// v4.2 : écran plein "EasyVista inaccessible" (différent de session expirée).
function showEvUnreachable() {
document.getElementById("loading").classList.add("hidden");
document.getElementById("error-box").classList.add("hidden");
document.getElementById("stats").classList.add("hidden");
document.getElementById("session-needed").classList.add("hidden");
document.getElementById("cards").innerHTML = "";
const el = document.getElementById("ev-unreachable");
if (el) el.classList.remove("hidden");
}
function hideEvUnreachable() {
const el = document.getElementById("ev-unreachable");
if (el) el.classList.add("hidden");
}
// v4.1.12 : bannière non bloquante "session expirée". Affichée quand le
// fetch détecte une session morte EN COURS DE ROUTE (pas au démarrage).
// L'utilisateur voit toujours les données déjà chargées, mais est prévenu
// que les mises à jour sont arrêtées.
function showSessionExpiredBanner() {
const b = document.getElementById("session-expired-banner");
if (b) b.classList.remove("hidden");
}
function hideSessionExpiredBanner() {
const b = document.getElementById("session-expired-banner");
if (b) b.classList.add("hidden");
}