// ============================================================================ // viewer.js — vue claire du planning techniciens // ============================================================================ // Idée de base : on récupère tout depuis le XML EasyVista (calendar_block) en // 1 seule requête. attr1/attr2/attr3 + textContent contiennent déjà ref, // contact, lieu, catégorie, formLink, deadline. Plus besoin de faire 74 // requêtes xhr2 au chargement comme la v3. Le texte complet de l'action // (Problème / À faire / Matériel) est lazy-load au hover, seulement si // l'user survole la ligne. // // Fetch des fiches : séquentiel (1 par 1) au lieu d'en paralléliser. Le // serveur EasyVista sérialise de toute façon, et ça rend l'abort instantané // si l'user change de date en cours. // Le cache est écrit toutes les 5 fiches (incrémental), pas juste à la fin. // Comme ça si l'user change de date au milieu, ce qu'on a déjà fetché est // pas perdu. // ============================================================================ // ============================================================================ // 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.type === "AL-Absence") return "absence"; // v5.0.15 : couleur noire/gris foncé 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, // v5.0.9 : timestamp (ms) auquel la session EV va expirer. sessionExpireAt: null, // v5.0.9 : true pendant une reconnexion en cours reconnecting: false, // v5.0.9 : true si la session est expirée (bannière rouge affichée) sessionExpired: false, // v5.0.9 : true si on a déjà fait le ping de confirmation < 5 min sessionPingDone: false, // v5.0.10 : dernière origine EV connue comme fonctionnelle (itsma.vd.ch // ou itsma.etat-de-vaud.ch selon qu'on est en externe ou interne). // Conservée même quand state.session est null, pour savoir où rediriger // lors de la reconnexion. lastKnownOrigin: null, // v5.0.11 : contexte réseau détecté ("internal" ou "external" ou null). // Détecté automatiquement au démarrage par un HEAD test sur l'URL interne. networkContext: null, // v5.0.11 : timer (setTimeout id) pour le timeout de reconnexion 90 sec reconnectTimeoutId: null }; // v5.0.9 : constantes session const SESSION_DURATION_MS = 30 * 60 * 1000; // 30 min const SESSION_WARN_THRESHOLD_MS = 5 * 60 * 1000; // 5 min → affichage compteur const SESSION_CRITICAL_THRESHOLD_MS = 2 * 60 * 1000; // 2 min → rouge + modal // v5.0.11 : timeout de la reconnexion. Si l'user n'est pas reconnecté // dans ce délai, on bascule en état "Reconnexion échouée" avec choix du réseau. const RECONNECT_TIMEOUT_MS = 90 * 1000; // 90 sec // ─── 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(); initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal initAppFooter(); // v4.2.9 : pied de page discret bas-droite initAppClock(); // v5.0.0 : horloge HH:MM au milieu topbar initAdminMenu(); // v5.0.0 : menu admin caché (5 clics sur titre) initSessionTimer(); // v5.0.9 : compteur de session EV (tick 1s) initDateCustomPicker(); // v2026.5.17 : faux input date avec jour // Initialiser la date = aujourd'hui state.currentDate = todayISO(); document.getElementById("date-picker").value = state.currentDate; updateDatePickerDayLabel(state.currentDate); // v2026.5.16 : label "Mardi" // v5.0.11 : détecter le contexte réseau en arrière-plan (non bloquant) detectNetworkContextAsync(); // Charger la sesson puis le planning await refreshSessionAndLoad(); } /** * v5.0.11 : détecte si on est en interne (bureau VPN) ou externe (télétravail), * de manière asynchrone au démarrage. Résultat utilisé pour choisir le bon * domaine lors de la reconnexion. */ async function detectNetworkContextAsync(force = false) { try { const resp = await sendMessage({ type: "detectNetwork", force }); if (resp && resp.ok) { state.networkContext = resp.context; // Si on n'a pas encore de lastKnownOrigin, on prend celui du contexte détecté if (!state.lastKnownOrigin) { state.lastKnownOrigin = resp.origin; } console.log("[viewer] réseau détecté :", resp.context, "→", resp.origin); } } catch (e) { console.warn("[viewer] détection réseau échouée", e); } } async function refreshSessionAndLoad() { const resp = await sendMessage({ type: "getSession" }); if (!resp.ok || !resp.session) { // v4.2.5 : si un cache existe pour le jour demandé, on l'affiche avec // une bannière "session expirée" sticky au-dessus. Sinon écran plein. const cached = await readCache(state.currentDate); if (cached) { renderFromData({ techs: cached.techs, targetDate: state.currentDate, captureTime: cached.savedAt || null, source: "cache" }); showSessionExpiredBanner(); } else { showSessionNeeded(); } return; } state.session = resp.session; // v5.0.10 : mémoriser l'origine courante pour la reconnexion si besoin if (resp.session && resp.session.origin) { state.lastKnownOrigin = resp.session.origin; } hideSessionNeeded(); hideEvUnreachable(); hideSessionExpiredBanner(); hideEvUnreachableBanner(); state.sessionExpired = false; state.reconnecting = false; fetchAndShowCurrentUser(); // v5.0.9 : à chaque démarrage/reconnexion, on suppose que la session vient // d'être rafraîchie à 30 min. updateSessionIndicator va masquer le compteur. markSessionActivity(); await loadForDate(state.currentDate); } /** * v5.0.9 : doit être appelée à chaque requête EasyVista réussie. Reset le * timer local à 30 min (la session serveur a été renouvelée implicitement). */ function markSessionActivity() { state.sessionExpireAt = Date.now() + SESSION_DURATION_MS; state.sessionPingDone = false; // reset le flag de ping updateSessionIndicator(); } // 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; // v2026.5.17 : afficher aussi le temps restant de la session (MM:SS) avec // une couleur qui dépend du seuil (vert/jaune/rouge). popup.innerHTML = ""; const nameEl = document.createElement("div"); nameEl.className = "user-name-popup-name"; nameEl.textContent = state.currentUser.name; popup.appendChild(nameEl); const sessEl = document.createElement("div"); sessEl.className = "user-name-popup-session"; sessEl.id = "user-name-popup-session"; _renderUserPopupSessionLine(sessEl); popup.appendChild(sessEl); 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"); } // v2026.5.17 : remplit la ligne "Session : MM:SS" avec couleur selon seuil. // Recalcule à chaque appel — appelée aussi par le tick session pour rafraîchir. function _renderUserPopupSessionLine(el) { if (!el) return; const remainingMs = _getSessionRemainingMs(); if (remainingMs == null) { el.textContent = "Session : —"; el.className = "user-name-popup-session"; return; } const mins = Math.floor(remainingMs / 60000); const secs = Math.floor((remainingMs % 60000) / 1000); const txt = `Session : ${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; el.textContent = txt; el.className = "user-name-popup-session"; if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS) { el.classList.add("session-critical"); } else if (remainingMs <= SESSION_WARN_THRESHOLD_MS) { el.classList.add("session-warn"); } else { el.classList.add("session-ok"); } } // v2026.5.17 : récupère en ms le temps restant avant expiration de la session. // Retourne null si on ne connaît pas encore (pas de session ouverte). function _getSessionRemainingMs() { if (!state.sessionExpireAt) return null; const remaining = state.sessionExpireAt - Date.now(); return remaining > 0 ? remaining : 0; } // ============================================================================ // 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 rafraichissement. // - 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); // v4.2.6 : boutons Absence et Douchette const absenceBtn = document.getElementById("absence-btn"); if (absenceBtn) absenceBtn.addEventListener("click", showAbsenceModal); const douchetteBtn = document.getElementById("douchette-btn"); if (douchetteBtn) douchetteBtn.addEventListener("click", showDouchetteModal); 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 user document.addEventListener("click", (e) => { const popup = document.getElementById("user-name-popup"); if (popup && !popup.classList.contains("hidden")) { // 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")) { hideUserNamePopup(); } } // v4.2.4 : clic ailleurs ferme aussi la grande bulle d'interventoin // quand elle est ouverte via clic timeline (mode "anchored"). Clic sur // la bulle elle-même ou sur une timeline-slot ne ferme pas. const tip = tooltipEl(); if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) { if (!e.target.closest("#tooltip") && !e.target.closest(".timeline-slot")) { hideTooltip({ force: true }); } } }); // v2026.5.20 : nouveau comportement de la touche Échap // - Appui court : ferme uniquement le popup SOUS la souris (normal ou // minimisé). Si la souris n'est sur aucun popup, ne fait rien. // Ferme aussi le popup user-badge et la grande bulle anchored. // - Maintenu ≥ 3 secondes : ferme TOUS les popups flottants, mais garde // les pastilles dock (popups "réduits" en bas). let _escHoldTimer = null; let _escHoldTriggered = false; const ESC_HOLD_MS = 3000; document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; // keydown peut se répéter si la touche est maintenue ; on ignore les répétitions. if (e.repeat) return; // Armer le timer "maintenu 3s" _escHoldTriggered = false; if (_escHoldTimer) clearTimeout(_escHoldTimer); _escHoldTimer = setTimeout(() => { _escHoldTriggered = true; _escHoldTimer = null; // Fermer TOUS les popups flottants (normaux + minimisés) mais pas les dockés document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced)").forEach(p => { try { p.remove(); } catch (err) {} }); // Nettoyer la liste for (let i = pinnedPopups.length - 1; i >= 0; i--) { if (!document.body.contains(pinnedPopups[i].el)) { pinnedPopups.splice(i, 1); } } _ensureDockCloseAllBtn(); }, ESC_HOLD_MS); }); document.addEventListener("keyup", (e) => { if (e.key !== "Escape") return; if (_escHoldTimer) { clearTimeout(_escHoldTimer); _escHoldTimer = null; } if (_escHoldTriggered) { // On a déjà fait l'action "maintenu", ne rien faire de plus _escHoldTriggered = false; return; } // Appui court : fermer le popup sous la souris si applicable hideUserNamePopup(); const tip = tooltipEl(); if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) { hideTooltip({ force: true }); } // Quel popup est sous la souris ? Utiliser :hover pour détecter const hovered = document.querySelector(".pinned-popup:hover"); if (hovered && !hovered.classList.contains("pinned-popup-reduced")) { // Retirer aussi de pinnedPopups const idx = pinnedPopups.findIndex(p => p.el === hovered); if (idx >= 0) pinnedPopups.splice(idx, 1); hovered.remove(); _ensureDockCloseAllBtn(); } }); // v5.0.10 : clic "Ouvrir EasyVista" sur l'écran plein → déclenche la // reconnexion SSO + l'auto-reload du viewer dès que la nouvelle session // est détectée (au lieu d'ouvrir juste un onglet). document.getElementById("open-ev-btn").addEventListener("click", () => { triggerReconnect(); }); // 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); // v4.2.5 : bindings bannière "EasyVista inaccessible" const evRetryBtn = document.getElementById("ev-unreachable-banner-retry"); if (evRetryBtn) evRetryBtn.addEventListener("click", async () => { hideEvUnreachableBanner(); await refreshSessionAndLoad(); }); const evOpenBtn = document.getElementById("ev-unreachable-banner-open"); if (evOpenBtn) evOpenBtn.addEventListener("click", openEasyVista); const evCloseBtn = document.getElementById("ev-unreachable-banner-close"); if (evCloseBtn) evCloseBtn.addEventListener("click", hideEvUnreachableBanner); } async function openEasyVista() { // v5.0.10 : ouvrir sur le domaine le plus approprié : // - lastKnownOrigin si on a déjà eu une session fonctionnelle (respecte // interne vs externe selon le réseau) // - session.origin si on a encore la session // - itsma.vd.ch en fallback (domaine externe accessible de partout, // même depuis le réseau VD il redirige vers l'interne transparent) const origin = state.lastKnownOrigin || (state.session && state.session.origin) || "https://itsma.vd.ch"; await chrome.tabs.create({ url: origin + "/" }); } // 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 = ` `; 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); } // ============================================================================ // v4.2.5 : modal d'alerte générique (session expirée / EV inaccessible / // erreur d'ouverture). Remplace les alert() natives par une vraie popup // avec flou autour, titre, message et boutons personnalisables. // ============================================================================ /** * Affiche un modal d'alerte. * @param {Object} opts * @param {string} opts.title - Titre * @param {string} opts.message - Message (HTML autorisé si opts.html=true) * @param {boolean} [opts.html=false] - Si true, message interprété comme HTML * @param {Array<{label:string, variant:"primary"|"secondary"|"danger", action:(()=>void|Promise)}>} opts.buttons * Boutons (en bas du modal). Le 1er = focus par défaut. */ function showAlertModal(opts) { // Si un alert modal est déjà affiché, l'enlever d'abord const existing = document.getElementById("alert-modal"); if (existing) existing.remove(); const overlay = document.createElement("div"); overlay.id = "alert-modal"; overlay.className = "modal-overlay"; const card = document.createElement("div"); card.className = "modal-card"; card.setAttribute("role", "dialog"); card.setAttribute("aria-labelledby", "alert-modal-title"); const h = document.createElement("h2"); h.id = "alert-modal-title"; h.className = "modal-title"; h.textContent = opts.title || ""; card.appendChild(h); const p = document.createElement("p"); p.className = "modal-message"; if (opts.html) { p.innerHTML = opts.message || ""; } else { p.textContent = opts.message || ""; } card.appendChild(p); const actions = document.createElement("div"); actions.className = "modal-actions"; (opts.buttons || []).forEach((btn, i) => { const b = document.createElement("button"); b.type = "button"; b.className = "btn"; if (btn.variant === "primary") b.classList.add("btn-modal-primary"); else if (btn.variant === "danger") b.classList.add("btn-modal-danger-strong"); else b.classList.add("btn-modal-cancel"); b.textContent = btn.label; b.addEventListener("click", async () => { overlay.remove(); if (typeof btn.action === "function") { try { await btn.action(); } catch (e) { console.error("[alert-modal]", e); } } }); actions.appendChild(b); if (i === 0) setTimeout(() => b.focus(), 50); }); card.appendChild(actions); overlay.appendChild(card); document.body.appendChild(overlay); // Clic sur le fond (flou) → fermer overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); // Échap ferme la modale const escHandler = (e) => { if (e.key === "Escape") { overlay.remove(); document.removeEventListener("keydown", escHandler); } }; document.addEventListener("keydown", escHandler); } // ============================================================================ // v4.2.9 : blocage du scroll en arrière-plan quand un modal est ouvert // ============================================================================ // // Un MutationObserver surveille l'apparition/disparition de tout élément // .modal-overlay dans le body. Dès qu'il y en a au moins un, on ajoute la // classe `modal-open` sur body → CSS bloque le scroll. Quand le dernier // modal disparaît, la classe est retirée. // // Centralisé ici pour que TOUS les modals (existants et futurs) en profitent // sans modification individuelle. function initModalScrollLock() { const updateLock = () => { const hasModal = document.querySelector(".modal-overlay") !== null; document.body.classList.toggle("modal-open", hasModal); }; const observer = new MutationObserver(updateLock); observer.observe(document.body, { childList: true, subtree: false }); updateLock(); // au cas où un modal serait déjà là au boot } // v4.2.9 : pied de page discret "QRO / vX.X.X" en bas à droite. // La version est lue depuis le manifest (source unique de vérité). function initAppFooter() { if (document.querySelector(".app-footer")) return; let version = ""; try { const manifest = chrome && chrome.runtime && chrome.runtime.getManifest ? chrome.runtime.getManifest() : null; if (manifest && manifest.version) version = "v" + manifest.version; } catch (e) {} const el = document.createElement("div"); el.className = "app-footer"; el.textContent = `QRO${version ? " / " + version : ""}`; document.body.appendChild(el); } // v5.0.0 : horloge HH:MM au milieu de la topbar. Mise à jour toutes les 30s // (les secondes ne sont pas affichées donc pas besoin d'un tick plus rapide). function initAppClock() { const el = document.getElementById("app-clock"); if (!el) return; const dateEl = document.getElementById("app-clock-date"); const timeEl = document.getElementById("app-clock-time"); // v2026.5.16 : format "Mardi 21 avril 2026" const JOURS = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]; const MOIS = [ "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre" ]; let lastDateStr = ""; const tick = () => { const d = new Date(); const h = String(d.getHours()).padStart(2, "0"); const m = String(d.getMinutes()).padStart(2, "0"); const timeStr = `${h}:${m}`; if (timeEl) timeEl.textContent = timeStr; else el.textContent = timeStr; // fallback si ancien markup // Date complète : actualisée seulement si elle a changé (évite reflow inutile) if (dateEl) { const jour = JOURS[d.getDay()]; const num = d.getDate(); const mois = MOIS[d.getMonth()]; const annee = d.getFullYear(); const dateStr = `${jour} ${num} ${mois} ${annee}`; if (dateStr !== lastDateStr) { dateEl.textContent = dateStr; lastDateStr = dateStr; } } // v5.0.0 : profite du tick pour mettre à jour la ligne rouge "now" updateNowLine(); }; tick(); // Tick toutes les 30s : ça garantit une MAJ rapide au changement de min setInterval(tick, 30 * 1000); } // v2026.5.17 : met à jour le faux input date custom (ex: "Vendredi 24.04.2026") // Remplace l'ancien updateDatePickerDayLabel. L'input date natif reste présent // mais caché, et son onChange continue de déclencher le chargement. const DAY_NAMES_FULL = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]; function updateDatePickerDayLabel(isoDate) { const el = document.getElementById("date-custom-label"); if (!el) return; if (!isoDate) { el.textContent = ""; return; } try { const d = isoToDate(isoDate); const day = DAY_NAMES_FULL[d.getDay()]; const dd = String(d.getDate()).padStart(2, "0"); const mm = String(d.getMonth() + 1).padStart(2, "0"); const yyyy = d.getFullYear(); el.textContent = `${day} ${dd}.${mm}.${yyyy}`; } catch (e) { el.textContent = ""; } } // v2026.5.17 : brancher le faux input date — clic dessus ouvre le vrai input // caché pour choisir une date. function initDateCustomPicker() { const custom = document.getElementById("date-custom"); const picker = document.getElementById("date-picker"); if (!custom || !picker) return; const openPicker = () => { try { if (typeof picker.showPicker === "function") { picker.showPicker(); } else { picker.focus(); picker.click(); } } catch (e) { picker.focus(); } }; custom.addEventListener("click", openPicker); custom.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openPicker(); } }); } // v5.0.0 : ligne verticale rouge "heure actuelle" sur la timeline, visible // UNIQUEMENT quand on affiche aujourd'hui. Appelée à chaque tick de l'horloge // + après chaque render (cf renderFromData). function updateNowLine() { const isToday = state.currentDate === todayISO(); // Retirer toutes les lignes existantes d'abord document.querySelectorAll(".timeline-now-line").forEach(el => el.remove()); if (!isToday) return; // Calculer la position en % sur la timeline (DAY_START à DAY_END) const now = new Date(); const nowMin = now.getHours() * 60 + now.getMinutes(); if (nowMin < DAY_START || nowMin > DAY_END) return; // hors plage affichée const pct = ((nowMin - DAY_START) / DAY_LEN) * 100; // Ajouter une ligne sur chaque barre timeline visible document.querySelectorAll(".timeline-bar").forEach(bar => { const line = document.createElement("div"); line.className = "timeline-now-line"; line.style.left = pct + "%"; bar.appendChild(line); }); } // v5.0.0 : menu admin caché. 5 clics consécutifs sur le titre "Planification" // (avec max 2 secondes entre chaque clic) ouvrent le panneau admin. function initAdminMenu() { const title = document.getElementById("app-title"); if (!title) return; let clicks = 0; let resetTimer = null; title.addEventListener("click", () => { clicks++; if (resetTimer) clearTimeout(resetTimer); resetTimer = setTimeout(() => { clicks = 0; }, 2000); if (clicks >= 5) { clicks = 0; clearTimeout(resetTimer); showAdminPanel(); } }); // Cursor pointer pour indiquer (discrètement) qu'il est cliquable title.style.cursor = "default"; } // ============================================================================ // v5.0.9 : Surveillance du timeout de session EasyVista // ============================================================================ /** * Initialise le tick du compteur de session (toutes les secondes). * Pas de requête réseau : décompte purement local depuis state.sessionExpireAt. * En parallèle, un polling 2s actif uniquement en reconnexion, pour détecter * dès que l'user s'est reconnecté dans l'onglet EasyVista ouvert. */ function initSessionTimer() { setInterval(() => { updateSessionIndicator(); }, 1000); // Polling actif UNIQUEMENT pendant une reconnexion pour détecter le nouveau // PHPSESSID dès qu'il apparaît dans un onglet EV. Rien d'envoyé au serveur // en dehors de ça. setInterval(async () => { if (!state.reconnecting) return; try { const resp = await sendMessage({ type: "getSession" }); if (resp && resp.ok && resp.session && resp.session.phpsessid) { const oldPhpsessid = state.session ? state.session.phpsessid : null; if (resp.session.phpsessid !== oldPhpsessid) { console.log("[session] nouvelle session détectée après reconnexion :", resp.session.phpsessid); // v5.0.11 : annuler le timeout de reconnexion puisque ça a marché if (state.reconnectTimeoutId) { clearTimeout(state.reconnectTimeoutId); state.reconnectTimeoutId = null; } state.session = resp.session; if (resp.session.origin) state.lastKnownOrigin = resp.session.origin; state.reconnecting = false; state.sessionExpired = false; hideReconnectingBanner(); hideSessionExpiredBanner(); hideReconnectFailedBanner(); markSessionActivity(); showToast("Reconnecté", "Session EasyVista renouvelée"); // Recharger le planning à la date courante sans perdre la position await loadForDate(state.currentDate); } } } catch (e) { // Silencieux, on réessayera au prochain tick } }, 2000); } /** * Met à jour l'affichage du compteur session dans la topbar. * Règles : * - Session expirée ou reconnexion → compteur caché (bannière gère l'affichage) * - > 5 min restantes → compteur invisible * - 2-5 min → jaune, bouton "Prolonger" visible * - < 2 min → rouge pulse + modal automatique (une seule fois) * - <= 0 → déclenche l'état "expirée" */ function updateSessionIndicator() { const el = document.getElementById("app-session"); if (!el) return; if (state.sessionExpired || state.reconnecting) { el.classList.add("hidden"); return; } if (!state.sessionExpireAt) { el.classList.add("hidden"); return; } const remainingMs = state.sessionExpireAt - Date.now(); if (remainingMs <= 0) { handleSessionExpired(); return; } if (remainingMs > SESSION_WARN_THRESHOLD_MS) { el.classList.add("hidden"); return; } // Zone d'alerte (< 5 min) // v5.0.9 : avant d'afficher l'alerte, on fait UN ping de confirmation // pour vérifier que le serveur est bien d'accord (compteur local parfois // désynchronisé si plusieurs requêtes EV ont rafraîchi sans qu'on update // notre horloge). Une seule fois par cycle. if (!state.sessionPingDone) { state.sessionPingDone = true; sendMessage({ type: "getSessionRemaining" }).then(resp => { if (resp && resp.ok && typeof resp.remainingMs === "number") { state.sessionExpireAt = Date.now() + resp.remainingMs; updateSessionIndicator(); } }).catch(() => {}); // En attendant, on continue avec l'estimation locale } const mm = Math.floor(remainingMs / 60000); const ss = Math.floor((remainingMs % 60000) / 1000); const timeStr = `${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; el.classList.remove("hidden", "session-warn", "session-critical"); if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS) { el.classList.add("session-critical"); if (!state._criticalModalShown) { state._criticalModalShown = true; showSessionCriticalModal(); } } else { el.classList.add("session-warn"); state._criticalModalShown = false; } el.innerHTML = ` ${timeStr} `; const extendBtn = el.querySelector(".session-extend-btn"); if (extendBtn) { extendBtn.onclick = async () => { extendBtn.disabled = true; extendBtn.textContent = "…"; try { const resp = await sendMessage({ type: "extendSession" }); if (resp && resp.ok && typeof resp.remainingMs === "number") { state.sessionExpireAt = Date.now() + resp.remainingMs; state.sessionPingDone = false; state._criticalModalShown = false; showToast("Session prolongée", "30 minutes de plus"); updateSessionIndicator(); } else { throw new Error((resp && resp.error) || "erreur inconnue"); } } catch (err) { extendBtn.disabled = false; extendBtn.textContent = "🔄 Prolonger"; if (err.message === "session_expired" || err.message === "no_session") { handleSessionExpired(); } } }; } // v2026.5.17 : si le popup user-badge est ouvert, rafraîchir la ligne "Session : MM:SS" const sessLineInPopup = document.getElementById("user-name-popup-session"); if (sessLineInPopup) _renderUserPopupSessionLine(sessLineInPopup); // v2026.5.17 : popup d'alerte "glissante" depuis le haut gauche // - à 5 min : alerte standard (si pas encore affichée ni "plus tard") // - à 2 min : alerte urgente (si pas encore affichée) _handleSessionSlideAlerts(remainingMs); } /** * v2026.5.17 : gère les 2 alertes popup glissant depuis le haut gauche. * - Première alerte à 5 min (SESSION_WARN_THRESHOLD_MS). Reste affichée jusqu'à * action manuelle (Prolonger ou Plus tard). * - Si "Plus tard", une 2e alerte plus urgente réapparait à 2 min * (SESSION_CRITICAL_THRESHOLD_MS). */ function _handleSessionSlideAlerts(remainingMs) { if (remainingMs == null) return; // Alerte à 5 min if (remainingMs <= SESSION_WARN_THRESHOLD_MS && remainingMs > SESSION_CRITICAL_THRESHOLD_MS && !state._slideAlert5minShown) { state._slideAlert5minShown = true; _showSessionSlideAlert({ urgent: false }); } // Alerte à 2 min (si déjà "Plus tard" sur l'alerte 5 min OU alerte 5 min jamais affichée) if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS && !state._slideAlert2minShown) { state._slideAlert2minShown = true; // Cacher éventuellement l'ancienne alerte pour ré-afficher la nouvelle _hideSessionSlideAlert(); _showSessionSlideAlert({ urgent: true }); } } function _showSessionSlideAlert({ urgent }) { // Retirer l'ancienne si elle existe _hideSessionSlideAlert(); const el = document.createElement("div"); el.id = "session-slide-alert"; el.className = "session-slide-alert" + (urgent ? " urgent" : ""); const title = urgent ? "⚠ Session expire dans 2 minutes !" : "⏱ Session expire dans 5 minutes"; el.innerHTML = `
${title}
`; document.body.appendChild(el); // Déclenche l'animation de slide-in (petite tempo pour que la transition parte) requestAnimationFrame(() => el.classList.add("visible")); // Action "Prolonger" el.querySelector(".session-slide-alert-extend").addEventListener("click", async () => { const extendBtn = el.querySelector(".session-slide-alert-extend"); extendBtn.disabled = true; extendBtn.textContent = "…"; try { const resp = await sendMessage({ type: "extendSession" }); if (resp && resp.ok && typeof resp.remainingMs === "number") { state.sessionExpireAt = Date.now() + resp.remainingMs; state.sessionPingDone = false; state._criticalModalShown = false; // Reset des flags d'alerte pour le prochain cycle state._slideAlert5minShown = false; state._slideAlert2minShown = false; showToast("Session prolongée", "30 minutes de plus"); updateSessionIndicator(); _hideSessionSlideAlert(); } else { throw new Error((resp && resp.error) || "erreur inconnue"); } } catch (err) { extendBtn.disabled = false; extendBtn.textContent = "🔄 Prolonger"; } }); // Action "Plus tard" el.querySelector(".session-slide-alert-later").addEventListener("click", () => { _hideSessionSlideAlert(); // Si c'est l'alerte 5 min qu'on dismissa, l'alerte 2 min reviendra // automatiquement (state._slideAlert2minShown toujours false). }); } function _hideSessionSlideAlert() { const el = document.getElementById("session-slide-alert"); if (!el) return; el.classList.remove("visible"); setTimeout(() => { try { el.remove(); } catch (e) {} }, 250); } /** * Appelée quand le compteur atteint 0 ou quand une requête EV échoue en * session expirée. Affiche la bannière "Session expirée" avec bouton "Me * reconnecter". */ function handleSessionExpired() { if (state.sessionExpired) return; state.sessionExpired = true; state.sessionExpireAt = null; state._criticalModalShown = false; console.warn("[session] session EV expirée"); showSessionExpiredBanner(); const el = document.getElementById("app-session"); if (el) el.classList.add("hidden"); } /** * Modal auto quand < 2 min : alerte visuelle forte. */ function showSessionCriticalModal() { showAlertModal({ title: "⚠️ Session EasyVista expire bientôt", message: "Votre session EasyVista expire dans moins de 2 minutes. Cliquez sur « Prolonger » pour éviter d'être déconnecté.", buttons: [ { label: "Ignorer", variant: "secondary", action: () => {} }, { label: "🔄 Prolonger maintenant", variant: "primary", action: async () => { try { const resp = await sendMessage({ type: "extendSession" }); if (resp && resp.ok && typeof resp.remainingMs === "number") { state.sessionExpireAt = Date.now() + resp.remainingMs; state.sessionPingDone = false; state._criticalModalShown = false; showToast("Session prolongée", "30 minutes de plus"); updateSessionIndicator(); } } catch (e) { handleSessionExpired(); } } } ] }); } /** * Appelé au clic "Me reconnecter" dans la bannière. Ouvre EasyVista dans un * nouvel onglet (déclenche Windows SSO Kerberos automatique). Le polling * dans initSessionTimer détectera la nouvelle session et rechargera le viewer. * * v5.0.10 : utilise l'origine dynamique (interne ou externe selon le réseau). * v5.0.11 : détecte le contexte réseau avant d'ouvrir (si pas déjà connu) + * timeout 90s : si pas reconnecté après ce délai, propose choix manuel. * * @param {string} [forcedOrigin] - origine à forcer (pour le choix manuel * dans le fallback après timeout). Si absent : détection auto. */ async function triggerReconnect(forcedOrigin) { state.reconnecting = true; hideSessionExpiredBanner(); hideReconnectFailedBanner(); showReconnectingBanner(); // Annuler tout timeout précédent if (state.reconnectTimeoutId) { clearTimeout(state.reconnectTimeoutId); state.reconnectTimeoutId = null; } try { let origin = forcedOrigin; if (!origin) { // v5.0.11 : re-détecter le réseau à chaque expiration pour gérer le // cas où on a changé de contexte (bureau → TT) pendant la session. await detectNetworkContextAsync(true); origin = state.lastKnownOrigin || (state.session && state.session.origin) || "https://itsma.vd.ch"; } console.log("[session] triggerReconnect → ouverture de", origin); await sendMessage({ type: "openEasyVistaLogin", origin }); // Démarrer le timeout 90s : si pas reconnecté, basculer en mode "Échec" state.reconnectTimeoutId = setTimeout(() => { if (state.reconnecting && !state.session) { console.warn("[session] reconnexion timeout 90s → bannière échec"); state.reconnecting = false; hideReconnectingBanner(); showReconnectFailedBanner(); } }, RECONNECT_TIMEOUT_MS); } catch (err) { console.warn("[session] openEasyVistaLogin failed:", err); state.reconnecting = false; hideReconnectingBanner(); showSessionExpiredBanner(); } } /** * v5.0.11 : l'user clique "Annuler" pendant la reconnexion. On arrête le * polling/timeout et on revient à l'état "Session expirée" normal. */ function cancelReconnect() { if (state.reconnectTimeoutId) { clearTimeout(state.reconnectTimeoutId); state.reconnectTimeoutId = null; } state.reconnecting = false; hideReconnectingBanner(); hideReconnectFailedBanner(); showSessionExpiredBanner(); } // v5.0.0 : stockage des paramètres admin dans chrome.storage.local. // Clé unique : "admin_config". Contient la config éditable (équipe, // absences récurrentes, statuts etc.). Au 1er lancement : initialisée // avec les valeurs hardcodées actuelles. const ADMIN_CONFIG_KEY = "admin_config"; function getDefaultAdminConfig() { return { team: { ...TEAM }, // Clone pour ne pas modifier le hardcode recurringAbsences: { ...RECURRING_ABSENCES }, // idem groupId: "191", evOrigins: ["https://itsma.etat-de-vaud.ch", "https://itsma.vd.ch"], closedStatus: [...CLOSED_STATUS], resolvedStatus: [...RESOLVED_STATUS], cancelledStatus: [...CANCELLED_STATUS], dayStart: 8, dayEnd: 18, cacheDays: 7 }; } async function loadAdminConfig() { try { const stored = await chrome.storage.local.get(ADMIN_CONFIG_KEY); if (stored && stored[ADMIN_CONFIG_KEY]) { // Fusion avec les defaults (pour rajouter d'éventuelles nouvelles clés) return { ...getDefaultAdminConfig(), ...stored[ADMIN_CONFIG_KEY] }; } } catch (e) { console.warn("[admin] loadAdminConfig err", e); } return getDefaultAdminConfig(); } async function saveAdminConfig(cfg) { try { await chrome.storage.local.set({ [ADMIN_CONFIG_KEY]: cfg }); console.log("[admin] config sauvegardée"); return true; } catch (e) { console.error("[admin] saveAdminConfig err", e); return false; } } // v5.0.0 : affiche le panel admin plein écran. async function showAdminPanel() { // Ferme un éventuel panel existant const existing = document.getElementById("admin-panel"); if (existing) existing.remove(); // Charge la config actuelle const cfg = await loadAdminConfig(); // Overlay plein écran const overlay = document.createElement("div"); overlay.id = "admin-panel"; overlay.className = "modal-overlay admin-overlay"; const card = document.createElement("div"); card.className = "admin-panel-card"; // En-tête const header = document.createElement("div"); header.className = "admin-header"; const title = document.createElement("h2"); title.textContent = "⚙ Administration"; title.className = "admin-title"; const closeBtn = document.createElement("button"); closeBtn.type = "button"; closeBtn.className = "admin-close-btn"; closeBtn.textContent = "×"; closeBtn.title = "Fermer (Échap)"; closeBtn.addEventListener("click", () => overlay.remove()); header.appendChild(title); header.appendChild(closeBtn); card.appendChild(header); // Navigation latérale (onglets) const body = document.createElement("div"); body.className = "admin-body"; const sidebar = document.createElement("nav"); sidebar.className = "admin-sidebar"; const content = document.createElement("div"); content.className = "admin-content"; const sections = [ { id: "team", label: "Équipe", render: renderAdminSectionTeam }, { id: "easyvista", label: "EasyVista", render: renderAdminSectionEV }, { id: "appearance", label: "Apparence", render: renderAdminSectionAppearance }, { id: "statuses", label: "Statuts", render: renderAdminSectionStatuses }, { id: "diagnostics",label: "Diagnostics", render: renderAdminSectionDiagnostics } ]; let currentSection = "team"; const navButtons = {}; for (const section of sections) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "admin-nav-btn"; btn.textContent = section.label; btn.dataset.section = section.id; if (section.id === currentSection) btn.classList.add("active"); btn.addEventListener("click", () => { currentSection = section.id; for (const k in navButtons) navButtons[k].classList.remove("active"); btn.classList.add("active"); content.innerHTML = ""; section.render(content, cfg, () => saveAndReload(cfg)); }); navButtons[section.id] = btn; sidebar.appendChild(btn); } body.appendChild(sidebar); body.appendChild(content); card.appendChild(body); overlay.appendChild(card); document.body.appendChild(overlay); // Rendu initial : section "Équipe" sections[0].render(content, cfg, () => saveAndReload(cfg)); // Échap ferme le panel const escHandler = (e) => { if (e.key === "Escape") { overlay.remove(); document.removeEventListener("keydown", escHandler); } }; document.addEventListener("keydown", escHandler); async function saveAndReload(updatedCfg) { const ok = await saveAdminConfig(updatedCfg); if (ok) { showToast("Config enregistrée", "Rechargez l'extension pour appliquer"); } else { showAlertModal({ title: "Erreur", message: "Impossible d'enregistrer la configuration.", buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); } } } // v5.0.0 : section "Équipe" du panel admin. // v5.0.1 : affiche la liste complète du groupe EasyVista (20+ personnes), // avec case à cocher "inclure dans la planification" pour chacune. function renderAdminSectionTeam(container, cfg, saveFn) { const h = document.createElement("h3"); h.textContent = "Équipe"; h.className = "admin-section-title"; container.appendChild(h); const desc = document.createElement("p"); desc.className = "admin-section-desc"; desc.textContent = "Sélectionnez les personnes qui doivent apparaître dans la planification. Les IDs viennent d'EasyVista (bouton Détecter) ou peuvent être saisis manuellement."; container.appendChild(desc); // État local : liste {id, name, included, days:[0..6]} // Au départ on remplit depuis cfg.team actuel, puis la détection EV // enrichit cette liste. const rows = []; for (const [id, name] of Object.entries(cfg.team || {})) { rows.push({ id, name, included: true, days: (cfg.recurringAbsences[id] || []).slice() }); } const tableWrap = document.createElement("div"); tableWrap.className = "admin-team-wrap"; container.appendChild(tableWrap); function render() { tableWrap.innerHTML = ""; // Bouton "Détecter depuis EasyVista" const detectBtn = document.createElement("button"); detectBtn.type = "button"; detectBtn.className = "btn btn-secondary"; detectBtn.textContent = "🔍 Détecter depuis EasyVista (groupe complet)"; detectBtn.style.marginBottom = "12px"; detectBtn.addEventListener("click", async () => { detectBtn.disabled = true; detectBtn.textContent = "Détection en cours…"; try { const resp = await sendMessage({ type: "detectTeam" }); if (resp && resp.ok && resp.members && resp.members.length) { // Merge : pour chaque membre détecté, ajoute à `rows` s'il n'y est // pas déjà. S'il y est déjà, met à jour le nom (si meilleur). for (const m of resp.members) { const existing = rows.find(r => r.id === m.id); if (existing) { // Améliorer le nom si le nom actuel commence par "?" if (m.name && !m.name.startsWith("?") && existing.name.startsWith("?")) { existing.name = m.name; } } else { rows.push({ id: m.id, name: m.name || "? (" + m.id + ")", included: !!m.alreadyInTeam, // coché si déjà dans l'équipe days: [] }); } } showToast("Détecté", resp.members.length + " personne(s) dans le groupe"); render(); } else { showAlertModal({ title: "Détection impossible", message: (resp && resp.error) || "Aucune personne trouvée. Vérifiez que vous êtes connecté à EasyVista.", buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); } } catch (err) { console.warn("[admin] detectTeam err", err); } finally { detectBtn.disabled = false; detectBtn.textContent = "🔍 Détecter depuis EasyVista (groupe complet)"; } }); tableWrap.appendChild(detectBtn); // Stats : nb inclus / total const included = rows.filter(r => r.included).length; const stats = document.createElement("div"); stats.className = "admin-section-desc"; stats.style.marginTop = "0"; stats.textContent = `${included} personne(s) incluse(s) sur ${rows.length} connue(s).`; tableWrap.appendChild(stats); // Table const table = document.createElement("table"); table.className = "admin-team-table"; const thead = document.createElement("thead"); thead.innerHTML = "InclureIDNom affichéAbsences récurrentes"; table.appendChild(thead); const tbody = document.createElement("tbody"); table.appendChild(tbody); const days = ["Dim","Lun","Mar","Mer","Jeu","Ven","Sam"]; rows.forEach((r, idx) => { const tr = document.createElement("tr"); if (!r.included) tr.classList.add("admin-row-excluded"); // Checkbox inclure const tdInc = document.createElement("td"); const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = r.included; cb.addEventListener("change", () => { r.included = cb.checked; tr.classList.toggle("admin-row-excluded", !r.included); stats.textContent = `${rows.filter(x => x.included).length} personne(s) incluse(s) sur ${rows.length} connue(s).`; }); tdInc.appendChild(cb); tr.appendChild(tdInc); // ID const tdId = document.createElement("td"); const inpId = document.createElement("input"); inpId.type = "text"; inpId.value = r.id; inpId.placeholder = "76272"; inpId.className = "admin-input admin-input-id"; inpId.addEventListener("input", () => { r.id = inpId.value.trim(); }); tdId.appendChild(inpId); tr.appendChild(tdId); // Nom const tdName = document.createElement("td"); const inpName = document.createElement("input"); inpName.type = "text"; inpName.value = r.name; inpName.placeholder = "Dupont, Jean"; inpName.className = "admin-input"; inpName.addEventListener("input", () => { r.name = inpName.value.trim(); }); tdName.appendChild(inpName); tr.appendChild(tdName); // Jours d'absence récurrente const tdAbs = document.createElement("td"); for (let d = 0; d < 7; d++) { const lbl = document.createElement("label"); lbl.className = "admin-day-cb"; const cbd = document.createElement("input"); cbd.type = "checkbox"; cbd.checked = r.days.includes(d); cbd.addEventListener("change", () => { if (cbd.checked && !r.days.includes(d)) r.days.push(d); if (!cbd.checked) r.days = r.days.filter(x => x !== d); }); lbl.appendChild(cbd); lbl.appendChild(document.createTextNode(days[d])); tdAbs.appendChild(lbl); } tr.appendChild(tdAbs); // Bouton supprimer ligne const tdDel = document.createElement("td"); const delBtn = document.createElement("button"); delBtn.type = "button"; delBtn.className = "admin-del-btn"; delBtn.textContent = "🗑"; delBtn.title = "Retirer cette ligne"; delBtn.addEventListener("click", () => { rows.splice(idx, 1); render(); }); tdDel.appendChild(delBtn); tr.appendChild(tdDel); tbody.appendChild(tr); }); tableWrap.appendChild(table); // Bouton Ajouter manuellement const addBtn = document.createElement("button"); addBtn.type = "button"; addBtn.className = "btn btn-secondary"; addBtn.textContent = "+ Ajouter manuellement"; addBtn.style.marginTop = "10px"; addBtn.addEventListener("click", () => { rows.push({ id: "", name: "", included: true, days: [] }); render(); }); tableWrap.appendChild(addBtn); // Bouton Enregistrer const saveBtn = document.createElement("button"); saveBtn.type = "button"; saveBtn.className = "btn btn-primary"; saveBtn.textContent = "💾 Enregistrer"; saveBtn.style.marginTop = "20px"; saveBtn.style.marginLeft = "10px"; saveBtn.addEventListener("click", () => { // Reconstruire cfg.team et cfg.recurringAbsences à partir de rows const newTeam = {}; const newRecAbs = {}; for (const r of rows) { if (!r.included || !r.id) continue; newTeam[r.id] = r.name || ("? (" + r.id + ")"); if (r.days && r.days.length > 0) newRecAbs[r.id] = r.days.slice(); } cfg.team = newTeam; cfg.recurringAbsences = newRecAbs; saveFn(); }); tableWrap.appendChild(saveBtn); } render(); } // v5.0.0 : sections suivantes (placeholders, à enrichir v5.0.1+) function renderAdminSectionEV(container, cfg, saveFn) { const h = document.createElement("h3"); h.textContent = "EasyVista"; h.className = "admin-section-title"; container.appendChild(h); const desc = document.createElement("p"); desc.className = "admin-section-desc"; desc.textContent = "Section à venir dans v5.0.1. Origines EasyVista + group_id."; container.appendChild(desc); // Infos lecture seule pour l'instant const pre = document.createElement("pre"); pre.className = "admin-readonly"; pre.textContent = JSON.stringify({ evOrigins: cfg.evOrigins, groupId: cfg.groupId }, null, 2); container.appendChild(pre); } function renderAdminSectionAppearance(container, cfg, saveFn) { const h = document.createElement("h3"); h.textContent = "Apparence"; h.className = "admin-section-title"; container.appendChild(h); const desc = document.createElement("p"); desc.className = "admin-section-desc"; desc.textContent = "Section à venir dans v5.0.x. Heures journée, durée cache, thème."; container.appendChild(desc); const pre = document.createElement("pre"); pre.className = "admin-readonly"; pre.textContent = JSON.stringify({ dayStart: cfg.dayStart, dayEnd: cfg.dayEnd, cacheDays: cfg.cacheDays }, null, 2); container.appendChild(pre); } function renderAdminSectionStatuses(container, cfg, saveFn) { const h = document.createElement("h3"); h.textContent = "Statuts"; h.className = "admin-section-title"; container.appendChild(h); const desc = document.createElement("p"); desc.className = "admin-section-desc"; desc.textContent = "Section à venir dans v5.0.x. Mots-clés Clôturé / Résolu / Annulé."; container.appendChild(desc); const pre = document.createElement("pre"); pre.className = "admin-readonly"; pre.textContent = JSON.stringify({ closed: cfg.closedStatus, resolved: cfg.resolvedStatus, cancelled: cfg.cancelledStatus }, null, 2); container.appendChild(pre); } function renderAdminSectionDiagnostics(container, cfg, saveFn) { const h = document.createElement("h3"); h.textContent = "Diagnostics"; h.className = "admin-section-title"; container.appendChild(h); const version = (chrome && chrome.runtime && chrome.runtime.getManifest) ? chrome.runtime.getManifest().version : "?"; const info = document.createElement("div"); info.className = "admin-diag-grid"; info.innerHTML = `
Version
${escapeHtml(version)}
Date courante
${escapeHtml(state.currentDate || "?")}
Aujourd'hui
${escapeHtml(todayISO())}
Session EasyVista
${state.session ? "✓ connecté (" + (state.session.origin || "?") + ")" : "✗ non détecté"}
Popups épinglées
${pinnedPopups.length}
`; container.appendChild(info); // Bouton reset const resetBtn = document.createElement("button"); resetBtn.type = "button"; resetBtn.className = "btn btn-danger"; resetBtn.textContent = "⚠ Réinitialiser la configuration (équipe, etc.)"; resetBtn.style.marginTop = "20px"; resetBtn.addEventListener("click", () => { showAlertModal({ title: "Confirmer la réinitialisation", message: "Remettre TOUTES les configurations aux valeurs par défaut ? (les techniciens ajoutés manuellement seront perdus)", buttons: [ { label: "Annuler", variant: "secondary", action: () => {} }, { label: "Réinitialiser", variant: "danger", action: async () => { await chrome.storage.local.remove(ADMIN_CONFIG_KEY); showToast("Réinitialisé", "Rechargez la page pour voir les défauts"); } } ] }); }); container.appendChild(resetBtn); } // ============================================================================ // v4.2.6 : Modals Absence et Douchette // ============================================================================ // Types d'absence EasyVista (extraits du HTML plan_set_holidays_popup.php) const HOLIDAY_TYPES = [ { guid: "{EF51F439-441E-4A68-9D1A-A6E0A85F32FE}", label: "Congés" }, { guid: "{B5B887A7-DE5D-4CAB-B55E-7D01E5D0DF84}", label: "Déménagement" }, { guid: "{8476B26C-DFE4-4256-B2B5-3CE1C9EC3479}", label: "Ecrans" }, { guid: "{E7432422-55CB-4DB9-8A26-619D036E2155}", label: "Evènements spéciaux" }, { guid: "{F9B8FFC6-5D64-4339-AAAF-166D6D3801DA}", label: "MAC" }, { guid: "{0554F45A-9B31-43D7-A1E2-0407D74F3BB5}", label: "Maladie" }, { guid: "{E8301A0F-B246-420A-863C-3837F1B581E0}", label: "PC" }, { guid: "{60D70502-063D-45AD-9415-25C1C556105F}", label: "Pompier" }, { guid: "{B343C590-1446-45BF-9CE6-790C759BA999}", label: "Réunion" }, { guid: "{7E63F472-677E-4EFD-B822-1AF4DC163AEC}", label: "Rollout" }, { guid: "{D45DEF80-9DDA-46BA-957E-B5B6D7F9D46A}", label: "Téléphones" }, { guid: "{06BCAC52-5A8A-4D6D-9BC6-566AAF18666A}", label: "UTP" } ]; /** * Formate une date ISO YYYY-MM-DD en DD/MM/YYYY (format EasyVista). */ function isoToEvDate(iso) { if (!iso) return ""; const parts = iso.split("-"); if (parts.length !== 3) return iso; return `${parts[2]}/${parts[1]}/${parts[0]}`; } /** * Construit un bloc liste de techniciens avec checkboxes. * @param {Object} opts * @param {boolean} [opts.selectAll] - Afficher la case "Tout sélectionner" * @returns {HTMLElement} */ function buildTechCheckboxList(opts = {}) { const container = document.createElement("div"); container.className = "modal-tech-list"; const techIds = Object.keys(TEAM); if (opts.selectAll) { const allRow = document.createElement("label"); allRow.className = "modal-tech-item tech-selectall"; const allBox = document.createElement("input"); allBox.type = "checkbox"; allBox.className = "tech-select-all"; const allLabel = document.createElement("span"); allLabel.textContent = "Tout sélectionner"; allRow.appendChild(allBox); allRow.appendChild(allLabel); container.appendChild(allRow); allBox.addEventListener("change", () => { container.querySelectorAll(".tech-checkbox").forEach(cb => { cb.checked = allBox.checked; }); }); } for (const id of techIds) { const row = document.createElement("label"); row.className = "modal-tech-item"; const cb = document.createElement("input"); cb.type = "checkbox"; cb.className = "tech-checkbox"; cb.value = id; const label = document.createElement("span"); label.textContent = TEAM[id]; row.appendChild(cb); row.appendChild(label); container.appendChild(row); // Cocher "Tout" si toutes les cases sont cochées (et décocher sinon) cb.addEventListener("change", () => { const allBox = container.querySelector(".tech-select-all"); if (!allBox) return; const boxes = [...container.querySelectorAll(".tech-checkbox")]; allBox.checked = boxes.every(b => b.checked); allBox.indeterminate = !allBox.checked && boxes.some(b => b.checked); }); } return container; } /** * Récupère la liste des techIds cochés dans une liste de checkboxes. */ function getCheckedTechIds(container) { return [...container.querySelectorAll(".tech-checkbox:checked")].map(cb => cb.value); } /** * Ouvre la modal "Créer une absence". */ function showAbsenceModal() { const existing = document.getElementById("absence-modal"); if (existing) existing.remove(); const overlay = document.createElement("div"); overlay.id = "absence-modal"; overlay.className = "modal-overlay"; const card = document.createElement("div"); card.className = "modal-card modal-wide"; card.setAttribute("role", "dialog"); // v5.0.0 : on mémorise la date affichée au moment de l'ouverture de la // modal. Le reload après création se fait sur cette date précise, pas // sur state.currentDate (qui aurait pu changer entre-temps). const dateAtOpen = state.currentDate || todayISO(); const title = document.createElement("h2"); title.className = "modal-title"; title.textContent = "Créer une absence"; card.appendChild(title); // Liste des techs (sans "Tout sélectionner" : on ne met quasi jamais tout // le monde en absence, et c'est trop dangereux par erreur) const techGroup = document.createElement("div"); techGroup.className = "modal-form-group"; const techLabel = document.createElement("label"); techLabel.className = "modal-form-label"; techLabel.textContent = "Technicien(s)"; techGroup.appendChild(techLabel); const techList = buildTechCheckboxList({ selectAll: false }); techGroup.appendChild(techList); card.appendChild(techGroup); // Dates et heures : aujourd'hui ou le jour affiché, 08:00-18:00 const today = state.currentDate || todayISO(); const dateGroup = document.createElement("div"); dateGroup.className = "modal-form-group"; const dateLabel = document.createElement("label"); dateLabel.className = "modal-form-label"; dateLabel.textContent = "Date et heure de début"; dateGroup.appendChild(dateLabel); const dateRow1 = document.createElement("div"); dateRow1.className = "modal-form-row"; const startDate = document.createElement("input"); startDate.type = "date"; startDate.className = "modal-form-input"; startDate.id = "absence-start-date"; startDate.value = today; const startTime = document.createElement("input"); startTime.type = "time"; startTime.className = "modal-form-input"; startTime.id = "absence-start-time"; startTime.value = "08:00"; dateRow1.appendChild(startDate); dateRow1.appendChild(startTime); dateGroup.appendChild(dateRow1); card.appendChild(dateGroup); const endGroup = document.createElement("div"); endGroup.className = "modal-form-group"; const endLabel = document.createElement("label"); endLabel.className = "modal-form-label"; endLabel.textContent = "Date et heure de fin"; endGroup.appendChild(endLabel); const endRow = document.createElement("div"); endRow.className = "modal-form-row"; const endDate = document.createElement("input"); endDate.type = "date"; endDate.className = "modal-form-input"; endDate.id = "absence-end-date"; endDate.value = today; const endTime = document.createElement("input"); endTime.type = "time"; endTime.className = "modal-form-input"; endTime.id = "absence-end-time"; endTime.value = "18:00"; endRow.appendChild(endDate); endRow.appendChild(endTime); endGroup.appendChild(endRow); card.appendChild(endGroup); // v5.0.4 : presets rapides pour les horaires (matin / après-midi / journée) const presetGroup = document.createElement("div"); presetGroup.className = "modal-form-group"; const presetLabel = document.createElement("label"); presetLabel.className = "modal-form-label"; presetLabel.textContent = "Presets rapides"; presetGroup.appendChild(presetLabel); const presetRow = document.createElement("div"); presetRow.className = "modal-form-row modal-preset-row"; const presets = [ { label: "Matin", start: "08:00", end: "12:00" }, { label: "Après-midi", start: "13:00", end: "18:00" }, { label: "Toute la journée", start: "08:00", end: "18:00" } ]; for (const p of presets) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn btn-secondary modal-preset-btn"; btn.textContent = p.label; btn.addEventListener("click", () => { startTime.value = p.start; endTime.value = p.end; // Synchroniser visuellement la mise à jour et déclencher // endDateTouched si besoin (la date reste inchangée) startTime.dispatchEvent(new Event("input", { bubbles: true })); endTime.dispatchEvent(new Event("input", { bubbles: true })); }); presetRow.appendChild(btn); } presetGroup.appendChild(presetRow); card.appendChild(presetGroup); // v5.0.0 : la date de fin suit la date de début tant que l'user ne l'a // pas explicitement modifiée. 95% des absences sont d'un seul jour, donc // changer juste le start doit mettre à jour le end aussi. let endDateTouched = false; endDate.addEventListener("input", () => { endDateTouched = true; }); startDate.addEventListener("input", () => { if (!endDateTouched || endDate.value < startDate.value) { endDate.value = startDate.value; } }); // Type d'absence const typeGroup = document.createElement("div"); typeGroup.className = "modal-form-group"; const typeLabel = document.createElement("label"); typeLabel.className = "modal-form-label"; typeLabel.textContent = "Type d'absence"; typeGroup.appendChild(typeLabel); const typeSelect = document.createElement("select"); typeSelect.className = "modal-form-select"; typeSelect.id = "absence-type-select"; const emptyOpt = document.createElement("option"); emptyOpt.value = ""; emptyOpt.textContent = "— Choisir un type —"; typeSelect.appendChild(emptyOpt); for (const t of HOLIDAY_TYPES) { const opt = document.createElement("option"); opt.value = t.guid; opt.textContent = t.label; typeSelect.appendChild(opt); } typeGroup.appendChild(typeSelect); card.appendChild(typeGroup); // Boutons Appliquer / Annuler const actions = document.createElement("div"); actions.className = "modal-actions horizontal"; const cancelBtn = document.createElement("button"); cancelBtn.type = "button"; cancelBtn.className = "btn btn-modal-cancel"; cancelBtn.textContent = "Annuler"; cancelBtn.addEventListener("click", () => overlay.remove()); const applyBtn = document.createElement("button"); applyBtn.type = "button"; applyBtn.className = "btn btn-modal-primary"; applyBtn.textContent = "Appliquer"; applyBtn.addEventListener("click", async () => { // Validation const techIds = getCheckedTechIds(techList); if (techIds.length === 0) { showAlertModal({ title: "Sélection manquante", message: "Choisissez au moins un technicien.", buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); return; } if (!typeSelect.value) { showAlertModal({ title: "Sélection manquante", message: "Choisissez un type d'absence.", buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); return; } const sd = startDate.value, st = startTime.value; const ed = endDate.value, et = endTime.value; if (!sd || !st || !ed || !et) { showAlertModal({ title: "Dates/heures manquantes", message: "Remplissez toutes les dates et heures.", buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); return; } // v5.0.0 : validation fin >= début pour ne pas envoyer des absences // inversées à EasyVista (il les accepte mais elles n'apparaissent jamais // dans le planning, cf bug constaté). if (ed < sd || (ed === sd && et <= st)) { showAlertModal({ title: "Dates incohérentes", message: "La date/heure de fin doit être après la date/heure de début.", buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); return; } // Désactiver le bouton pendant l'envoi applyBtn.disabled = true; applyBtn.textContent = "Envoi…"; try { await submitAbsence({ techIds: techIds, startDate: sd, startTime: st, endDate: ed, endTime: et, typeGuid: typeSelect.value }); overlay.remove(); showToast("Absence créée", techIds.length + " tech" + (techIds.length > 1 ? "s" : "")); // v5.0.0 : reload le planning DE LA DATE AFFICHÉE AVANT (dateAtOpen), // pas de state.currentDate qui a pu être modifié entre-temps (bug // où le planning sautait à la date de début de l'absence). if (state.session) { await loadForDate(dateAtOpen, { forceRefetch: true }); } } catch (err) { applyBtn.disabled = false; applyBtn.textContent = "Appliquer"; showAlertModal({ title: "Erreur lors de la création", message: "Impossible de créer l'absence : " + (err.message || err), buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); } }); actions.appendChild(cancelBtn); actions.appendChild(applyBtn); card.appendChild(actions); overlay.appendChild(card); document.body.appendChild(overlay); overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); const escHandler = (e) => { if (e.key === "Escape") { overlay.remove(); document.removeEventListener("keydown", escHandler); } }; document.addEventListener("keydown", escHandler); } /** * Envoie la requête de création d'absence à EasyVista. * Appelle le background script qui fait le POST avec la bonne session. */ async function submitAbsence(opts) { const resp = await sendMessage({ type: "submitAbsence", techIds: opts.techIds, startDate: isoToEvDate(opts.startDate), startTime: opts.startTime + ":00", // HH:MM:SS endDate: isoToEvDate(opts.endDate), endTime: opts.endTime + ":00", typeGuid: opts.typeGuid, currentDate: isoToEvDate(opts.startDate) }); if (!resp || !resp.ok) { throw new Error(resp && resp.error ? resp.error : "erreur inconnue"); } return resp; } /** * Ouvre la modal "Envoyer la planification sur la douchette". */ function showDouchetteModal() { const existing = document.getElementById("douchette-modal"); if (existing) existing.remove(); const overlay = document.createElement("div"); overlay.id = "douchette-modal"; overlay.className = "modal-overlay"; const card = document.createElement("div"); card.className = "modal-card"; card.setAttribute("role", "dialog"); const title = document.createElement("h2"); title.className = "modal-title"; title.textContent = "Envoyer la planification sur la douchette"; card.appendChild(title); const msg = document.createElement("p"); msg.className = "modal-message"; msg.textContent = "Choisissez le ou les techniciens qui recevront la planification du jour sur leur douchette."; card.appendChild(msg); const techGroup = document.createElement("div"); techGroup.className = "modal-form-group"; const techList = buildTechCheckboxList({ selectAll: true }); techGroup.appendChild(techList); card.appendChild(techGroup); // Boutons const actions = document.createElement("div"); actions.className = "modal-actions horizontal"; const cancelBtn = document.createElement("button"); cancelBtn.type = "button"; cancelBtn.className = "btn btn-modal-cancel"; cancelBtn.textContent = "Annuler"; cancelBtn.addEventListener("click", () => overlay.remove()); const sendBtn = document.createElement("button"); sendBtn.type = "button"; sendBtn.className = "btn btn-modal-primary"; sendBtn.textContent = "Envoyer"; sendBtn.addEventListener("click", async () => { const techIds = getCheckedTechIds(techList); if (techIds.length === 0) { showAlertModal({ title: "Sélection manquante", message: "Choisissez au moins un technicien.", buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); return; } sendBtn.disabled = true; sendBtn.textContent = "Envoi…"; try { const result = await submitDouchette(techIds); overlay.remove(); if (result && result.okCount > 0) { showToast( "Envoyé sur douchette", result.okCount + "/" + techIds.length + " tech" + (techIds.length > 1 ? "s" : "") ); } if (result && result.errors && result.errors.length > 0) { showAlertModal({ title: "Envoi partiellement échoué", message: result.errors.length + " tech(s) n'ont pas pu recevoir : " + result.errors.map(e => TEAM[e.techId] || e.techId).join(", "), buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); } } catch (err) { sendBtn.disabled = false; sendBtn.textContent = "Envoyer"; showAlertModal({ title: "Erreur lors de l'envoi", message: "Impossible d'envoyer sur la douchette : " + (err.message || err), buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); } }); actions.appendChild(cancelBtn); actions.appendChild(sendBtn); card.appendChild(actions); overlay.appendChild(card); document.body.appendChild(overlay); overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); const escHandler = (e) => { if (e.key === "Escape") { overlay.remove(); document.removeEventListener("keydown", escHandler); } }; document.addEventListener("keydown", escHandler); } /** * Envoie la planification sur la douchette de plusieurs techniciens. * Retourne { okCount, errors: [{techId, error}] }. */ async function submitDouchette(techIds) { const resp = await sendMessage({ type: "submitDouchette", techIds: techIds, currentDate: isoToEvDate(state.currentDate || todayISO()) }); if (!resp || !resp.ok) { throw new Error(resp && resp.error ? resp.error : "erreur inconnue"); } return resp; } // ============================================================================ // 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 = {}) { // v4.3.1 : changer de date fermait tous les popups épinglés. // v2026.5.17 : les popups épinglés restent maintenant ouverts entre dates, // avec les données qu'ils avaient au moment de l'épinglage. // v2026.5.18 : au changement de date, on réduit tous les popups épinglés // dans la taskbar du bas (l'user peut les re-agrandir au clic). const previousDate = state.currentDate; if (previousDate && previousDate !== isoDate) { _reduceAllPinnedPopups(); } state.currentDate = isoDate; document.getElementById("date-picker").value = isoDate; updateDatePickerDayLabel(isoDate); // v2026.5.16 : label "Mardi" à côté if (!state.session) { // v4.2.5 : afficher le cache + bannière, plutôt que l'écran "session" const cached = await readCache(isoDate); if (cached) { renderFromData({ techs: cached.techs, targetDate: isoDate, captureTime: cached.savedAt || null, source: "cache" }); showSessionExpiredBanner(); } else { 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.2.5 : AVANT de retirer les ghosts, on lance une analyse de chaque // ghost pour déterminer si c'est : // - un ticket TERMINÉ par le tech (→ garder en vert ✓ simple) // - un ticket CLÔTURÉ/RÉSOLU dans EasyVista (→ garder en vert ✓✓ double) // - un ticket DÉPLACÉ (action ouverte au même tech autre jour) → retirer // - un ticket ANNULÉ / autre → retirer // L'analyse est asynchrone (re-fetch de chaque fiche) : on la lance en // arrière-plan APRÈS le rendu initial pour ne pas bloquer l'UI. // En attendant, les ghosts restent visibles avec un indicateur "en cours // d'analyse" (petit spinner / opacité réduite). const ghostsToAnalyze = []; for (const tech of merged.techs) { for (const iv of tech.interventions) { if (iv.ghost) { iv._disappearChecking = true; // marquer "en cours d'analyse" ghostsToAnalyze.push({ tech, iv }); } } } // 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`); // v4.2.5 : analyser les ghosts (tickets disparus du planning) pour décider // s'il faut les garder en vert (terminés par tech / clôturés) ou les // retirer définitivement (déplacés / annulés). Asynchrone en arrière-plan. if (ghostsToAnalyze.length > 0 && !isRefreshAborted(myToken)) { console.log(`[load] analyse de ${ghostsToAnalyze.length} ticket(s) disparu(s)…`); analyzeDisappearedInterventions(merged.techs, ghostsToAnalyze, myToken) .then(() => { if (!isRefreshAborted(myToken)) { renderFromData({ techs: merged.techs, targetDate: isoDate, captureTime: Date.now(), source: "fresh", lastRefreshKind: activeRefreshButton }); writeCache(isoDate, { techs: merged.techs }).catch(() => {}); } }) .catch(err => console.error("[disappear-analysis]", err)); } // 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 ) ); // v5.0.6 : logs détaillés pour diagnostiquer pourquoi le fetch ne se // lance pas. const totalIv = merged.techs.reduce((s, t) => s + (t.interventions || []).length, 0); const totalInterIv = merged.techs.reduce((s, t) => s + (t.interventions || []).filter(i => i.type === "AL-Intervention").length, 0); const notFetched = merged.techs.reduce((s, t) => s + (t.interventions || []).filter(i => i.type === "AL-Intervention" && !i.ficheFetched).length, 0); console.log(`[load] merged : ${merged.techs.length} techs, ${totalIv} iv totales, ${totalInterIv} interventions réelles, ${notFetched} sans fiche`); console.log(`[load] needFetch = ${needFetch} | doStatusRefresh = ${!!opts.doStatusRefresh} | forceRefetch = ${!!opts.forceRefetch} | aborted = ${isRefreshAborted(myToken)}`); // v4.3.2 : avant de lancer le fetch des fiches (lourd, ~460 KB chacune), // on fait d'abord un batch xhr2 (léger, ~2-5 KB chacun) pour récupérer // les vraies infos contact/lieu de toutes les interventions en parallèle. if (!isRefreshAborted(myToken)) { await prefetchAllXhr2(merged.techs, myToken, opts.doStatusRefresh); } 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)…`); await refreshStatuses(merged.techs, isoDate, { forceAll: !!opts.doStatusRefresh, myToken }); console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`); } else { console.log(`[load] PAS DE FETCH : needFetch=${needFetch}, doStatusRefresh=${!!opts.doStatusRefresh}, aborted=${isRefreshAborted(myToken)}`); } // 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) { // v4.2.5 : si le planning du jour est DÉJÀ rendu (cache), on affiche // une bannière non bloquante en haut, le cache reste visible. // Si rien n'est rendu (1er chargement, pas de cache), on affiche // l'écran plein comme avant. const hasCacheRendered = document.getElementById("cards") && document.getElementById("cards").children.length > 0; if (resp.error === "no_session" || resp.error === "session_expired") { state.session = null; if (hasCacheRendered) { showSessionExpiredBanner(); } else { showSessionNeeded(); } } else if (resp.error === "ev_unreachable") { if (hasCacheRendered) { showEvUnreachableBanner(); } else { 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 sesson 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 sesson 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; const hasCacheRendered = document.getElementById("cards") && document.getElementById("cards").children.length > 0; if (hasCacheRendered) { showSessionExpiredBanner(); } else { 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" vs "Absence". // // v5.0.3 (simplifiée) : le label suit le pattern "Nom / Créé par : X Y". // // - Congés / Maladie / Pompier → AL-Absence (tech réellement absent) // - TOUT LE RESTE (Ecrans, PC, MAC, Rollout, Téléphones, UTP, Réunion, // Déménagement, Evènements spéciaux, Formation, ...) // → AL-Reservation (créneau bloqué, tech pas absent) // // Cette règle simple évite les cas "absence toute la journée" déclenchés // par erreur pour des réservations de type événement / réunion. const ABSENCE_LABELS = /^(cong[ée]s|maladie|pompier)$/i; 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(); if (ABSENCE_LABELS.test(label1)) { // Vraie absence du tech effectiveType = "AL-Absence"; } else { // Réservation : créneau bloqué (matériel ou activité), tech pas absent 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. // 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) // 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), // 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 interventoin 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)) { // v5.0.1 : les absences et réservations supprimées côté EasyVista // sont définitivement retirées (pas ghost). La logique ghost est // conçue pour les interventions dont on veut garder trace en attendant // la vérification du statut (clos/annulé). Absences/réservations n'ont // pas de notion de statut, une disparition = suppression pure. if (iv.type === "AL-Absence" || iv.type === "AL-Reservation") { continue; // ne pas rajouter } const ghost = { ...iv, ghost: true }; outTech.interventions.push(ghost); } } // Retrier outTech.interventions.sort((a, b) => (a.startTime || "").localeCompare(b.startTime || "") ); } return { techs: resultTechs }; } // ============================================================================ // v4.2.5 : analyse des tickets disparus du planning // ============================================================================ // // Pour chaque ticket qui était dans le cache mais n'est plus dans le XML // fresh, on doit décider s'il faut : // 1. Le GARDER en vert double ✓✓ → clôturé / résolu dans EasyVista // 2. Le GARDER en vert simple ✓ → terminé par le tech (commentaire LOGIN:) // 3. Le RETIRER → déplacé sur un autre jour / annulé / autre // // Logique (validée avec l'utilisateur) : // a) Re-fetch la fiche // b) Si statut global = CLOS ou RÉSOLU → garder, vert ✓✓ // c) Sinon parcourir les actions OUVERTES de la fiche : // - Si action ouverte au nom du tech sur JOUR DIFFÉRENT → retirer (déplacée) // - Sinon passer à l'étape d // d) Parcourir les actions FERMÉES au nom du tech : // - Si une action fermée contient un commentaire tech (pattern `LOGIN: // commentaire` où LOGIN = alphanumérique 3-12 chars minuscule) → garder, vert ✓ // - Sinon → retirer // // Distinction action ouverte/fermée : // Observation sur les HTML fournis : dans le JSON timeline de la fiche, // l'action "AL-Intervention" apparaît SEULEMENT si elle a été complétée // (fermée). Si elle est toujours ouverte, elle n'est pas dans le timeline. // Les autres types d'actions ("Ajout d'informations", "Envoi de mail", etc.) // apparaissent dès leur création. // Regex pour détecter un commentaire tech dans le texte d'une action. // Pattern : début de ligne OU
suivi d'un login court (3-12 caractères // alphanumériques MINUSCULES) + ":" + espace + texte. // Exemples qui matchent : "vyjuva: Casque remplacé", "awr: ok". // Exemples qui NE matchent PAS : // - "Service : X" (majuscule + pas un login) // - "Nom2, Prénom2" (contient une virgule, pas un login) // - "AWR 16/04/26" (pas de deux-points) // - "Date : vendredi 17.04" (majuscule au début, c'est un champ) const RX_LOGIN_COMMENTAIRE = /(?:^|\n|)\s*([a-z0-9_]{3,12})\s*:\s+(\S[^\n<]{2,})/im; /** * Extrait toutes les actions d'une fiche en parsant les blocs "rows" du HTML. * Chaque action a 14 values : * [2] = Intervenant (ex: "Nom, Prénom" ou "EZV_WS_REST_USER") * [4] = Type d'action (ex: "AL-Intervention", "Ajout d'informations") * [8] = Date de création (JJ/MM/AAAA HH:MM:SS) * [9] = Date de fin * [11] = Description HTML (contient le texte de l'action + commentaire tech) * * Retourne : [ { intervenant, type, dateCreation, dateFin, description }, ... ] */ function parseAllActionsFromFicheHtml(html) { if (!html) return []; // Décoder : dans le HTML, les JSON imbriqués ont \u0022 pour " et \/ pour / const decoded = html .replace(/\\u0022/g, '"') .replace(/\\\//g, '/'); const actions = []; // Chercher chaque bloc "rows":[...] const rowsRegex = /"rows":\[/g; let m; while ((m = rowsRegex.exec(decoded)) !== null) { const start = m.index + m[0].length; // Trouver la fin du array [...] correspondant (balance des crochets) let j = start; let depth = 1; while (j < decoded.length && depth > 0) { const c = decoded[j]; if (c === '[') depth++; else if (c === ']') depth--; j++; } const block = decoded.substring(start, j - 1); const values = extractValuesFromRowBlock(block); if (values.length < 12) continue; // Une "vraie" action a 14 valeurs. On se contente de 12 minimum // pour avoir au moins la description. actions.push({ intervenant: decodeUnicodeEscapes(values[2] || ""), type: decodeUnicodeEscapes(values[4] || ""), dateCreation: values[8] || "", dateFin: values[9] || "", description: values[11] || "" }); } return actions; } /** * Extrait les valeurs "value":"..." d'un bloc JSON row, gère les guillemets * échappés (\"). */ function extractValuesFromRowBlock(block) { const values = []; let i = 0; while (i < block.length) { const mIdx = block.indexOf('"value":"', i); if (mIdx < 0) break; const start = mIdx + '"value":"'.length; let j = start; while (j < block.length) { if (block[j] === '\\') { j += 2; continue; } if (block[j] === '"') break; j++; } values.push(block.substring(start, j)); i = j + 1; } return values; } /** * Décode les échappements Unicode \u00XX présents dans les valeurs extraites. */ function decodeUnicodeEscapes(s) { if (!s) return s; return s.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16))); } /** * Détermine si une action est "fermée" ou "ouverte". * - Pour AL-Intervention : on cherche sa présence dans le JSON timeline de * la fiche (via la valeur [13] qui contient un JSON avec "NAME".) Si cette * action existe dans le JSON, elle est considérée fermée. * - Pour les autres types : on considère fermée si dateFin est remplie et * différente de dateCreation (approximation raisonnable observée sur les * HTML fournis). * - Actions système (Intervenant = "EZV_WS_REST_USER" ou vide) : ignorées * dans le matching "action au nom du tech". * * Pour notre logique, ce qui compte vraiment : * - Actions "AL-Intervention" fermées = présentes dans le bloc JSON * "timeline" de la fiche (pas dans les "rows" HTML, qui les listent toutes) * * Plus simplement, je détecte la présence de AL-Intervention dans le HTML * comme indicateur : si `"NAME":"AL-Intervention"` figure dans le JSON * timeline, alors l'AL-Intervention est fermée. */ function hasClosedAlInterventionInHtml(html) { if (!html) return false; // Chercher dans le HTML brut (non décodé) le pattern de timeline // `\u0022NAME\u0022:\u0022AL-Intervention\u0022` return /\\u0022NAME\\u0022:\\u0022AL-Intervention\\u0022/.test(html); } /** * Vérifie si le texte d'une action contient un commentaire tech au format * `LOGIN: commentaire`. Nettoie d'abord le HTML de la description. */ function hasTechCommentInDescription(description) { if (!description) return false; // Décoder unicode puis remplacer les
par \n pour faciliter le regex const txt = decodeUnicodeEscapes(description) .replace(//gi, '\n') .replace(/<\/?p[^>]*>/gi, '\n') .replace(/<[^>]+>/g, '') .replace(/ /g, ' ') .replace(/&/g, '&'); return RX_LOGIN_COMMENTAIRE.test(txt); } /** * Normalise un nom "Nom, Prénom" (insensible à la casse, accents ignorés) * pour comparaison. */ function normalizeName(s) { if (!s) return ""; return s .toLowerCase() .normalize("NFD").replace(/[\u0300-\u036f]/g, "") .replace(/\s+/g, " ") .trim(); } /** * Détermine si une action est au nom du technicien donné. * Compare l'intervenant de l'action avec le nom du tech (insensible casse/accents). * Ignore les actions système (EZV_WS_REST_USER, vide). */ function actionBelongsToTech(action, techName) { const interv = normalizeName(action.intervenant); if (!interv || interv === "ezv_ws_rest_user") return false; const tech = normalizeName(techName); if (!tech) return false; // Le nom du tech dans notre config est souvent "Prénom Nom" alors que // l'EasyVista affiche "Nom, Prénom". On accepte les deux ordres. // Simple test : au moins un mot du nom tech (longueur > 2) est dans l'intervenant. const techParts = tech.split(/[\s,]+/).filter(p => p.length >= 3); if (techParts.length === 0) return false; // Exiger que TOUS les mots significatifs du nom tech soient dans l'intervenant return techParts.every(p => interv.includes(p)); } /** * Analyse les tickets disparus du planning et décide pour chacun s'il faut * le garder en vert (terminé tech ou clôturé) ou le retirer. * * Modifie directement les tech.interventions en place (retire les ghosts à * retirer, met à jour les propriétés des ghosts à garder). */ async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken) { // Traiter en parallèle pour rester rapide (max 3 fiches en parallèle) const concurrency = 3; const queue = [...ghostsToAnalyze]; const workers = []; for (let w = 0; w < concurrency; w++) { workers.push((async () => { while (queue.length > 0) { if (isRefreshAborted(myToken)) return; const { tech, iv } = queue.shift(); try { await analyzeOneDisappearedIv(tech, iv); } catch (err) { console.warn("[disappear] analyse échouée pour", iv.actionId, err); // En cas d'erreur, on garde l'iv visible mais sans marquage spécial iv._disappearChecking = false; iv.ghost = false; // on la laisse visible plutôt que perdre de l'info iv._disappearStatus = "error"; } } })()); } await Promise.all(workers); // Filtrer les iv qui doivent être retirées définitivement for (const tech of techs) { tech.interventions = tech.interventions.filter(iv => !iv._disappearRemove); } } /** * Analyse une seule intervention disparue. * Met à jour iv._disappearStatus ("closed" | "terminated" | "moved" | "cancelled") * et iv._disappearRemove (true si à retirer). */ async function analyzeOneDisappearedIv(tech, iv) { // v4.3.0 : court-circuit pour les réservations (AL-Reservation). Elles n'ont // pas de notion de "terminé par tech" ni de statut clos/résolu à afficher // (pas de fiche à ouvrir). Quand une réservation disparaît du planning, // elle est juste retirée — inutile de re-fetcher sa fiche. if (iv.type === "AL-Reservation") { iv._disappearChecking = false; iv._disappearStatus = "cancelled"; iv._disappearRemove = true; return; } // Étape 1 : re-fetch la fiche const resp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink }); if (!resp || !resp.ok) { // En cas d'erreur fetch : on garde visible (pas de décision) iv._disappearChecking = false; iv._disappearStatus = "error"; iv.ghost = false; return; } const html = resp.html; // Étape 2 : statut global de la fiche const ficheData = parseFicheHtml(html); const status = ficheData.status || iv.status || null; iv.status = status; // garder à jour if (isClosedStatus(status) || isResolvedStatus(status)) { // CAS 1 : clôturé / résolu → garder, vert ✓✓ (double check) iv._disappearChecking = false; iv._disappearStatus = "closed"; iv._disappearRemove = false; iv.ghost = false; return; } // Étape 3 : parser toutes les actions de la fiche const actions = parseAllActionsFromFicheHtml(html); // Identifier les actions AL-Intervention au nom du tech. // // Pour savoir si une AL-Intervention spécifique est fermée ou ouverte, // on utilise l'indicateur global `hasClosedAlInterventionInHtml` : // - SI la fiche contient "AL-Intervention" dans le JSON timeline // → l'action AL-Intervention est fermée (terminée par le tech) // - SINON → elle est encore ouverte const alActionsForTech = actions.filter(a => a.type === "AL-Intervention" && actionBelongsToTech(a, tech.name || tech.label || "") ); const hasClosedAl = hasClosedAlInterventionInHtml(html); // CAS 2 : action AL-Intervention encore ouverte au nom du tech if (alActionsForTech.length > 0 && !hasClosedAl) { // Vérifier sur quel jour elle est planifiée maintenant. Si on ne peut // pas déterminer, on retire par prudence (elle a été bougée, sinon // elle serait encore dans le fresh). // On regarde si une action ouverte référence explicitement notre jour. // Simple heuristique : on regarde les dates dans les descriptions. iv._disappearChecking = false; iv._disappearStatus = "moved"; iv._disappearRemove = true; // retirer (déplacée) return; } // CAS 3 : action AL-Intervention FERMÉE au nom du tech → chercher un // commentaire tech dans les descriptions des actions du tech. if (alActionsForTech.length > 0 && hasClosedAl) { const anyHasComment = alActionsForTech.some(a => hasTechCommentInDescription(a.description) ); if (anyHasComment) { // Terminée par le tech → garder, vert ✓ simple iv._disappearChecking = false; iv._disappearStatus = "terminated"; iv._disappearRemove = false; iv.ghost = false; return; } // Pas de commentaire détecté → retirer (annulée) iv._disappearChecking = false; iv._disappearStatus = "cancelled"; iv._disappearRemove = true; return; } // CAS 4 : aucune action AL-Intervention au nom du tech dans la fiche → // vérifier si une action quelconque au nom du tech existe avec commentaire. // Si oui, on considère que le tech a travaillé dessus. const anyActionForTech = actions.filter(a => actionBelongsToTech(a, tech.name || tech.label || "") ); const anyHasComment = anyActionForTech.some(a => hasTechCommentInDescription(a.description) ); if (anyHasComment) { iv._disappearChecking = false; iv._disappearStatus = "terminated"; iv._disappearRemove = false; iv.ghost = false; return; } // CAS 5 (défaut) : aucune trace claire du tech → retirer iv._disappearChecking = false; iv._disappearStatus = "cancelled"; iv._disappearRemove = true; } // ============================================================================ // 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 "rafraichir" 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 // rafraichir la date actuellement affichée. Si l'user change de date // pdt 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 pdt 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 interventoin 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; // v4.2.5 : on retire définitivement le champ commentaireTech (obsolète // depuis qu'on récupère l'action complète via l'API timeline). delete iv.commentaireTech; // 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 apel 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). */ // v4.3.2 : pré-fetch de tous les xhr2 en parallèle (batch). // Objectif : avoir les VRAIES infos contact/lieu pour toutes les interventions // AVANT que l'utilisateur se mette à les survoler. Comme le xhr2 est léger // (2-5 KB), on peut en faire plusieurs en parallèle sans écrouler EasyVista. // // Params : // techs : liste des techs avec leurs interventions // myToken : jeton d'annulation (si l'user change de date, on s'arrête) // forceAll : si true, re-fait le xhr2 même pour les inter déjà xhr2Fetched // (utilisé par "Tout recharger") async function prefetchAllXhr2(techs, myToken, forceAll) { if (!techs) return; // Lister les iv qui ont besoin d'un xhr2 const needed = []; for (const tech of techs) { for (const iv of tech.interventions || []) { if (iv.type !== "AL-Intervention") continue; if (!iv.actionId || iv.ghost) continue; if (iv.xhr2Fetching) continue; if (iv.xhr2Fetched && !forceAll) continue; needed.push(iv); } } if (needed.length === 0) return; console.log(`[load] pré-fetch xhr2 batch : ${needed.length} interventoin(s)…`); const t0 = performance.now(); // Si forceAll, reset le flag pour que ensureBulleDescription re-fetch if (forceAll) { for (const iv of needed) iv.xhr2Fetched = false; } // Batch en parallèle avec concurrency limitée (6) — assez rapide, pas trop // aggressif sur EasyVista. const concurrency = 6; const queue = [...needed]; const workers = []; for (let w = 0; w < concurrency; w++) { workers.push((async () => { while (queue.length > 0) { if (isRefreshAborted(myToken)) return; const iv = queue.shift(); try { await ensureBulleDescription(iv); } catch (err) { console.warn("[prefetch xhr2] iv", iv.actionId, err); } } })()); } await Promise.all(workers); console.log(`[load] pré-fetch xhr2 fini en ${Math.round(performance.now() - t0)} ms`); } 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 "rafraichissement 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 rafraichissement du texte "MAJ HH:MM" ou "rafraichissement 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 pdt 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 vriament * 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"); // v4.3.0 : détecter les conflits d'horaire entre interventions d'un même // tech (même heure de début OU chevauchement). detectOverlaps(data.techs); // Calculer les stats const stats = computeStats(data.techs, data.targetDate); renderCaptureInfo(data, stats); renderStats(stats); renderCards(data); } // v4.3.0 : détection des conflits d'horaire entre interventions d'un même tech. // Marque iv._hasOverlap = true pour chaque intervention en conflit avec une // autre (même heure de début OU chevauchement de créneaux). // Les absences récurrentes, tickets fantômes à retirer, et réservations // sont ignorés (pas de conflit pertinent pour eux). function detectOverlaps(techs) { if (!techs) return; for (const tech of techs) { const ivs = (tech.interventions || []).filter(iv => iv && iv.startTime && iv.endTime && !iv._disappearRemove && iv.type !== "AL-Reservation" && // v4.3.2 : le pompier est une absence "tolérée" qui chevauche par // nature les heures de travail (garde volontaire) — on l'exclut des // conflits. En revanche les congés/maladies/formations restent // détectés car une inter planifiée pdt une absence, c'est un vrai pb. !iv.isPompier ); // Reset flag sur toutes les inters du tech (y compris celles ignorées) for (const iv of (tech.interventions || [])) { iv._hasOverlap = false; } // Convertir HH:MM en minutes pour comparaison rapide const toMin = (hhmm) => { if (!hhmm) return null; const parts = hhmm.split(":"); if (parts.length < 2) return null; const h = parseInt(parts[0], 10); const m = parseInt(parts[1], 10); if (isNaN(h) || isNaN(m)) return null; return h * 60 + m; }; // Comparer chaque paire for (let i = 0; i < ivs.length; i++) { for (let j = i + 1; j < ivs.length; j++) { const a = ivs[i], b = ivs[j]; const aStart = toMin(a.startTime), aEnd = toMin(a.endTime); const bStart = toMin(b.startTime), bEnd = toMin(b.endTime); if (aStart === null || aEnd === null || bStart === null || bEnd === null) continue; // Chevauchement = a commence avant que b finisse ET b commence avant que a finisse. // Inclut aussi le cas "même heure de début" (aStart === bStart). if (aStart < bEnd && bStart < aEnd) { a._hasOverlap = true; b._hasOverlap = true; } } } } } 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) → "rafraichissement 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"); } // v5.0.13 : un tech est considéré "absent toute la journée" uniquement si une // absence couvre RÉELLEMENT du matin au soir (ou quasi), pas juste s'il a des // absences (éventuellement partielles). Avant, une absence matin 08-12 seule // faisait passer le tech en "absent toute la journée" car il n'avait QUE des // absences. Maintenant on check explicitement que l'absence couvre ≥ 90% de // la plage 08:00-18:00. 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; // Parmi les absences (hors pompier), est-ce qu'une seule couvre la journée ? const fullDayAbsences = tech.interventions.filter(iv => { if (iv.type !== "AL-Absence" || iv.isPompier) return false; const startMin = timeToMinutes(iv.startTime); const endMin = timeToMinutes(iv.endTime); if (startMin == null || endMin == null) { // Si on n'a pas d'horaires, on considère que c'est toute la journée // (cas des absences multi-jours sans horaires précis) return true; } // Absence couvre toute la journée si son créneau déborde largement // la plage affichée (≥ 90%). Une demi-journée (4h) sur 10h = 40% → ne // passera pas, donc on ne marquera pas le tech comme absent toute la journée. const DAY_LEN_MIN = 10 * 60; // 08:00 → 18:00 = 10h const clampedStart = Math.max(startMin, 8 * 60); const clampedEnd = Math.min(endMin, 18 * 60); const coveredMin = Math.max(0, clampedEnd - clampedStart); return coveredMin >= 0.9 * DAY_LEN_MIN; }); return fullDayAbsences.length > 0; } // ============================================================================ // 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); // v5.0.4 : tooltip au hover sur toute la carte absent (pas juste un // bouton visible). Contient : détail période + bouton supprimer si // c'est une absence supprimable (actionId réel, pas pompier récurrent). if (ab.actionId && !ab.isPompier && !ab._recurring) { // On attache le tooltip sur la CARD ENTIÈRE (card) — comme ça // survoler n'importe où sur la zone grisée "absent" le déclenche. const ivCopy = { ...ab, type: "AL-Absence" // force pour buildTooltipHTML }; card.addEventListener("mouseenter", (e) => { showTooltip(e, ivCopy, card); }); card.addEventListener("mouseleave", () => { hideTooltip(); }); } } // 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; } // v5.0.14 : si le tech n'a aucune intervention mais a des absences // partielles (demi-journée) ou pompier, on veut quand même afficher la // timeline avec les blocs absence visibles. Sans ça, une absence 08-12 // seule n'apparaissait jamais sur la carte (affichait juste "Pas // d'intervention planifiée"). const hasPartialAbsences = absenceBlocks.some(ab => { if (ab.isPompier) return false; const s = timeToMinutes(ab.startTime); const e = timeToMinutes(ab.endTime); if (s === null || e === null) return false; // Absence qui couvre PAS toute la journée → c'est partiel return !(s <= DAY_START && e >= DAY_END); }); if (realInterventions.length === 0 && !isPompier && !hasPartialAbsences) { 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 = `
${realInterventions.length} intervention${realInterventions.length > 1 ? "s" : ""}
${morning} matin · ${afternoon} après-midi
`; body.appendChild(stats); } // Liste interventions for (const iv of realInterventions) { body.appendChild(buildInterventionRow(iv, card)); } // v5.0.15 : afficher aussi les absences partielles (demi-journée) comme // des rows, avec le même style que les réservations mais en gris foncé. // Les absences qui couvrent toute la journée sont déjà traitées plus haut // (carte "Absent toute la journée") et ne doivent pas être dupliquées ici. if (!isAbsent) { const partialAbsences = absenceBlocks.filter(ab => { if (ab.isPompier) return false; const s = timeToMinutes(ab.startTime); const e = timeToMinutes(ab.endTime); if (s === null || e === null) return false; return !(s <= DAY_START && e >= DAY_END); }); // Trier par heure de début partialAbsences.sort((a, b) => (a.startTime || "").localeCompare(b.startTime || "")); for (const ab of partialAbsences) { body.appendChild(buildInterventionRow(ab, card)); } } card.appendChild(body); return card; } // ============================================================================ // Timeline // ============================================================================ // v5.0.0 : constantes timeline globales (avant : locales à buildTimeline), // pour que updateNowLine puisse les utiliser aussi. const DAY_START = 8 * 60; // 08:00 en minutes const DAY_END = 18 * 60; // 18:00 en minutes const DAY_LEN = DAY_END - DAY_START; function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) { 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) { // v4.2.5 : priorité aux statuts de disparition analysés if (iv._disappearStatus === "closed") return "status-closed"; if (iv._disappearStatus === "terminated") return "status-terminated"; if (iv._disappearStatus === "error") return null; 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 interventoin (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; // v4.2.4 : utiliser setTooltipViewportPosition pour bénéficier de la // détection automatique fixed/abs (et donc de la stabilité au scroll). setTooltipViewportPosition(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; // recupere 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/4 : ouvre la GRANDE popup au clic sur un segment timeline, ancrée // juste en dessous du segment. Pas épinglée : se ferme sur clic ailleurs, // Échap, OU quand la souris quitte la popup elle-même (mouseleave). 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; const tip = tooltipEl(); if (!tip) return; // Nettoyer tout état précédent (ancrage, épinglage, timers) bulleState.pinned = false; bulleState.hoveredInBulle = false; bulleState.hoveredInRow = false; if (bulleState.hideTimer) { clearTimeout(bulleState.hideTimer); bulleState.hideTimer = null; } tip.classList.remove("pinned"); // Construire la grande bulle tip.innerHTML = buildTooltipHTML(iv); tip.classList.remove("hidden"); tip.classList.add("visible"); // mode "anchored" : le hover ne doit pas la remplacer par une autre popup tip.dataset.mode = "anchored"; state.currentTooltipIv = iv; // Position : juste sous le segment timeline. D'abord on reset les coords // pour que getBoundingClientRect() reflète la vraie taille du nouveau // contenu. tip.style.left = "-9999px"; tip.style.top = "0px"; // Forcer un reflow pour que tipRect soit à jour avec le nouveau contenu const tipRect = tip.getBoundingClientRect(); const r = el.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) { y = r.top - tipRect.height - 8; } if (y < 4) y = 4; // Positionner proprement (avec détection auto fixed vs abs) setTooltipViewportPosition(x, y); } 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à ancrée (clic timeline), on ne // la remplace pas par la petite popup hover. if (tip.dataset.mode === "anchored") return; // v4.2.4 : annuler tout hideTimer en cours pour éviter que la popup // précédente, en train d'être masquée, masque AUSSI celle-ci juste après. // Problème typique quand on passe rapidement d'un segment à un autre. if (bulleState.hideTimer) { clearTimeout(bulleState.hideTimer); bulleState.hideTimer = null; } 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'interventoin // ============================================================================ 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"); // v4.3.3 : on ne marque plus les ghosts visuellement (classe is-ghost // retirée). Les tickets disparus sont soit retirés (_disappearRemove), // soit affichés en vert (_disappearStatus). Plus de barrage. // v4.2.5 : indicateur "en cours d'analyse" (ticket disparu, on re-fetch // la fiche pour décider de le garder en vert ou le retirer). if (iv._disappearChecking) row.classList.add("_checking"); 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 && iv.type !== "AL-Absence") { 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.type === "AL-Absence") { // v5.0.15 : absence partielle (demi-journée) affichée comme une row refHeader.textContent = "Absence"; refHeader.classList.add("is-absence-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 / absence) if (statusClass && iv.type !== "AL-Reservation" && iv.type !== "AL-Absence") { const statusEl = document.createElement("div"); statusEl.className = "iv-status-check"; // v4.2.5 : ✓✓ double pour clôturé/résolu (statut officiel EasyVista) // ✓ simple pour "terminé par tech" (commentaire LOGIN: détecté) if (statusClass === "status-closed" || statusClass === "status-resolved") { statusEl.textContent = "✓✓"; statusEl.classList.add("double"); } else { statusEl.textContent = "✓"; } row.appendChild(statusEl); } if (iv.ref && iv.type !== "AL-Reservation" && iv.type !== "AL-Absence") { 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"; // v4.3.0 : marquer rouge + icône ⚠ si conflit horaire détecté if (iv._hasOverlap) { timeEl.classList.add("iv-time-overlap"); } 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); // v4.3.0 : icône d'alerte à côté des heures si conflit if (iv._hasOverlap) { const warn = document.createElement("div"); warn.className = "iv-time-overlap-warn"; warn.textContent = "⚠"; warn.title = "Conflit d'horaire avec une autre intervention"; timeEl.appendChild(warn); } } 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; } // v5.0.15 : absence partielle (demi-journée) affichée comme une row au // même style que les réservations mais en gris foncé, avec le type d'absence // (Congés, Maladie, Pompier) comme sujet. if (iv.type === "AL-Absence") { // Bloc "Par Nom, Prénom" si on a un créateur if (iv.reservationCreator) { const parEl = document.createElement("div"); parEl.className = "iv-reservation-par"; parEl.textContent = "Par " + iv.reservationCreator; rightCol.appendChild(parEl); } // Type d'absence (Congés, Maladie, Pompier) si dispo dans label const absenceTypeMatch = (iv.label || "").match(/^([^/]+?)\s*(?:\/|$)/); const absenceType = absenceTypeMatch ? absenceTypeMatch[1].trim() : null; if (absenceType) { const sujetEl = document.createElement("div"); sujetEl.className = "iv-reservation-sujet"; sujetEl.textContent = "Type : " + absenceType; rightCol.appendChild(sujetEl); } row.appendChild(rightCol); // Tooltip au hover (avec bouton supprimer) 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) { // v4.2.5 : popup modale propre au lieu d'alert natif showAlertModal({ title: "Impossible d'ouvrir la fiche", message: "Votre session EasyVista a expiré. Reconnectez-vous à EasyVista puis réessayez.", buttons: [ { label: "Ouvrir EasyVista", variant: "primary", action: () => openEasyVista() }, { label: "Annuler", variant: "secondary", action: () => {} } ] }); return; } if (!iv.requestId) { showAlertModal({ title: "Impossible d'ouvrir la fiche", message: "L'identifiant de la fiche est manquant. Essayez d'actualiser le planning (bouton Actualiser).", buttons: [ { label: "OK", variant: "secondary", action: () => {} } ] }); 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) { // v4.2.5 : popup modale selon le type d'erreur if (ficheResp.error === "no_session" || ficheResp.error === "session_expired") { showAlertModal({ title: "Session EasyVista expirée", message: "Votre session a expiré pendant l'ouverture de la fiche. Reconnectez-vous à EasyVista puis réessayez.", buttons: [ { label: "Ouvrir EasyVista", variant: "primary", action: () => openEasyVista() }, { label: "Annuler", variant: "secondary", action: () => {} } ] }); } else if (ficheResp.error === "ev_unreachable") { showAlertModal({ title: "EasyVista inaccessible", message: "EasyVista est inaccessible pour le moment. Réessayez dans quelques instants.", buttons: [ { label: "Réessayer", variant: "primary", action: () => openInterventionInNewTab(iv, opts) }, { label: "Ouvrir EasyVista", variant: "secondary", action: () => openEasyVista() }, { label: "Annuler", variant: "secondary", action: () => {} } ] }); } else { showAlertModal({ title: "Impossible d'ouvrir la fiche", message: "Une erreur est survenue : " + (ficheResp.error || "inconnue"), buttons: [ { label: "OK", variant: "secondary", action: () => {} } ] }); } 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) { // v4.2.5 : le warning précédent était alarmiste pour rien. // Tentative 1 peut légitimement échouer (cache stale côté EV). // On log en info, on retry, et en dernier recours on ouvre quand // même la fiche (avec un target de fallback) plutôt que de bloquer. console.info(`[click] tentative ${attempts}/${maxAttempts}: pattern target=${iv.requestId}&checksum=... introuvable dans HTML de la fiche (taille ${ficheResp.html.length})`); if (attempts >= maxAttempts) { // Fallback : tenter avec le requestId seul, sans checksum précis. // Ça ouvre une URL EasyVista valide qui redirige vers la fiche. console.info(`[click] fallback sans checksum précis pour ${iv.requestId}`); target = iv.requestId; checksum = null; break; } 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) { // v4.2.5 : popup modale au lieu d'alert showAlertModal({ title: "Erreur lors de l'ouverture de la fiche", message: "Une erreur s'est produite : " + (err && err.message ? err.message : String(err)), buttons: [ { label: "Réessayer", variant: "primary", action: () => openInterventionInNewTab(iv, opts) }, { label: "Annuler", variant: "secondary", action: () => {} } ] }); 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); // v4.2.5 : si on n'a pas pu extraire le checksum précis (fallback après // retry), on omet le paramètre checksum. EasyVista acceptera l'URL et // redirigera vers la fiche correspondant au target. const urlParts = [ `${session.origin}/index.php`, `?PHPSESSID=${encodeURIComponent(session.phpsessid)}`, `&internalurltime=${internalurltime}`, `&eventName=formEvent`, `&target=${encodeURIComponent(target)}`, ]; if (checksum) { urlParts.push(`&checksum=${encodeURIComponent(checksum)}`); } urlParts.push(`&sender=${sender}`); const url = urlParts.join(""); 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. // v2026.5.16 : ne PAS matcher si le numéro est précédé d'une lettre ou // d'un underscore (identifiants style XXXX_NNNNNNNN, ABC123456, // SERIAL_0123456789). On ajoute un lookbehind négatif (?= 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); // v2026.5.16 : dernier garde-fou — rejeter les "noms" qui ressemblent // à des fragments de description technique plutôt qu'à des vrais contacts. // Exemples rejetés : // - "1x" (quantité isolée) // - "1x pc" (quantité + type matériel) // - "pc XNNNNNN" (type + numéro de série) // - "XXXX_NNNNNNNN" (identifiant matériel) // Critères d'un vrai nom : contient au moins un mot qui commence par une // majuscule ET n'est pas juste un identifiant technique. if (name) { const looksLikeIdentifier = /^[A-Z]{2,}[_\-]\d+$/.test(name); // XXXX_NNNNNNNN const startsWithQuantity = /^\d+x(\s|$)/i.test(name); // "1x" ou "1x pc" const noCapitalWord = !/\b[A-ZÉÈÀÂÎÔÛÇ][a-zéèàâîôûç]+/.test(name); // aucun mot "Xxxxx" const hasOnlyTechTokens = /^(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout)(\s+(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout|[A-Z]\d+))*\s*$/i.test(name); if (looksLikeIdentifier || startsWithQuantity || hasOnlyTechTokens || (noCapitalWord && !phone)) { name = null; } } 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 }; // v2026.5.16 : le format EasyVista peut avoir jusqu'à 3 parties séparées // par "/" : VILLE / ADRESSE / PRÉCISIONS (étage, bureau, indications). // Exemple : "LAUSANNE / Av. de Beaulieu 19 / 4eme en face de l'ascenseur" // On ne garde que VILLE + ADRESSE. Les précisions (3e partie et suivantes) // sont strippées — elles alourdissent la carte et sont disponibles dans // le tooltip détaillé. const parts = s.split("/").map(p => p.trim()).filter(Boolean); let ville, adresse; if (parts.length === 0) { return { ville: null, adresse: null }; } else if (parts.length === 1) { // Pas de slash : tout est l'adresse ville = null; adresse = parts[0]; } else { // 2+ parties : ville = 1ère, adresse = 2e, on ignore le reste ville = parts[0]; adresse = parts[1]; } // 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) => { 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'interventoin 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", "status-terminated"); 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/mettre à jour selon statut let checkEl = row.querySelector(".iv-status-check"); if (sc) { // v4.2.5 : ✓✓ pour clos/résolu, ✓ pour terminé tech const isDouble = (sc === "status-closed" || sc === "status-resolved"); const desiredText = isDouble ? "✓✓" : "✓"; if (!checkEl) { checkEl = document.createElement("div"); checkEl.className = "iv-status-check"; // 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); } checkEl.textContent = desiredText; checkEl.classList.toggle("double", isDouble); } else if (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", "status-terminated", ...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) { // v2026.5.19 : pendant qu'un popup épinglé est en cours de drag, on ignore // les mouseenter sur les cartes — sinon en survolant une carte on déclenche // l'ouverture d'un nouveau tooltip par-dessus ce qu'on est en train de bouger. if (state._popupDragging) return; // 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 pdt 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 interventoin (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"); // v4.2.4 : reset du mode d'ancrage et de la détection de position if (el.dataset) { delete el.dataset.mode; } state.currentTooltipIv = null; currentTooltipPos = null; tooltipPositionMode = null; // re-détecter à la prochaine ouverture }, 1000); // v2026.5.17 : délai 1s au lieu de 120ms pour laisser le temps // à l'user d'atteindre le popup depuis la carte } // 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) { // Historique : avant on suivait la souris. Maintenant la bulle est fixe // (placée une seule fois au mouseenter). Cette fonction est là juste pour // pas casser les appels existants. } // ============================================================================ // Positionnement du tooltip // ============================================================================ // On positionne avec style.left/top en coords VIEWPORT (comme position:fixed). // Si un ancêtre casse position:fixed (transform, filter, backdrop-filter ou // contain), on détecte ça empiriquement au 1er placement via // getBoundingClientRect — et on bascule en "abs" : mêmes coords mais on // compense le scroll manuellement pour garder la bulle stable à l'écran. // ============================================================================ // Position stockée : targetLeft / targetTop = coordonnées VIEWPORT désirées // (où la popup doit apparaître à l'écran, peu importe le scroll). let currentTooltipPos = null; // Mode de positionnement, détecté empiriquement : // null : pas encore détecté // "fixed" : position:fixed marche → on laisse le navigateur gérer au scroll // "abs" : position:fixed cassée → on compense manuellement au scroll let tooltipPositionMode = null; function setTooltipViewportPosition(viewportX, viewportY) { const el = tooltipEl(); if (!el) return; currentTooltipPos = { x: viewportX, y: viewportY }; // Appliquer la position en supposant que position:fixed marche el.style.left = viewportX + "px"; el.style.top = viewportY + "px"; // Détection empirique au 1er positionnement : on compare la position // réelle du tooltip (getBoundingClientRect) à la position demandée. // Si ça correspond (à 1px près), position:fixed fonctionne. Sinon // c'est qu'un ancêtre a cassé le containing block. if (tooltipPositionMode === null) { const r = el.getBoundingClientRect(); const deltaX = Math.abs(r.left - viewportX); const deltaY = Math.abs(r.top - viewportY); if (deltaX <= 1 && deltaY <= 1) { tooltipPositionMode = "fixed"; } else { tooltipPositionMode = "abs"; console.info( "[tooltip] position:fixed cassée par un ancêtre, passage en mode compensé au scroll. " + `delta=(${deltaX.toFixed(1)}, ${deltaY.toFixed(1)})` ); } } // Si mode "abs" : le top/left qu'on vient de poser est en réalité interprété // par rapport au containing block (pas le viewport). On doit compenser // immédiatement pour placer la popup au bon endroit visuellement. if (tooltipPositionMode === "abs") { const r = el.getBoundingClientRect(); const offsetX = viewportX - r.left; // écart à corriger const offsetY = viewportY - r.top; // Nouvelle valeur absolute qui produit la position viewport voulue const absLeft = parseFloat(el.style.left) + offsetX; const absTop = parseFloat(el.style.top) + offsetY; el.style.left = absLeft + "px"; el.style.top = absTop + "px"; // Mémoriser pour compenser au scroll el._absBasisLeft = absLeft; el._absBasisTop = absTop; el._absBasisScrollX = window.scrollX || window.pageXOffset || 0; el._absBasisScrollY = window.scrollY || window.pageYOffset || 0; } } // Listener global scroll : si on est en mode "abs", on compense pour que la // popup reste visuellement au même endroit pendant le scroll. function reapplyTooltipPosition() { if (!currentTooltipPos) return; const el = tooltipEl(); if (!el || !el.classList.contains("visible")) return; if (tooltipPositionMode !== "abs") return; // fixed marche, rien à faire // Compenser le scroll : la popup doit rester à currentTooltipPos dans le // viewport. Pour ça, on ajoute l'écart entre le scroll actuel et le // scroll au moment de l'ancrage. const scrollX = window.scrollX || window.pageXOffset || 0; const scrollY = window.scrollY || window.pageYOffset || 0; const dx = scrollX - (el._absBasisScrollX || 0); const dy = scrollY - (el._absBasisScrollY || 0); el.style.left = ((el._absBasisLeft || 0) + dx) + "px"; el.style.top = ((el._absBasisTop || 0) + dy) + "px"; } 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; // v2026.5.17 : éviter le chevauchement avec les popups épinglés existants. // On teste la position candidate, et si elle chevauche un popup épinglé, // on essaie d'autres candidats (gauche de la carte, au-dessous, au-dessus). const tipW = tipRect.width || 320; const tipH = tipRect.height || 200; const pinnedRects = _getPinnedPopupsViewportRects(); if (pinnedRects.length) { const candidates = [ { x, y, label: "right" }, { x: rowRect.left - tipW - pad, y: rowRect.top, label: "left" }, { x: rowRect.left, y: rowRect.bottom + pad, label: "below" }, { x: rowRect.left, y: rowRect.top - tipH - pad, label: "above" } ]; for (const c of candidates) { // Borne dans le viewport if (c.x < 4) c.x = 4; if (c.x + tipW > window.innerWidth - 8) c.x = window.innerWidth - tipW - 8; if (c.y < 4) c.y = 4; if (c.y + tipH > window.innerHeight - 8) c.y = window.innerHeight - tipH - 8; const testRect = { left: c.x, top: c.y, right: c.x + tipW, bottom: c.y + tipH }; const overlaps = pinnedRects.some(pr => _rectsOverlap(testRect, pr)); if (!overlaps) { x = c.x; y = c.y; break; } } } setTooltipViewportPosition(x, y); } /** * v2026.5.17 : retourne les rectangles (en coords viewport) de tous les popups * actuellement épinglés et visibles (non réduits). Utilisé pour anti-chevauchement. */ function _getPinnedPopupsViewportRects() { const rects = []; document.querySelectorAll(".pinned-popup").forEach(p => { if (p.classList.contains("pinned-popup-reduced")) return; // docké, pas à l'écran const r = p.getBoundingClientRect(); if (r.width > 0 && r.height > 0) rects.push(r); }); return rects; } // ============================================================================ // v4.3.0 : système de popups épinglés détachés // ============================================================================ // // Au lieu d'épingler le tooltip unique (qui empêchait d'afficher d'autres // infos au survol), on clone son contenu en un popup indépendant : // - Ancré DANS le contenu de la page (position: absolute + coordonnées // document) → scrolle avec le contenu, pas avec le viewport. // - Peut coexister avec d'autres popups épinglés (jusqu'à ce qu'il n'y // ait plus de place disponible). // - Persiste jusqu'à fermeture explicite (bouton ×, Échap, ou Ctrl×2 si 1 seul). // // Le tooltip live (#tooltip) garde son rôle initial : il se ferme au mouseleave. const pinnedPopups = []; // [{el, iv, rect}] /** * Ancre la popup au contenu : ajoute le scrollY actuel au top viewport pour * obtenir une position absolute document, qui scrolle avec le contenu. */ function _viewportToDocumentY(y) { return y + (window.scrollY || window.pageYOffset || 0); } function _viewportToDocumentX(x) { return x + (window.scrollX || window.pageXOffset || 0); } /** * Teste si un rectangle {left, top, right, bottom} (en coords document) * chevauche avec un popup déjà épinglé. */ function _rectsOverlap(a, b) { return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom); } /** * Cherche une position libre pour un popup de dimensions {w, h} près de la * ligne source `rowEl`. Essaie dans l'ordre : droite, gauche, dessous, dessus. * Retourne {x, y} en coordonnées document, ou null si aucune position libre. */ function _findFreePopupPosition(rowEl, w, h) { const pad = 14; const rowRect = rowEl.getBoundingClientRect(); // v2026.5.20 : utiliser la safe area (en dessous topbar, au-dessus dock) const safe = _getPopupSafeArea(); // 4 candidats d'abord, autour de la row source (en coords viewport) const candidates = [ { x: rowRect.right + pad, y: rowRect.top, name: "droite" }, { x: rowRect.left - w - pad, y: rowRect.top, name: "gauche" }, { x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" }, { x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" } ]; // v2026.5.20 : ajouter une grille de positions de fallback couvrant toute // la safe area (pas de 60px × 60px) — garantit qu'on trouve ~toujours une // place, sauf si vraiment trop de popups actifs. const availW = safe.right - safe.left; const availH = safe.bottom - safe.top; if (availW > w + 20 && availH > h + 20) { for (let y = safe.top; y + h <= safe.bottom; y += 60) { for (let x = safe.left; x + w <= safe.right; x += 60) { candidates.push({ x, y, name: "grid" }); } } } // Tester chaque candidat dans l'ordre for (const c of candidates) { let x = c.x, y = c.y; // Clamp dans la safe area if (x < safe.left) x = safe.left; if (x + w > safe.right) x = safe.right - w; if (x < safe.left) continue; // popup plus large que safe area if (y < safe.top) y = safe.top; if (y + h > safe.bottom) y = safe.bottom - h; if (y < safe.top) continue; // Si, après clamp, la popup chevaucherait la ligne source elle-même, // on ignore ce candidat (on préfère une direction qui la laisse visible). const rowRectClamped = { left: rowRect.left, top: rowRect.top, right: rowRect.right, bottom: rowRect.bottom }; const candRect = { left: x, top: y, right: x + w, bottom: y + h }; // v2026.5.20 : pour les 4 candidats principaux, on refuse de chevaucher // la row ; pour les candidats "grid" de fallback, on l'accepte // (on veut une place à tout prix). if (c.name !== "grid" && _rectsOverlap(candRect, rowRectClamped)) continue; // Test chevauchement avec les popups déjà épinglés (coords document) const docRect = { left: _viewportToDocumentX(x), top: _viewportToDocumentY(y), right: _viewportToDocumentX(x + w), bottom: _viewportToDocumentY(y + h) }; let overlapsOther = false; for (const p of pinnedPopups) { // Ne pas comparer avec un popup qui est dans le dock (réduit) if (p.el && p.el.classList && p.el.classList.contains("pinned-popup-reduced")) continue; if (_rectsOverlap(docRect, p.rect)) { overlapsOther = true; break; } } if (!overlapsOther) { return { viewportX: x, viewportY: y, docX: docRect.left, docY: docRect.top, rect: docRect }; } } // v2026.5.20 : ultime fallback — accepter de chevaucher mais décaler // un peu par rapport au 1er popup épinglé existant. Évite complètement // le "Pas de place" injuste. if (pinnedPopups.length > 0) { const last = pinnedPopups[pinnedPopups.length - 1]; let x = (last.rect.left - (window.scrollX || 0)) + 30; let y = (last.rect.top - (window.scrollY || 0)) + 30; if (x + w > safe.right) x = safe.right - w; if (y + h > safe.bottom) y = safe.bottom - h; if (x < safe.left) x = safe.left; if (y < safe.top) y = safe.top; const docRect = { left: _viewportToDocumentX(x), top: _viewportToDocumentY(y), right: _viewportToDocumentX(x + w), bottom: _viewportToDocumentY(y + h) }; return { viewportX: x, viewportY: y, docX: docRect.left, docY: docRect.top, rect: docRect }; } return null; } /** * v4.3.0 : épingle la bulle courante en la clonant dans un popup détaché * ancré au contenu. Le tooltip live redevient disponible. */ function pinTooltip() { if (!state.currentTooltipIv) return; const srcEl = tooltipEl(); if (!srcEl) return; const iv = state.currentTooltipIv; // v2026.5.21 : unicité actionId + date. Si un popup pour la même ref // ET la même date est déjà épinglé, on le supprime et on re-crée un nouveau // (user a choisi ce comportement : "tu supprime le popup actuellement // épinglé et tu répingle la nouvelle fenêtre"). const currentDate = state.currentDate || ""; const existingKey = (iv.actionId || "") + "|" + currentDate; for (let i = pinnedPopups.length - 1; i >= 0; i--) { const p = pinnedPopups[i]; if (!p || !p.el) continue; const aid = p.el.dataset.actionId || ""; const d = p.el.dataset.originDate || ""; if (aid + "|" + d === existingKey) { // Retirer l'ancien (popup + pastille dock éventuelle) if (p.el._linkedPill) { try { p.el._linkedPill.remove(); } catch (e) {} } try { p.el.remove(); } catch (e) {} pinnedPopups.splice(i, 1); } } // Nettoyer un éventuel dock devenu vide const dockEl = document.getElementById("pinned-popups-dock"); if (dockEl && dockEl.querySelectorAll(".pinned-popup-dock-pill").length === 0) { dockEl.classList.remove("visible"); const closeAllBtn = document.getElementById("pinned-popups-close-all"); if (closeAllBtn) closeAllBtn.remove(); } // Chercher la ligne source (row iv-v2) let rowEl = null; if (iv.actionId) { rowEl = document.querySelector(`.intervention-v2[data-action-id="${iv.actionId}"]`); } if (!rowEl) { // Fallback : utiliser la position actuelle du tooltip live rowEl = srcEl; } // Cloner le contenu du tooltip actuel en popup détaché const popup = document.createElement("div"); popup.className = "tooltip pinned-popup visible"; popup.dataset.actionId = iv.actionId || ""; popup.innerHTML = srcEl.innerHTML; // v2026.5.18 : mémoriser la ref et la couleur pour le dock (pastille avec // couleur de catégorie + texte ref) popup.dataset.ref = iv.ref || ""; popup.dataset.colorKey = (typeof deriveColorKey === "function" ? deriveColorKey(iv) : "autre") || "autre"; // v2026.5.19 : mémoriser aussi la date pour l'afficher sur la pastille dock popup.dataset.originDate = state.currentDate || ""; // v2026.5.17 : masquer l'icône 📌 du contenu cloné (redondante car le // popup a sa propre topbar avec le bouton "désépingler" 📍 explicite) const oldPin = popup.querySelector('.tooltip-pinbtn[data-action="pin"]'); if (oldPin) oldPin.remove(); // v2026.5.17 : topbar avec 3 boutons pour un popup épinglé : // v2026.5.18 : swap des actions — _ réduit dans le dock, ▭ minimise flottant // _ = Réduire (docké dans la taskbar du bas) // ▭ = Minimiser (popup reste flottant mais compact, juste la ref) // 📍 = Désépingler (l'icône d'épingle "plantée" ; clic = retire l'épingle) const topbar = document.createElement("div"); topbar.className = "pinned-popup-topbar"; // Bouton Réduire (icône _ ) const reduceBtn = document.createElement("button"); reduceBtn.type = "button"; reduceBtn.className = "pinned-popup-btn pinned-popup-reduce"; reduceBtn.innerHTML = "_"; reduceBtn.title = "Réduire (docké en bas de l'écran)"; reduceBtn.addEventListener("click", (e) => { e.stopPropagation(); _reducePinnedPopup(popup); }); topbar.appendChild(reduceBtn); // Bouton Minimiser (icône ▭ ) const minBtn = document.createElement("button"); minBtn.type = "button"; minBtn.className = "pinned-popup-btn pinned-popup-minimize"; minBtn.innerHTML = "▭"; minBtn.title = "Minimiser (reste flottant mais compact)"; minBtn.addEventListener("click", (e) => { e.stopPropagation(); _minimizePinnedPopup(popup); }); topbar.appendChild(minBtn); // v2026.5.19 : Bouton Actualiser (même icône SVG que le tooltip standard) // Re-fetch la fiche de l'intervention pour mettre à jour les infos (statut, // commentaires, action text) sans recharger le planning entier. const refreshBtn = document.createElement("button"); refreshBtn.type = "button"; refreshBtn.className = "pinned-popup-btn pinned-popup-refresh"; refreshBtn.innerHTML = ''; refreshBtn.title = "Actualiser les informations de cette intervention"; refreshBtn.addEventListener("click", async (e) => { e.stopPropagation(); if (refreshBtn.classList.contains("spinning")) return; refreshBtn.classList.add("spinning"); try { await _refreshPinnedPopupIv(popup, iv); } finally { setTimeout(() => refreshBtn.classList.remove("spinning"), 300); } }); topbar.appendChild(refreshBtn); // Bouton Désépingler (icône épingle plantée) const unpinBtn = document.createElement("button"); unpinBtn.type = "button"; unpinBtn.className = "pinned-popup-btn pinned-popup-unpin"; unpinBtn.innerHTML = "📍"; unpinBtn.title = "Désépingler (se ferme quand la souris sort)"; unpinBtn.addEventListener("click", (e) => { e.stopPropagation(); _softUnpinPopup(popup); }); topbar.appendChild(unpinBtn); popup.appendChild(topbar); // v4.3.3 : barre de drag en haut, pour déplacer la popup à la souris. // Ancrée en haut à 22px de haut ; le padding-top de la popup est augmenté // côté CSS pour ne pas que le contenu soit caché derrière. const dragbar = document.createElement("div"); dragbar.className = "pinned-popup-dragbar"; dragbar.title = "Glissez pour déplacer"; popup.appendChild(dragbar); _attachPopupDragHandler(popup, dragbar); // v4.3.0 : le popup contient un clone du tooltip live, qui inclut le // bouton 📌. Dans un popup déjà épinglé, ce bouton devient "désépingler". // On intercepte le clic ici, avant qu'il remonte. popup.addEventListener("click", (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; if (action === "pin") { e.stopPropagation(); e.preventDefault(); _softUnpinPopup(popup); } // Les autres actions (reload, copy-ref, etc.) ne sont pas gérées ici ; // on pourrait les ajouter plus tard si besoin. }); // Placer en (0,0) temporairement pour mesurer la taille popup.style.position = "absolute"; popup.style.left = "-9999px"; popup.style.top = "-9999px"; popup.style.visibility = "hidden"; document.body.appendChild(popup); // Mesurer après rendu const pRect = popup.getBoundingClientRect(); const w = pRect.width; const h = pRect.height; // Chercher une position libre const pos = _findFreePopupPosition(rowEl, w, h); if (!pos) { // Pas de place : retirer et afficher un toast popup.remove(); showToast("Pas de place", "Fermez une popup épinglée"); return; } // Appliquer la position (coords document = position: absolute) popup.style.left = pos.docX + "px"; popup.style.top = pos.docY + "px"; popup.style.visibility = "visible"; // v2026.5.20 : clamper dans la safe area (topbar + dock) _clampPopupInSafeArea(popup); // Enregistrer dans la liste pinnedPopups.push({ el: popup, iv: iv, rect: pos.rect }); // v4.3.0 : libérer le tooltip live (il redevient utilisable pour d'autres survols) bulleState.pinned = false; bulleState.hoveredInRow = false; bulleState.hoveredInBulle = false; srcEl.classList.remove("visible", "pinned"); srcEl.classList.add("hidden"); if (srcEl.dataset) delete srcEl.dataset.mode; state.currentTooltipIv = null; currentTooltipPos = null; tooltipPositionMode = null; if (bulleState.hideTimer) { clearTimeout(bulleState.hideTimer); bulleState.hideTimer = null; } } /** Ferme un popup épinglé donné. */ function _closePinnedPopup(el) { const idx = pinnedPopups.findIndex(p => p.el === el); if (idx >= 0) pinnedPopups.splice(idx, 1); el.remove(); } /** * v2026.5.19 : re-fetch les infos d'une intervention et met à jour le contenu * du popup épinglé correspondant. Utilise fetchAndUpdateIntervention qui fait * xhr2 + fiche, puis régénère le HTML du tooltip avec buildTooltipHTML. */ async function _refreshPinnedPopupIv(popup, iv) { if (!popup || !iv) return; try { // Forcer le refetch : on invalide les flags qui disent "déjà fetché" iv.xhr2Fetched = false; iv.xhr2Fetching = false; iv.ficheFetched = false; iv.ficheFetching = false; // Token de refresh actuel (pour que fetchAndUpdateIntervention ne soit // pas abortée par les checks isRefreshAborted) const token = (typeof currentRefreshToken !== "undefined") ? currentRefreshToken : 0; await fetchAndUpdateIntervention(iv, token); // Régénérer le HTML du tooltip avec les nouvelles infos. // On doit réinjecter juste le contenu, en gardant la topbar et la dragbar // (qui ne sont PAS dans le tooltip source, elles sont propres au popup). const topbar = popup.querySelector(".pinned-popup-topbar"); const dragbar = popup.querySelector(".pinned-popup-dragbar"); const newHtml = buildTooltipHTML(iv); popup.innerHTML = newHtml; // Virer aussi la vieille icône 📌 si elle revient dans le rebuild const oldPin = popup.querySelector('.tooltip-pinbtn[data-action="pin"]'); if (oldPin) oldPin.remove(); // Remettre topbar et dragbar if (topbar) popup.appendChild(topbar); if (dragbar) popup.appendChild(dragbar); } catch (err) { console.warn("[refresh-popup]", err); } } /** * Désépinglage "mou" : la popup n'est plus considérée épinglée (elle n'est * plus dans pinnedPopups, donc le comptage pour Ctrl×2 etc. ignore) mais on * la laisse visible. Elle disparait quand la souris sort. */ function _softUnpinPopup(el) { // Retirer de la liste (pour le comptage Ctrl×2) mais garder le DOM const idx = pinnedPopups.findIndex(p => p.el === el); if (idx >= 0) pinnedPopups.splice(idx, 1); // v4.3.3 corr : basculer visuellement en tooltip normal (retirer tous les // attributs visuels du mode épinglé : bordure bleue, dragbar, bouton ×, // padding-top, etc.). La classe .soft-unpinned fait ça côté CSS. // On retire .pinned-popup pour que les règles visuelles lourdes // disparaissent, tout en gardant la popup au même endroit (position // absolute conservée). el.classList.remove("pinned-popup"); el.classList.add("soft-unpinned"); // Icône 📌 → 📍 pour le clin d'œil (même si elle va bientôt disparaitre) const pinBtn = el.querySelector('[data-action="pin"]'); if (pinBtn) pinBtn.textContent = "📍"; // Supprimer les éléments propres au mode épinglé : barre de drag et × const dragbar = el.querySelector(".pinned-popup-dragbar"); if (dragbar) dragbar.remove(); const closeBtn = el.querySelector(".pinned-popup-close"); if (closeBtn) closeBtn.remove(); // v2026.5.17 : retirer aussi la nouvelle topbar et le conteneur minimisé const topbar = el.querySelector(".pinned-popup-topbar"); if (topbar) topbar.remove(); el.classList.remove("pinned-popup-minimized"); el.classList.remove("pinned-popup-reduced"); // v2026.5.18 : retirer aussi la pastille du dock si elle existe if (el._linkedPill) { try { el._linkedPill.remove(); } catch (e) {} el._linkedPill = null; } // Si le dock est vide, le cacher ; mettre à jour le bouton "Fermer tous" const dock = document.getElementById("pinned-popups-dock"); if (dock && dock.querySelectorAll(".pinned-popup-dock-pill").length === 0) { dock.classList.remove("visible"); const closeAllBtn = document.getElementById("pinned-popups-close-all"); if (closeAllBtn) closeAllBtn.remove(); } else { _ensureDockCloseAllBtn(); } // v2026.5.22 : si le tooltip hover est actuellement affiché pour la même // intervention que celle qu'on désépingle, il faut regénérer son HTML pour // que l'icône passe de 📍 (active rouge) à 📌 (non active) — sinon l'user // voit l'ancienne icône et croit qu'il est toujours épinglé. const tip = tooltipEl(); if (tip && tip.classList.contains("visible") && state.currentTooltipIv) { tip.innerHTML = buildTooltipHTML(state.currentTooltipIv); } // Helper qui joue l'animation de sortie puis supprime le DOM const animateAndRemove = () => { el.classList.add("unpinning"); setTimeout(() => el.remove(), 180); }; if (!el.matches(":hover")) { animateAndRemove(); return; } // Souris dessus : on ne supprime pas tout de suite. On attend mouseleave // et à ce moment on joue l'animation de sortie et on supprime. el.addEventListener("mouseleave", animateAndRemove, { once: true }); } // ============================================================================ // v2026.5.17 : États d'un popup épinglé // - Normal (complet, flottant) // - Minimisé (compact, flottant, juste la ref + topbar) // - Réduit (docké dans la taskbar en bas de l'écran) // ============================================================================ /** * Passe un popup épinglé en mode Minimisé : on ne montre plus que la ref, * dans un petit cadre flottant toujours drag-able. * * v2026.5.19 : au lieu de masquer tout le contenu via CSS et tenter de * réafficher la ref (fragile), on crée un élément dédié `.pinned-popup-minref` * qui contient juste la ref + la date. Cet élément est ajouté/retiré au besoin. */ function _minimizePinnedPopup(popup) { if (!popup) return; popup.classList.add("pinned-popup-minimized"); // Adapter les boutons topbar : [▭] devient [⬆] (agrandir) const minBtn = popup.querySelector(".pinned-popup-minimize"); if (minBtn) { minBtn.innerHTML = "⬆"; minBtn.title = "Agrandir"; // On retire les anciens listeners en clonant l'élément const newBtn = minBtn.cloneNode(true); minBtn.replaceWith(newBtn); newBtn.addEventListener("click", (e) => { e.stopPropagation(); _expandPinnedPopup(popup); }); } // Créer un élément dédié pour afficher la ref en mode minimisé let minRef = popup.querySelector(".pinned-popup-minref"); if (!minRef) { minRef = document.createElement("div"); minRef.className = "pinned-popup-minref"; const refText = popup.dataset.ref || "(sans ref)"; minRef.textContent = refText; minRef.title = "Cliquer pour agrandir"; minRef.addEventListener("click", (e) => { e.stopPropagation(); _expandPinnedPopup(popup); }); popup.appendChild(minRef); } } /** * Repasse un popup minimisé en mode Normal (complet). */ function _expandPinnedPopup(popup) { if (!popup) return; popup.classList.remove("pinned-popup-minimized"); // Restaurer bouton Minimiser const minBtn = popup.querySelector(".pinned-popup-minimize"); if (minBtn) { minBtn.innerHTML = "▭"; minBtn.title = "Minimiser (reste flottant mais compact)"; const newBtn = minBtn.cloneNode(true); minBtn.replaceWith(newBtn); newBtn.addEventListener("click", (e) => { e.stopPropagation(); _minimizePinnedPopup(popup); }); } // Retirer l'élément ref dédié (s'il existe) const minRef = popup.querySelector(".pinned-popup-minref"); if (minRef) minRef.remove(); } /** * Passe un popup épinglé en mode Réduit : il disparaît de son emplacement * flottant et vient s'ajouter dans une taskbar en bas de l'écran sous forme * de pastille cliquable. */ function _reducePinnedPopup(popup) { if (!popup) return; // Récupérer la référence pour le label de la pastille // v2026.5.18 : préférer le dataset.ref mémorisé à la création plutôt que // le textContent (qui peut contenir "—" si la ref n'était pas encore // disponible à l'épinglage) const refEl = popup.querySelector(".iv-ref-header"); const label = popup.dataset.ref || (refEl ? (refEl.textContent || "").trim() : "") || "Popup"; const colorKey = popup.dataset.colorKey || "autre"; // S'assurer que la taskbar du bas existe let dock = document.getElementById("pinned-popups-dock"); if (!dock) { dock = document.createElement("div"); dock.id = "pinned-popups-dock"; dock.className = "pinned-popups-dock"; document.body.appendChild(dock); } // Créer la pastille dock // v2026.5.18 : le fond de la pastille prend la couleur de catégorie // (via la classe color-XXX déjà utilisée ailleurs dans le CSS) // v2026.5.19 : pastille à 2 lignes — ref (gras) + date origine (petit) const pill = document.createElement("button"); pill.type = "button"; pill.className = "pinned-popup-dock-pill color-" + colorKey; pill.title = "Cliquer pour agrandir"; const pillRef = document.createElement("span"); pillRef.className = "pinned-popup-dock-pill-ref"; pillRef.textContent = label; pill.appendChild(pillRef); // Date d'origine (ex: "21.04") const originDate = popup.dataset.originDate || ""; if (originDate) { const pillDate = document.createElement("span"); pillDate.className = "pinned-popup-dock-pill-date"; pillDate.textContent = _formatDateShort(originDate); pill.appendChild(pillDate); } // Mémoriser la position/taille du popup avant de le masquer const rect = popup.getBoundingClientRect(); popup.dataset.prevLeft = popup.style.left || (rect.left + "px"); popup.dataset.prevTop = popup.style.top || (rect.top + "px"); popup.dataset.prevWidth = popup.style.width || ""; // Cacher le popup (on le garde en DOM pour conserver son état et restaurer // instantanément) popup.classList.add("pinned-popup-reduced"); // Associer pill ↔ popup pill._linkedPopup = popup; popup._linkedPill = pill; pill.addEventListener("click", (e) => { e.stopPropagation(); _restorePinnedPopupFromDock(popup); }); // v2026.5.20 : mini-menu au survol (Agrandir / Fermer) pill.addEventListener("mouseenter", () => { _showPillHoverMenu(pill, popup); }); pill.addEventListener("mouseleave", (e) => { // Le menu peut être sous la souris — on ne ferme pas si on entre dans le menu _schedulePillMenuClose(); }); dock.appendChild(pill); dock.classList.add("visible"); // v2026.5.18 : s'assurer qu'il y a un bouton "Fermer tous" si 2+ popups _ensureDockCloseAllBtn(); // v2026.5.20 : le dock qui apparaît peut chevaucher des popups flottants — // les reclamper pour qu'ils restent dans la safe area. _reclampAllFloatingPopups(); } /** * v2026.5.20 : affiche un mini-menu au-dessus d'une pastille dock au survol. * Contient 2 actions : Agrandir, Fermer. */ let _pillMenuCloseTimer = null; function _showPillHoverMenu(pill, popup) { // Annuler une fermeture en cours if (_pillMenuCloseTimer) { clearTimeout(_pillMenuCloseTimer); _pillMenuCloseTimer = null; } // S'il existe déjà un menu pour un autre pill, le fermer const existing = document.getElementById("pill-hover-menu"); if (existing) { if (existing._linkedPill === pill) return; // déjà pour ce pill existing.remove(); } const menu = document.createElement("div"); menu.id = "pill-hover-menu"; menu.className = "pill-hover-menu"; menu._linkedPill = pill; menu._linkedPopup = popup; const restoreBtn = document.createElement("button"); restoreBtn.type = "button"; restoreBtn.className = "pill-hover-menu-btn"; restoreBtn.innerHTML = ' Agrandir'; restoreBtn.addEventListener("click", (e) => { e.stopPropagation(); _hidePillHoverMenu(); _restorePinnedPopupFromDock(popup); }); menu.appendChild(restoreBtn); const closeBtn = document.createElement("button"); closeBtn.type = "button"; closeBtn.className = "pill-hover-menu-btn pill-hover-menu-close"; closeBtn.innerHTML = ' Fermer'; closeBtn.addEventListener("click", (e) => { e.stopPropagation(); _hidePillHoverMenu(); // Retirer le popup de la liste et supprimer le DOM const idx = pinnedPopups.findIndex(p => p.el === popup); if (idx >= 0) pinnedPopups.splice(idx, 1); try { popup.remove(); } catch (err) {} try { pill.remove(); } catch (err) {} const dock = document.getElementById("pinned-popups-dock"); if (dock && dock.querySelectorAll(".pinned-popup-dock-pill").length === 0) { dock.classList.remove("visible"); const closeAllBtn = document.getElementById("pinned-popups-close-all"); if (closeAllBtn) closeAllBtn.remove(); _reclampAllFloatingPopups(); } else { _ensureDockCloseAllBtn(); } }); menu.appendChild(closeBtn); document.body.appendChild(menu); // Positionner au-dessus de la pastille const r = pill.getBoundingClientRect(); const menuR = menu.getBoundingClientRect(); let left = r.left + (r.width / 2) - (menuR.width / 2); if (left < 4) left = 4; if (left + menuR.width > window.innerWidth - 4) left = window.innerWidth - menuR.width - 4; menu.style.left = left + "px"; menu.style.top = (r.top - menuR.height - 8) + "px"; // Garder ouvert si la souris entre dans le menu menu.addEventListener("mouseenter", () => { if (_pillMenuCloseTimer) { clearTimeout(_pillMenuCloseTimer); _pillMenuCloseTimer = null; } }); menu.addEventListener("mouseleave", () => { _schedulePillMenuClose(); }); } function _schedulePillMenuClose() { if (_pillMenuCloseTimer) clearTimeout(_pillMenuCloseTimer); _pillMenuCloseTimer = setTimeout(() => { _hidePillHoverMenu(); _pillMenuCloseTimer = null; }, 250); } function _hidePillHoverMenu() { const existing = document.getElementById("pill-hover-menu"); if (existing) existing.remove(); } /** * v2026.5.20 : calcule la safe area pour les popups épinglés. * Retourne {top, bottom, left, right} en coords viewport. * - top : hauteur de la topbar (les popups ne doivent pas passer dessous) * - bottom : top du dock si visible, sinon hauteur viewport */ function _getPopupSafeArea() { let topLimit = 4; const topbar = document.querySelector("header.topbar"); if (topbar) { const r = topbar.getBoundingClientRect(); if (r.bottom > topLimit) topLimit = r.bottom + 4; } let bottomLimit = window.innerHeight - 4; const dock = document.getElementById("pinned-popups-dock"); if (dock && dock.classList.contains("visible")) { const r = dock.getBoundingClientRect(); if (r.top < bottomLimit) bottomLimit = r.top - 4; } return { top: topLimit, bottom: bottomLimit, left: 4, right: window.innerWidth - 4 }; } /** * v2026.5.20 : contraint un popup flottant (en coords document via style.left/top) * dans la safe area. Appelé à l'épinglage, pendant le drag, et quand le dock * apparaît/disparaît. */ function _clampPopupInSafeArea(popup) { if (!popup) return; if (popup.classList.contains("pinned-popup-reduced")) return; // pas clamp si docké const safe = _getPopupSafeArea(); const rect = popup.getBoundingClientRect(); const w = rect.width || popup.offsetWidth || 280; const h = rect.height || popup.offsetHeight || 200; // Les coords viewport actuelles const vLeft = rect.left; const vTop = rect.top; // Calcul des coords viewport cibles après clamp let newVLeft = vLeft; let newVTop = vTop; if (newVLeft < safe.left) newVLeft = safe.left; if (newVLeft + w > safe.right) newVLeft = safe.right - w; if (newVLeft < safe.left) newVLeft = safe.left; // si popup plus large que viewport if (newVTop < safe.top) newVTop = safe.top; if (newVTop + h > safe.bottom) newVTop = safe.bottom - h; if (newVTop < safe.top) newVTop = safe.top; if (newVLeft === vLeft && newVTop === vTop) return; // rien à faire // Différence = appliquer au style.left / style.top (qui sont en document coords) const dx = newVLeft - vLeft; const dy = newVTop - vTop; const curLeft = parseFloat(popup.style.left) || 0; const curTop = parseFloat(popup.style.top) || 0; popup.style.left = (curLeft + dx) + "px"; popup.style.top = (curTop + dy) + "px"; } /** * Réclampe tous les popups flottants (utile après apparition/disparition du dock). */ function _reclampAllFloatingPopups() { document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced)").forEach(p => { _clampPopupInSafeArea(p); }); } /** * v2026.5.19 : réduit TOUS les popups épinglés actuellement ouverts (en mode * normal ou minimisé) dans la taskbar du bas. Appelé au changement de date. */ function _reduceAllPinnedPopups() { const popups = document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced)"); popups.forEach(popup => { try { _reducePinnedPopup(popup); } catch (e) {} }); } /** * v2026.5.19 : ISO date (YYYY-MM-DD) → format court "DD.MM" pour le dock. */ function _formatDateShort(iso) { if (!iso) return ""; const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!m) return iso; return `${m[3]}.${m[2]}`; } /** * v2026.5.18 : ajoute (ou met à jour) le bouton "Fermer tous" dans le dock * quand au moins 2 popups épinglés existent (réduits OU affichés). * Le bouton est placé à droite du dock. */ function _ensureDockCloseAllBtn() { const dock = document.getElementById("pinned-popups-dock"); if (!dock) return; const allPinned = document.querySelectorAll(".pinned-popup"); let closeAllBtn = document.getElementById("pinned-popups-close-all"); if (allPinned.length >= 2) { if (!closeAllBtn) { closeAllBtn = document.createElement("button"); closeAllBtn.type = "button"; closeAllBtn.id = "pinned-popups-close-all"; closeAllBtn.className = "pinned-popups-close-all"; closeAllBtn.textContent = "✕ Fermer tous"; closeAllBtn.title = "Fermer tous les popups épinglés"; closeAllBtn.addEventListener("click", (e) => { e.stopPropagation(); closeAllPinnedPopups(); }); dock.appendChild(closeAllBtn); } else { // Remettre à la fin (après les pastilles éventuellement ajoutées) dock.appendChild(closeAllBtn); } dock.classList.add("visible"); } else if (closeAllBtn) { closeAllBtn.remove(); } } /** * Ramène un popup réduit en mode Normal : retire la pastille du dock et * réaffiche le popup flottant à sa position d'avant réduction. */ function _restorePinnedPopupFromDock(popup) { if (!popup) return; popup.classList.remove("pinned-popup-reduced"); // Si le popup était minimisé avant d'être réduit, on l'agrandit direct // (la demande était : "Si la reduit et rappeller s'affiche en grand direct") popup.classList.remove("pinned-popup-minimized"); const minBtn = popup.querySelector(".pinned-popup-minimize"); if (minBtn) { minBtn.innerHTML = "▭"; minBtn.title = "Minimiser (reste flottant mais compact)"; const newBtn = minBtn.cloneNode(true); minBtn.replaceWith(newBtn); newBtn.addEventListener("click", (e) => { e.stopPropagation(); _minimizePinnedPopup(popup); }); } // Supprimer la pastille associée if (popup._linkedPill) { popup._linkedPill.remove(); popup._linkedPill = null; } // Si le dock est vide (sauf le bouton "Fermer tous"), le masquer const dock = document.getElementById("pinned-popups-dock"); if (dock) { const remainingPills = dock.querySelectorAll(".pinned-popup-dock-pill").length; if (remainingPills === 0) { dock.classList.remove("visible"); const closeAllBtn = document.getElementById("pinned-popups-close-all"); if (closeAllBtn) closeAllBtn.remove(); } else { _ensureDockCloseAllBtn(); } } } /** Ferme toutes les popups épinglées (appelé par Échap ou changement de date). */ /** * v5.0.1 : helper pour déclencher la suppression d'une absence ou réservation. * Affiche la modal de confirmation, puis appelle le background. */ function _triggerDeleteItem(actionId, kind, triggerBtn) { if (!actionId) return; const label = kind === "reservation" ? "cette réservation" : "cette absence"; showAlertModal({ title: "Confirmer la suppression", message: `Voulez-vous vraiment supprimer ${label} ? Cette action est irréversible.`, buttons: [ { label: "Annuler", variant: "secondary", action: () => {} }, { label: "Supprimer", variant: "danger", action: async () => { if (triggerBtn) { triggerBtn.disabled = true; triggerBtn.textContent = "Suppression…"; } try { const resp = await sendMessage({ type: "deletePlanningItem", actionId: actionId, kind: kind }); if (!resp || !resp.ok) { throw new Error(resp && resp.error ? resp.error : "erreur inconnue"); } showToast("Supprimé", "L'élément a été retiré du planning."); unpinTooltip(); closeAllPinnedPopups(); if (state.session) { await loadForDate(state.currentDate, { forceRefetch: true }); } } catch (err) { showAlertModal({ title: "Erreur lors de la suppression", message: "Impossible de supprimer : " + (err.message || err), buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); if (triggerBtn) { triggerBtn.disabled = false; triggerBtn.textContent = "🗑 Supprimer l'absence"; } } } } ] }); } function closeAllPinnedPopups() { for (const p of pinnedPopups.slice()) { p.el.remove(); } pinnedPopups.length = 0; // Fermer aussi les popups en état soft-unpinned qui trainent encore document.querySelectorAll(".pinned-popup.soft-unpinned").forEach(el => el.remove()); // v2026.5.18 : supprimer aussi les éléments du dock document.querySelectorAll(".pinned-popup").forEach(el => el.remove()); document.querySelectorAll(".pinned-popup-dock-pill").forEach(el => el.remove()); const closeAllBtn = document.getElementById("pinned-popups-close-all"); if (closeAllBtn) closeAllBtn.remove(); const dock = document.getElementById("pinned-popups-dock"); if (dock) dock.classList.remove("visible"); } /** * v4.3.3 : permet de déplacer une popup épinglée à la souris via sa barre * de drag. Met à jour les coords document (position absolute) et le rect * mémorisé dans pinnedPopups pour que les nouvelles popups évitent bien * la nouvelle position. */ function _attachPopupDragHandler(popup, dragbar) { let dragging = false; let startMouseX = 0, startMouseY = 0; let startLeft = 0, startTop = 0; const onMouseMove = (e) => { if (!dragging) return; const dx = e.clientX - startMouseX; const dy = e.clientY - startMouseY; let newLeft = startLeft + dx; let newTop = startTop + dy; // v2026.5.20 : clamper dans la safe area (topbar en haut, dock en bas, // bordures viewport gauche/droite). On calcule en coords viewport puis // on applique en coords document. popup.style.left = newLeft + "px"; popup.style.top = newTop + "px"; _clampPopupInSafeArea(popup); }; const onMouseUp = () => { if (!dragging) return; dragging = false; popup.classList.remove("dragging"); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); // v2026.5.19 : retirer la classe body et le flag global après un petit // délai pour laisser le temps au mouseleave de la carte de se propager // sans déclencher de tooltip parasite. document.body.classList.remove("popup-dragging"); setTimeout(() => { state._popupDragging = false; }, 50); // Mettre à jour le rect mémorisé pour la détection de chevauchement const entry = pinnedPopups.find(p => p.el === popup); if (entry) { const l = parseFloat(popup.style.left) || 0; const t = parseFloat(popup.style.top) || 0; const w = popup.offsetWidth; const h = popup.offsetHeight; entry.rect = { left: l, top: t, right: l + w, bottom: t + h }; } }; dragbar.addEventListener("mousedown", (e) => { // Seulement bouton gauche if (e.button !== 0) return; e.preventDefault(); dragging = true; startMouseX = e.clientX; startMouseY = e.clientY; startLeft = parseFloat(popup.style.left) || 0; startTop = parseFloat(popup.style.top) || 0; popup.classList.add("dragging"); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); } // 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"); if (el.dataset) delete el.dataset.mode; state.currentTooltipIv = null; currentTooltipPos = null; tooltipPositionMode = 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 : v4.3.0 // - Si 0 popup épinglé ET un tooltip live visible : épingler // - Si EXACTEMENT 1 popup épinglé ET souris pas dessus : le fermer // - Si 2+ popups épinglés : ne fait rien (ambigu, user doit utiliser Échap) // 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; if (e.repeat) return; const now = performance.now(); if (now - lastCtrlTs < 400) { lastCtrlTs = 0; if (pinnedPopups.length === 0) { // Aucun popup épinglé : épingler le tooltip live s'il y en a un if (state.currentTooltipIv) pinTooltip(); } else if (pinnedPopups.length === 1) { // 1 popup épinglé : le fermer si la souris n'est pas dessus const p = pinnedPopups[0]; if (!p.el.matches(":hover")) { _closePinnedPopup(p.el); } } // 2+ popups : rien faire (Échap pour tout fermer) } 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") { // v4.3.0 : toujours épingler (le tooltip live clone son contenu en popup // détaché). Pour désépingler, l'user utilise × sur le popup, ou Échap. 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(() => {}); } } else if (action === "delete-item") { // v5.0.0 : supprimer absence/réservation (depuis tooltip) const actionId = btn.dataset.actionId; const kind = btn.dataset.kind || "absence"; _triggerDeleteItem(actionId, kind, btn); } }); // 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 interventoin (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 interventoin), // 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)); // v5.0.0 : bouton supprimer pour les réservations (avec confirmation) rows.push(`
`); return `
${rows.join("")}
`; } // v5.0.0 : cas spécial absence (congé, maladie, formation, pompier, ...) if (iv.type === "AL-Absence") { const label = iv.label || "Absence"; rows.push(`
Type
${escapeHtml(label)}
`); if (iv.startTime && iv.endTime) { rows.push(row("Horaire", `${iv.startTime}–${iv.endTime}`)); } // Pour les absences récurrentes (Pillonel vendredi), pas d'actionId réel // → pas de bouton supprimer. Pour les autres → oui. if (iv.actionId) { rows.push(`
`); } 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"); // v5.0.10 : rebrancher le bouton "Ouvrir EasyVista" natif pour qu'il // appelle triggerReconnect() au lieu de juste ouvrir un onglet. Ça // déclenche la reconnexion SSO ET l'auto-reload du viewer quand la // nouvelle session est détectée. // On renomme aussi le bouton pour être explicite. const btn = b.querySelector("#session-banner-reconnect"); if (btn && !btn.dataset.boundReconnect) { btn.dataset.boundReconnect = "1"; btn.textContent = "🔄 Me reconnecter"; // Retirer d'éventuels anciens listeners en clonant le bouton const clone = btn.cloneNode(true); btn.parentNode.replaceChild(clone, btn); clone.addEventListener("click", () => triggerReconnect()); } } hideEvUnreachableBanner(); hideReconnectingBanner(); } function hideSessionExpiredBanner() { const b = document.getElementById("session-expired-banner"); if (b) b.classList.add("hidden"); } // v5.0.9 : bannière affichée pendant la reconnexion (remplace la bannière // expirée après clic sur "Me reconnecter") // v5.0.11 : ajoute un bouton "Annuler" pour interrompre le processus. function showReconnectingBanner() { let b = document.getElementById("session-reconnecting-banner"); if (!b) { b = document.createElement("div"); b.id = "session-reconnecting-banner"; b.className = "banner-reconnecting"; const topbar = document.querySelector(".topbar") || document.querySelector("header") || document.body; if (topbar.nextSibling) { topbar.parentNode.insertBefore(b, topbar.nextSibling); } else { document.body.insertBefore(b, document.body.firstChild); } } b.innerHTML = ` `; const cancelBtn = b.querySelector(".banner-cancel-btn"); if (cancelBtn) cancelBtn.addEventListener("click", () => cancelReconnect()); b.classList.remove("hidden"); hideSessionExpiredBanner(); hideReconnectFailedBanner(); } function hideReconnectingBanner() { const b = document.getElementById("session-reconnecting-banner"); if (b) b.classList.add("hidden"); } // v5.0.11 : bannière "Reconnexion échouée" avec choix manuel du réseau // (Bureau/Télétravail). Affichée après timeout 90s de reconnexion. function showReconnectFailedBanner() { let b = document.getElementById("session-reconnect-failed-banner"); if (!b) { b = document.createElement("div"); b.id = "session-reconnect-failed-banner"; b.className = "banner-reconnect-failed"; const topbar = document.querySelector(".topbar") || document.querySelector("header") || document.body; if (topbar.nextSibling) { topbar.parentNode.insertBefore(b, topbar.nextSibling); } else { document.body.insertBefore(b, document.body.firstChild); } } b.innerHTML = ` `; // Boutons de choix de réseau : retry avec origine forcée b.querySelectorAll(".banner-btn-primary").forEach(btn => { btn.addEventListener("click", () => { const origin = btn.dataset.origin; hideReconnectFailedBanner(); triggerReconnect(origin); }); }); // Bouton Annuler : retour à la bannière "Session expirée" simple const cancelBtn = b.querySelector(".banner-cancel-btn"); if (cancelBtn) cancelBtn.addEventListener("click", () => { hideReconnectFailedBanner(); showSessionExpiredBanner(); }); b.classList.remove("hidden"); hideSessionExpiredBanner(); hideReconnectingBanner(); } function hideReconnectFailedBanner() { const b = document.getElementById("session-reconnect-failed-banner"); if (b) b.classList.add("hidden"); } // v4.2.5 : bannière non bloquante "EasyVista inaccessible" function showEvUnreachableBanner() { const b = document.getElementById("ev-unreachable-banner"); if (b) b.classList.remove("hidden"); // On masque la bannière session expirée (1 seule bannière à la fois) hideSessionExpiredBanner(); } function hideEvUnreachableBanner() { const b = document.getElementById("ev-unreachable-banner"); if (b) b.classList.add("hidden"); }