/** * Planification — Extension navigateur EasyVista (Canton de Vaud / DGNSI) * * Vue claire et rapide du planning des techniciens : interventions, * réservations, absences, statut pompier, etc. Regroupé par tech avec * timelines visuelles et popups détaillés. * * Copyright (c) 2026 Quentin Rouiller * Licensed under the MIT License — see LICENSE file in the project root. * * @author Quentin Rouiller * @repository https://gitea.netaplaid.ch/FroSteel/Planification */ // ============================================================================ // 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. // ============================================================================ // ============================================================================ // v2026.5.38 : Observabilité — logger unifié + handlers globaux // ============================================================================ // Toutes les erreurs runtime (uncaught + unhandled rejection) sont attrapées // par les listeners ci-dessous et loggées avec un préfix clair, un timestamp, // et la stack trace si dispo. L'idée : qd l'user lance F12 et copie la console, // on a tout pour reproduire et corriger. // // Format des logs : [PREFIX][LEVEL] message {context} // - LEVEL : INFO / WARN / ERROR // - PREFIX : module qui logue (load, currentUser, session, viewMode, ...) // - message : un truc lisible humain // - context : objet sérialisable avec les vars utiles au debug // ============================================================================ // Clef localStorage pour le mode debug — exposé dans le panel admin. // Quand activé : LOG.info() devient visible. Sinon : muet. // LOG.warn et LOG.error sont TOUJOURS visibles, peu importe le flag. const DEBUG_LOGS_KEY = "debug_logs"; const LOG = (() => { // On capture la version du manifest une fois, pour la mettre dans les logs // d'erreur (utile qd l'user nous envoie un screenshot console). let _version = "?"; try { if (chrome && chrome.runtime && chrome.runtime.getManifest) { _version = chrome.runtime.getManifest().version || "?"; } } catch (e) { // chrome.runtime peut ne pas être dispo dans certains contextes } // État debug — relu depuis localStorage. Peut être toggle à la volée // depuis le panel admin sans reload. let _debug = false; try { _debug = localStorage.getItem(DEBUG_LOGS_KEY) === "1"; } catch (e) { // localStorage peut être bloqué (mode privé) — on reste en mode normal } const _stamp = () => new Date().toISOString().substring(11, 23); // HH:MM:SS.mmm const _format = (level, prefix, msg, ctx) => { const head = `[${_stamp()}][v${_version}][${prefix}][${level}]`; if (ctx !== undefined) return [head, msg, ctx]; return [head, msg]; }; return { // info() : visible UNIQUEMENT si debug activé. Pour les étapes verbose. info: (prefix, msg, ctx) => { if (!_debug) return; console.log(..._format("INFO", prefix, msg, ctx)); }, // warn / error : toujours visibles warn: (prefix, msg, ctx) => console.warn (..._format("WARN", prefix, msg, ctx)), error:(prefix, msg, ctx) => console.error(..._format("ERROR", prefix, msg, ctx)), // Erreur "interne" — affiche stack + serialize l'objet erreur exception: (prefix, msg, err, extra) => { const ctx = { name: err && err.name, message: err && err.message, stack: err && err.stack, extra: extra }; console.error(..._format("ERROR", prefix, msg, ctx)); }, // Toggle à la volée depuis le panel admin setDebug: (on) => { _debug = !!on; try { localStorage.setItem(DEBUG_LOGS_KEY, _debug ? "1" : "0"); } catch (e) {} // Synchroniser avec le service worker (qui a son propre flag) try { chrome.runtime.sendMessage({ type: "setDebugLogs", on: _debug }, () => { // on ignore lastError volontairement : le SW peut être en sommeil if (chrome.runtime.lastError) { /* ok */ } }); } catch (e) {} console.log(..._format("INFO", "logger", `mode debug = ${_debug ? "ON" : "OFF"}`)); }, isDebug: () => _debug, version: () => _version }; })(); // Global error handler : attrape les exceptions qui passent à travers tous les // try/catch. L'user voit un toast, on logue tout en console. window.addEventListener("error", (event) => { LOG.exception("global", "uncaught error", event.error || new Error(event.message || "unknown"), { filename: event.filename, lineno: event.lineno, colno: event.colno }); // Toast non-bloquant — on suppose que showToast est dispo (init en haut) try { if (typeof showToast === "function") { showToast("Erreur inattendue", "Voir la console (F12) pour le détail"); } } catch (e) { /* showToast peut ne pas être init au tout début */ } }); // Promesses rejetées sans .catch() — typiquement des async/await sans try window.addEventListener("unhandledrejection", (event) => { const reason = event.reason; LOG.exception("global", "unhandled promise rejection", reason instanceof Error ? reason : new Error(String(reason)), { promise: "unhandled" }); try { if (typeof showToast === "function") { showToast("Erreur asynchrone", "Voir la console (F12) pour le détail"); } } catch (e) { /* idem */ } }); LOG.info("boot", "viewer.js chargé", { version: LOG.version() }); // v2026.5.40 r22 : pattern simple et fiable pour fermer le tooltip // - mouseleave row → timer 500ms (démarré dans hideTooltip) // - mouseenter popup → clearTimeout (annule la fermeture en cours) // - mouseleave popup → hideTooltip (démarre un nouveau timer 500ms) // - mouseenter row → showTooltip (annule la fermeture aussi) // Pas de flags hoveredInBulle/hoveredInRow, pas de watchdog mousemove, // pas de elementFromPoint : juste des events natifs qui sont fiables. // Source : pattern recommandé dans Tippy.js, jQuery PowerTip, MDN Popover API. // ============================================================================ // Configuration // ============================================================================ // v2026.5.41 : plus aucune équipe / absence récurrente codée en dur. // L'utilisateur configure tout depuis Paramètres → Équipe : // - cfg.team = { id: name } — techniciens à afficher // - cfg.recurringAbsences = { id: [days] } — jours d'absence récurrente // (chrome.storage.local["admin_config"], persiste entre les mises à jour) // // TEAM et RECURRING_ABSENCES sont rechargées au boot depuis admin_config par // _initTeamFromConfig() (appelée tôt dans init()). Tant qu'elles ne sont pas // chargées, elles restent vides → aucun fetch tenté. let TEAM = {}; let RECURRING_ABSENCES = {}; async function _initTeamFromConfig() { try { const cfg = await loadAdminConfig(); TEAM = cfg.team || {}; RECURRING_ABSENCES = cfg.recurringAbsences || {}; } catch (e) { console.warn("[boot] _initTeamFromConfig err", e); } } // 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 CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD // 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() { await initTheme(); // v2026.5.39 : appliquer le zoom texte enregistré dès le boot, avant que // le DOM ne soit affiché (sinon "flash" à la taille par défaut). _initTextZoomFromConfig(); // v2026.5.39 : lire les heures de la journée depuis admin_config (8-18 défaut). await _initDayBoundsFromConfig(); // v2026.5.41 : charger l'équipe et les absences récurrentes depuis admin_config. // Avant ce point, TEAM = {} → aucun fetch ne tournera tant que la config // n'est pas chargée. Si l'utilisateur n'a rien configuré, le fetch retournera // l'erreur "no_team_configured" qui invite à ouvrir les paramètres. await _initTeamFromConfig(); 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 _applyViewMode(); // v2026.5.32 : appliquer la vue sauvegardée 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. // v2026.5.26 : en cas d'échec, affiche un rond gris "?" + retry 60s (max 10 essais). // v2026.5.34 : le badge est maintenant TOUJOURS visible (état "?" par défaut // dans le HTML). Cette fonction met à jour le contenu (initiales // quand succès, "?" quand échec). Logs abondants pour debug. // // État initial (HTML) : // État succès : initiales calculées + couleur dérivée du nom // État échec : "?" + couleur grise (classe user-badge-unknown) // // Retry : 10 tentatives espacées de 60s (10 min max), arrêt au 1er succès. let _currentUserRetryCount = 0; const _CURRENT_USER_MAX_RETRIES = 10; const _CURRENT_USER_RETRY_DELAY_MS = 60 * 1000; /** * Récupère le nom de l'utilisateur EasyVista connecté et l'affiche dans le badge. * * @author Quentin Rouiller */ async function fetchAndShowCurrentUser() { const attemptId = _currentUserRetryCount + 1; console.log(`[currentUser] tentative ${attemptId}/${_CURRENT_USER_MAX_RETRIES + 1} de fetchCurrentUser`); const badge = document.getElementById("user-badge"); if (!badge) { // Fallback défensif : pas de badge dans le DOM ? On log et on abandonne. console.warn("[currentUser] badge DOM introuvable — abandon"); return; } let success = false; let errorReason = null; try { const resp = await sendMessage({ type: "fetchCurrentUser" }); console.log("[currentUser] réponse reçue :", resp ? JSON.stringify(resp).substring(0, 200) : "(null)"); if (!resp) { errorReason = "response_null"; } else if (!resp.ok) { errorReason = resp.error || "ok_false"; } else if (!resp.user) { errorReason = "user_null"; } else { const fullName = resp.user.name || resp.user.login || null; if (!fullName) { errorReason = "name_empty"; } else { // ✅ Succès : mise à jour du badge const initials = computeUserInitials(fullName); console.log(`[currentUser] SUCCÈS : "${fullName}" → initiales "${initials}"`); badge.textContent = initials; badge.title = fullName; badge.style.setProperty("--user-badge-color", colorFromName(fullName)); badge.classList.remove("user-badge-unknown"); // On retire aussi "hidden" au cas où (compat ancienne version) badge.classList.remove("hidden"); state.currentUser = resp.user; success = true; _currentUserRetryCount = 0; // reset compteur au succès } } } catch (err) { errorReason = "exception: " + String(err); console.warn("[currentUser] exception durant sendMessage :", err); } if (success) return; // ❌ Échec : on laisse le badge en état "inconnu" (déjà le cas par défaut) // et on schedule un retry. console.warn(`[currentUser] échec (raison: ${errorReason}) — badge reste en état "?"`); // Défense : s'assurer que le badge est bien en état inconnu (au cas où // une mise à jour partielle a eu lieu puis échoué). badge.textContent = "?"; badge.title = "Utilisateur — cliquer pour accéder aux paramètres"; badge.style.setProperty("--user-badge-color", "#6b7280"); badge.classList.add("user-badge-unknown"); badge.classList.remove("hidden"); // Schedule retry si pas trop d'essais if (_currentUserRetryCount < _CURRENT_USER_MAX_RETRIES) { _currentUserRetryCount++; console.log(`[currentUser] retry programmé : ${_currentUserRetryCount}/${_CURRENT_USER_MAX_RETRIES} dans ${_CURRENT_USER_RETRY_DELAY_MS / 1000}s`); setTimeout(() => { fetchAndShowCurrentUser(); }, _CURRENT_USER_RETRY_DELAY_MS); } else { console.warn("[currentUser] max retries atteint, arrêt du retry automatique. Le badge reste cliquable (⚙ Paramètres accessible)."); } } /** * v2026.5.34 : déclenche un fetchAndShowCurrentUser() SI le user n'est pas * encore connu (badge en état "?"). Appelée après chaque succès de planning * pour profiter d'une session EV valide sans attendre le retry de 60s. * * Sans effet si : * - state.currentUser est déjà renseigné (pas besoin de re-fetcher) * - un retry est déjà en cours (évite les doublons) * * @param {string} reason - contexte pour les logs (ex: "after_load_success") * * @author Quentin Rouiller */ function _maybeRetryFetchUser(reason) { if (state.currentUser && state.currentUser.name) { // User déjà connu, rien à faire return; } const badge = document.getElementById("user-badge"); if (badge && !badge.classList.contains("user-badge-unknown")) { // Badge n'est pas en état inconnu → user probablement connu par un autre chemin return; } console.log(`[currentUser] relance opportuniste (raison: ${reason}) — user encore inconnu`); // Reset le compteur puisqu'on a un nouveau contexte (session fraîche) _currentUserRetryCount = 0; fetchAndShowCurrentUser(); } // 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; } // v2026.5.17 : afficher aussi le temps restant de la session (MM:SS) avec // une couleur qui dépend du seuil (vert/jaune/rouge). // v2026.5.26 : si user inconnu, afficher "Utilisateur inconnu" + retry + réglages popup.innerHTML = ""; const nameEl = document.createElement("div"); nameEl.className = "user-name-popup-name"; if (state.currentUser && state.currentUser.name) { nameEl.textContent = state.currentUser.name; } else { nameEl.textContent = "Utilisateur inconnu"; nameEl.style.fontStyle = "italic"; nameEl.style.color = "var(--text-muted)"; } 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); // v2026.5.25 : bouton Paramètres (remplace les 5 clics sur le titre) // v2026.5.32 : bouton "Vue" pour basculer Vue classique ↔ Vue horizontale // v2026.5.39 r7 : on affiche la vue de DESTINATION (pas la vue actuelle), // et le logo s'adapte pour visualiser comment seront affichées les // interventions après clic. // - en classique : on propose Horizontale + logo "rangées" (≡) // - en horizontal : on propose Classique + logo "grille" (⊞) const viewBtn = document.createElement("button"); viewBtn.type = "button"; viewBtn.className = "user-name-popup-settings"; const currentView = _getCurrentView(); if (currentView === "horizontal") { viewBtn.innerHTML = ' Passer en vue Classique'; } else { viewBtn.innerHTML = ' Passer en vue Horizontale'; } viewBtn.title = "Bascule entre vue classique (cards en grille) et vue horizontale (1 ligne par tech)"; viewBtn.addEventListener("click", (e) => { e.stopPropagation(); hideUserNamePopup(); _toggleView(); }); popup.appendChild(viewBtn); const settingsBtn = document.createElement("button"); settingsBtn.type = "button"; settingsBtn.className = "user-name-popup-settings"; settingsBtn.innerHTML = ' Paramètres'; settingsBtn.title = "Ouvrir les paramètres d'administration"; settingsBtn.addEventListener("click", (e) => { e.stopPropagation(); hideUserNamePopup(); if (typeof showAdminPanel === "function") { showAdminPanel(); } }); popup.appendChild(settingsBtn); // v2026.5.38 : signature auteur en bas du popup user-badge // Style : même look que le footer "QRO/vX.Y.Z" en bas à droite — petit, // gris atténué, séparé par une fine ligne supérieure. const authorLine = document.createElement("div"); authorLine.className = "user-name-popup-author"; authorLine.textContent = "Développé par Quentin Rouiller"; authorLine.title = "Auteur de l'extension"; popup.appendChild(authorLine); 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 // ============================================================================ // v2026.5.41 : thème unifié sur admin_config.theme (chrome.storage.local). // Le toggle topbar écrit dans la même clé que le select Apparence du panel, // pour que les deux soient toujours en accord. async function initTheme() { let pref = "auto"; try { const cfg = await loadAdminConfig(); if (cfg.theme === "light" || cfg.theme === "dark" || cfg.theme === "auto") { pref = cfg.theme; } } catch (e) {} _applyTheme(pref); _watchOsThemeChanges(); } function detectDefaultTheme() { if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { return "dark"; } return "light"; } async function toggleTheme() { // L'effectif courant (light ou dark), même si pref="auto" const currentAttr = document.documentElement.getAttribute("data-theme"); const effective = (currentAttr === "light" || currentAttr === "dark") ? currentAttr : detectDefaultTheme(); const next = effective === "dark" ? "light" : "dark"; _applyTheme(next); try { const cfg = await loadAdminConfig(); cfg.theme = next; await saveAdminConfig(cfg); } catch (e) { console.warn("[theme] toggle save err", e); } } // ============================================================================ // 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é). /** * Pied de page discret bas-droite avec QRO et numéro de version. * * @author Quentin Rouiller */ 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) { LOG.warn("footer", "getManifest indispo (mode dégradé sans version)", { err: e && e.message }); } const el = document.createElement("div"); el.className = "app-footer"; el.textContent = `QRO${version ? " / " + version : ""}`; document.body.appendChild(el); } // v2026.5.32 : bascule entre Vue classique (cards) et Vue horizontale (rows) // Persisté dans localStorage (clé : "view_mode"). Défaut : "classic". const VIEW_MODE_KEY = "view_mode"; function _getCurrentView() { try { const v = localStorage.getItem(VIEW_MODE_KEY); return v === "horizontal" ? "horizontal" : "classic"; } catch (e) { return "classic"; } } function _setCurrentView(mode) { try { localStorage.setItem(VIEW_MODE_KEY, mode === "horizontal" ? "horizontal" : "classic"); } catch (e) { // localStorage peut être bloqué (mode privé Firefox, quota plein) LOG.warn("viewMode", "localStorage indispo, vue non persistée", { err: e && e.message }); } _applyViewMode(); } function _toggleView() { const current = _getCurrentView(); const next = current === "horizontal" ? "classic" : "horizontal"; _setCurrentView(next); } /** * v2026.5.36 : applique le mode de vue (classique/horizontal) en déplaçant * physiquement les éléments de la topbar vers/depuis une sidebar verticale * à gauche de l'écran. * * En vue horizontale : * - Sidebar gauche verticale contenant (haut → bas) : * · Navigation date (prev / date / next / aujourd'hui) * · Horloge + date (compacte, une par ligne) * · Info de synchro * · Stats globales (interventions/techs/absents) * · Boutons actions (Absence, Douchette, Actualiser, Tout recharger, Vider cache) * - Topbar réduite à : user-badge + titre + theme-toggle * * En vue classique : * - Tout est remis dans la topbar comme avant (topbar-left / topbar-right) * * On mémorise les parents d'origine sur chaque élément (data-orig-parent) * pour restaurer proprement en vue classique. * * v2026.5.38 : on ne log plus que les warnings (parent absent etc.) — les * logs verbeux étape par étape ont été retirés, ils polluaient la console * à chaque toggle. * * @author Quentin Rouiller */ function _applyViewMode() { const mode = _getCurrentView(); // Mettre à jour la classe sur pour les règles CSS document.documentElement.classList.remove("view-classic", "view-horizontal"); document.documentElement.classList.add("view-" + mode); // Liste des IDs à déplacer entre topbar (classique) et sidebar (horizontal) // Ordre = ordre visuel dans la sidebar en mode horizontal (haut → bas) // v2026.5.37 : user-badge, app-title, theme-toggle déplacés aussi → la topbar // devient vide en vue horizontale. const ELEMENTS_TO_RELOCATE = [ "user-badge", // tout en haut de la sidebar "app-title", // juste après user-badge "date-nav", // (pas un id mais une classe, traité à part) "app-clock", "capture-info", "stats", // conteneur stats globales (généré dynamiquement) "absence-btn", "douchette-btn", "refresh-partial-btn", "refresh-btn", "abort-btn", "clear-cache-btn", "theme-toggle" // tout en bas de la sidebar ]; if (mode === "horizontal") { _moveElementsToSidebar(ELEMENTS_TO_RELOCATE); } else { _restoreElementsToTopbar(ELEMENTS_TO_RELOCATE); } // v2026.5.39 r2 : label complet "Aujourd'hui" en sidebar (plus large), "Auj." // en topbar classique (plus compact). On gère ça ici car on n'utilise pas // de pseudo-element CSS (problèmes avec le reflow). const navToday = document.getElementById("nav-today"); if (navToday) { navToday.textContent = (mode === "horizontal") ? "Aujourd'hui" : "Auj."; } } /** * v2026.5.36 : crée ou retrouve la sidebar, et y déplace les éléments listés. * Les éléments sont déplacés dans l'ordre du tableau. * * @author Quentin Rouiller */ function _moveElementsToSidebar(ids) { // Créer (ou retrouver) le wrapper qui contiendra sidebar + main côte à côte let wrapper = document.getElementById("horizontal-wrapper"); const mainEl = document.getElementById("main"); if (!wrapper) { wrapper = document.createElement("div"); wrapper.id = "horizontal-wrapper"; wrapper.className = "horizontal-wrapper"; // Placer le wrapper à la place de
, puis mettre
DEDANS if (mainEl && mainEl.parentNode) { mainEl.parentNode.insertBefore(wrapper, mainEl); wrapper.appendChild(mainEl); } else { console.warn("[viewMode]
introuvable, wrapper ajouté à "); document.body.appendChild(wrapper); } } // Créer la sidebar si elle n'existe pas, et la mettre EN PREMIER dans le wrapper let sidebar = document.getElementById("horizontal-sidebar"); if (!sidebar) { sidebar = document.createElement("aside"); sidebar.id = "horizontal-sidebar"; sidebar.className = "horizontal-sidebar"; wrapper.insertBefore(sidebar, wrapper.firstChild); } else if (sidebar.parentNode !== wrapper) { // Sidebar existe ailleurs, on la remet dans le wrapper wrapper.insertBefore(sidebar, wrapper.firstChild); } // Traitement spécial : .date-nav (classe, pas id) // v2026.5.37 : en vue horizontale, on décompose .date-nav pour : // - Mettre btn-today en haut // - Intercaler app-clock entre btn-today et date-custom // - Grouper les 2 flèches dans un wrapper #sidebar-arrows pour qu'elles // soient côte à côte et même largeur const dateNav = document.querySelector(".date-nav"); if (dateNav) { _memorizeOriginalParent(dateNav); sidebar.appendChild(dateNav); // Créer le wrapper des flèches si pas déjà fait, et y mettre prev + next let arrowsWrap = document.getElementById("sidebar-arrows"); if (!arrowsWrap) { arrowsWrap = document.createElement("div"); arrowsWrap.id = "sidebar-arrows"; sidebar.appendChild(arrowsWrap); } const prevBtn = document.getElementById("nav-prev"); const nextBtn = document.getElementById("nav-next"); if (prevBtn && !arrowsWrap.contains(prevBtn)) { _memorizeOriginalParent(prevBtn); arrowsWrap.appendChild(prevBtn); } if (nextBtn && !arrowsWrap.contains(nextBtn)) { _memorizeOriginalParent(nextBtn); arrowsWrap.appendChild(nextBtn); } } // Pour chaque ID listé, trouver et déplacer for (const id of ids) { if (id === "date-nav") continue; // déjà traité const el = document.getElementById(id); if (!el) { continue; } _memorizeOriginalParent(el); sidebar.appendChild(el); } } /** * v2026.5.36 : restaure chaque élément à son parent d'origine (mémorisé dans * data-orig-parent). Utilisé quand on revient en vue classique. * * @author Quentin Rouiller */ function _restoreElementsToTopbar(ids) { // v2026.5.37 : d'abord remettre les flèches dans .date-nav (avant de // restaurer .date-nav à son parent d'origine), puis supprimer le wrapper // #sidebar-arrows. const dateNav = document.querySelector(".date-nav"); const prevBtn = document.getElementById("nav-prev"); const nextBtn = document.getElementById("nav-next"); if (dateNav && prevBtn && !dateNav.contains(prevBtn)) { // Remettre prev en premier dans date-nav dateNav.insertBefore(prevBtn, dateNav.firstChild); } if (dateNav && nextBtn && !dateNav.contains(nextBtn)) { // Remettre next après date-custom-wrapper (position d'origine) : // l'ordre d'origine est [prev, date-custom-wrapper, next, today]. const dateCustomWrap = dateNav.querySelector(".date-custom-wrapper"); if (dateCustomWrap && dateCustomWrap.nextSibling) { dateNav.insertBefore(nextBtn, dateCustomWrap.nextSibling); } else { dateNav.appendChild(nextBtn); } } // Supprimer le wrapper des flèches (normalement vide maintenant) const arrowsWrap = document.getElementById("sidebar-arrows"); if (arrowsWrap) { arrowsWrap.remove(); } // Restaurer .date-nav à son parent d'origine if (dateNav) _restoreToOriginalParent(dateNav, ".date-nav"); for (const id of ids) { if (id === "date-nav") continue; const el = document.getElementById(id); if (!el) continue; _restoreToOriginalParent(el, "#" + id); } // Supprimer la sidebar si elle existe et est vide const sidebar = document.getElementById("horizontal-sidebar"); if (sidebar && sidebar.children.length === 0) { sidebar.remove(); } // Sortir
du wrapper et supprimer le wrapper const wrapper = document.getElementById("horizontal-wrapper"); if (wrapper) { const mainEl = wrapper.querySelector("#main"); if (mainEl && wrapper.parentNode) { wrapper.parentNode.insertBefore(mainEl, wrapper); } wrapper.remove(); } } function _memorizeOriginalParent(el) { if (el.dataset.origParent) return; // déjà mémorisé const parent = el.parentNode; if (!parent || !parent.id) { // Parent sans id : on mémorise par selector (parent tag + classes) let selector = parent.tagName.toLowerCase(); if (parent.className) { const cls = parent.className.split(/\s+/)[0]; if (cls) selector += "." + cls; } el.dataset.origParent = "SEL:" + selector; } else { el.dataset.origParent = "ID:" + parent.id; } // Mémoriser aussi l'index d'origine pour restaurer l'ordre const siblings = Array.from(parent.children); el.dataset.origIndex = String(siblings.indexOf(el)); } function _restoreToOriginalParent(el, label) { const orig = el.dataset.origParent; if (!orig) { return; } let parent = null; if (orig.startsWith("ID:")) { parent = document.getElementById(orig.substring(3)); } else if (orig.startsWith("SEL:")) { parent = document.querySelector(orig.substring(4)); } if (!parent) { console.warn(`[viewMode] parent d'origine pour ${label} introuvable (orig=${orig})`); return; } // Insérer à l'index d'origine si possible const idx = parseInt(el.dataset.origIndex || "0", 10); const refChild = parent.children[idx] || null; if (refChild && refChild !== el) { parent.insertBefore(el, refChild); } else { parent.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). // v2026.5.27 : date courte "Jeudi 23.04.26" sur la même ligne que l'heure, // séparées par un gros point "•", même taille que l'heure. /** * Horloge HH:MM au milieu de la topbar, mise à jour chaque minute (depuis v5.0.0). * * @author Quentin Rouiller */ 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"); const JOURS = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]; 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 courte : "Jeudi 23.04.26" if (dateEl) { const jour = JOURS[d.getDay()]; const dd = String(d.getDate()).padStart(2, "0"); const mm = String(d.getMonth() + 1).padStart(2, "0"); const yy = String(d.getFullYear()).slice(-2); const dateStr = `${jour} ${dd}.${mm}.${yy}`; 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); }); } // ============================================================================ // 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"); // v2026.5.34 : relancer fetchUser tout de suite (au lieu d'attendre // le retry de 60s) — la session vient d'être renouvelée, c'est le // meilleur moment pour récupérer le user. _maybeRetryFetchUser("session_reconnected"); // 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(e => LOG.warn("session", "getSessionRemaining a échoué (silencieux)", { err: e && e.message })); // 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) { // v2026.5.38 : addEventListener plutôt que .onclick — meilleur tracage // si plus tard on veut detach le handler proprement. extendBtn.addEventListener("click", 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" — v2026.5.40 r12 : // - succès → fermer l'alerte + toast bas-droite "Session prolongée" // - échec → fermer l'alerte + toast erreur + ouvrir la bannière / // écran de reconnexion EasyVista 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; 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) { // Échec : on ferme cette mini-alerte, on affiche un toast d'erreur, // puis on bascule sur la bannière "session expirée" (laquelle propose // un bouton pour ouvrir EasyVista et se reconnecter). _hideSessionSlideAlert(); showToast("Échec prolongation", "Reconnectez-vous à EasyVista"); LOG.warn("session", "extendSession a échoué", { err: err && err.message }); if (typeof handleSessionExpired === "function") { handleSessionExpired(); } } }); // 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 }); 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"; // v2026.5.39 : Apparence en 1re. À propos en dernière (après Diagnostics). const sections = [ { id: "appearance", label: "Apparence", render: renderAdminSectionAppearance }, { id: "team", label: "Équipe", render: renderAdminSectionTeam }, { id: "easyvista", label: "EasyVista", render: renderAdminSectionEV }, { id: "diagnostics",label: "Diagnostics", render: renderAdminSectionDiagnostics }, { id: "about", label: "À propos", render: renderAdminSectionAbout } ]; let currentSection = "appearance"; 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) { // v2026.5.41 : application immédiate sans demander de recharger. // 1) repeupler TEAM / RECURRING_ABSENCES en mémoire depuis la nouvelle config. // 2) refetcher le planning du jour courant. // 3) le toast reste visible au-dessus du panel grâce au z-index 11000. await _initTeamFromConfig(); showToast("Config enregistrée", "Mise à jour du planning…"); try { await loadForDate(state.currentDate); } catch (e) { console.warn("[admin] reload planning err", e); } } 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); // ---- v2026.5.41 : Sélecteur de groupe EasyVista (SI-CSS / SI-EXT / …) ---- // Placé en tête de la section Équipe : c'est le groupe qui détermine quels // techniciens sont listés. Détection live depuis le